Compare commits

...

18 Commits

Author SHA1 Message Date
bf5fd83f98 feat(cart): integrate confirmation modals for item removal in cart component 2026-07-02 12:04:29 -03:00
446d1b8970 feat(modal): add confirm and confirm delete modals with tests
- Implemented ConfirmModalComponent and ConfirmDeleteModalComponent for confirmation dialogs.
- Added modal service methods to open confirm and confirm delete modals.
- Created corresponding HTML and SCSS files for both modal components.
- Updated ReutilizablesTestPageComponent to utilize new modals.
- Enhanced modal service tests to cover new functionality.
- Refactored existing tests to accommodate changes in modal behavior and structure.
2026-07-02 11:45:47 -03:00
b3782a6cc1 feat: implement modal service and components for enhanced user interaction, including modal showcase in demo page 2026-07-02 11:24:31 -03:00
d31cb65d65 feat: update cart service and components to handle productVariantId, improve item removal and addition feedback 2026-07-02 11:00:40 -03:00
c9d8265790 feat: enhance cart functionality with quantity update and removal features, integrating toast notifications for user feedback 2026-07-02 10:38:26 -03:00
f2cc42e0cc feat: implement cart overlay and dropdown with styling for improved user experience 2026-07-02 09:59:24 -03:00
5ac6704300 feat: set selected variant on product load in product detail page 2026-07-02 09:49:11 -03:00
4817dd1f76 feat: rename stock to cantidad_maxima in product variant interfaces and update related components for consistency 2026-07-02 09:39:06 -03:00
16eea32ce7 feat: implement cart icon component with quantity badge and integrate into store header for improved UI 2026-07-02 09:27:01 -03:00
8a6ed763df feat: enhance icon button and quantity selector styles for improved UI and functionality 2026-07-02 09:16:33 -03:00
0a7a9d91c7 feat: add icon button component and integrate into cart item for improved UI 2026-07-02 09:13:33 -03:00
202205c038 feat: integrate quantity selector component into cart and product detail pages for enhanced functionality 2026-07-02 09:01:30 -03:00
394a1dde0d feat: implement quantity selector component for improved cart functionality 2026-07-02 08:47:52 -03:00
8471505e6f feat: refactor cart item and cart components for improved layout and styling 2026-07-02 08:42:35 -03:00
06d29be056 feat: enhance cart layout and styling for improved user experience 2026-07-01 16:56:29 -03:00
af406c215a feat: implement cart component with mock data and styling for improved cart functionality 2026-07-01 16:51:23 -03:00
ac9d1fdc85 feat: update store header and input styles for improved UI consistency 2026-07-01 16:50:24 -03:00
cb77ef9d20 feat: add product detail page with variant selection and cart integration 2026-07-01 16:18:39 -03:00
57 changed files with 3324 additions and 209 deletions

View File

@@ -1,6 +1,7 @@
@if (status() === 'ready') {
<router-outlet />
<app-toast-container />
<app-modal-host />
} @else if (status() === 'not-found') {
<section class="tenant-status">
<div class="tenant-status__card">

View File

@@ -2,10 +2,13 @@ import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { of } from 'rxjs';
import { App } from './app';
import { routes } from './app.routes';
import { Tenant } from './core/services/tenant.interface';
import { TenantService } from './core/services/tenant.service';
import { CartService } from './core/services/cart/cart.service';
const tenant: Tenant = {
id: 1,
@@ -57,6 +60,13 @@ async function renderAppAt(
{
provide: TenantService,
useValue: tenantService
},
{
provide: CartService,
useValue: {
cart: signal(null).asReadonly(),
loadCart: () => of({ id: 1, items: [], subtotal: '0' })
}
}
]
}).compileComponents();

View File

@@ -1,6 +1,12 @@
import '@angular/compiler';
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { TestBed, getTestBed } from '@angular/core/testing';
import {
BrowserTestingModule,
platformBrowserTesting
} from '@angular/platform-browser/testing';
import { provideRouter } from '@angular/router';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { App } from './app';
import { Tenant } from './core/services/tenant.interface';
@@ -34,6 +40,21 @@ function createTenantServiceStub(status: 'ready' | 'not-found', currentTenant: T
}
describe('App', () => {
beforeAll(() => {
try {
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting()
);
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
});
afterEach(() => {
TestBed.resetTestingModule();
});
it('creates the app when the tenant is ready', async () => {
await TestBed.configureTestingModule({
imports: [App],

View File

@@ -3,6 +3,7 @@ import { RouterOutlet } from '@angular/router';
import { TenantService } from './core/services/tenant.service';
import { DEFAULT_TENANT_BRANDING } from './core/services/tenant-ssr-cache.store';
import { ModalHostComponent } from './shared/components/modal-host/modal-host.component';
import { ToastContainerComponent } from './shared/components/toast-container/toast-container.component';
function hexToRgb(hex: string): string {
@@ -22,7 +23,7 @@ function hexToRgb(hex: string): string {
@Component({
selector: 'app-root',
imports: [RouterOutlet, ToastContainerComponent],
imports: [RouterOutlet, ToastContainerComponent, ModalHostComponent],
templateUrl: './app.html',
styleUrl: './app.scss',
host: {

View File

@@ -1,13 +1,13 @@
<header class="store-layout__header">
<div class="store-layout__header-main">
<div class="container-xl px-3 px-md-4 py-3 py-lg-4 d-flex flex-wrap align-items-center justify-content-between gap-3">
<div class="container-xl px-3 px-md-4 py-3 py-lg-4 d-flex flex-row flex-wrap align-items-center justify-content-between gap-3">
<div
class="store-layout__brand-slot d-inline-flex align-items-center gap-3 flex-shrink-0"
data-testid="store-header-brand-slot"
aria-label="Logo del comercio"
>
@if (logoUrl) {
<img class="store-layout__brand-logo" [src]="logoUrl" alt="Logo del comercio" />
@if (logoUrl()) {
<img class="store-layout__brand-logo" [src]="logoUrl()" alt="Logo del comercio" />
} @else {
<div class="store-layout__brand-logo-shell" aria-hidden="true">
<span class="store-layout__brand-logo-skeleton"></span>
@@ -15,8 +15,8 @@
}
</div>
<div class="d-flex flex-column flex-md-row align-items-stretch align-items-md-center gap-3 ms-auto w-100 w-lg-auto">
<div class="input-group store-layout__search" role="search">
<div class="d-flex flex-row align-items-center gap-3 ms-auto flex-nowrap w-100 justify-content-between justify-content-md-end">
<div class="input-group store-layout__search flex-grow-1" role="search">
<label class="visually-hidden" for="store-search-input">Buscar productos</label>
<input
id="store-search-input"
@@ -34,16 +34,17 @@
</div>
<div class="d-flex align-items-center justify-content-end gap-1" aria-label="Acciones de usuario">
@for (action of headerActions; track action.label) {
<button
type="button"
class="btn btn-link store-layout__action-button p-2 text-decoration-none"
[attr.aria-label]="action.label"
[attr.title]="action.label"
>
<i [class]="action.iconClass" aria-hidden="true"></i>
</button>
}
<app-cart-icon
[quantity]="cartQuantity()"
ariaLabel="Carrito de compras"
title="Carrito"
(click)="cartClick.emit()"
/>
<app-icon-button
variant="user"
ariaLabel="Mi cuenta"
title="Mi cuenta"
/>
</div>
</div>
</div>

View File

@@ -42,50 +42,55 @@
.store-layout__search {
width: min(100%, 24rem);
min-width: 18rem;
min-width: 0;
}
.store-layout__search-input {
border-color: rgba(255, 255, 255, 0.2);
color: #666666;
border-color: #cccccc;
background-color: rgba(255, 255, 255, 0.96);
}
.store-layout__search-input::placeholder {
color: #666666;
}
.store-layout__search-input:focus {
border-color: var(--tenant-primary);
box-shadow: 0 0 0 0.25rem color-mix(in srgb, white 70%, var(--tenant-primary));
}
.store-layout__search-button {
color: #ffffff;
border-color: rgba(255, 255, 255, 0.2);
background-color: color-mix(in srgb, white 10%, var(--tenant-header-bg));
color: #a0a0a0;
border-color: #cccccc;
background-color: rgba(255, 255, 255, 0.96);
}
.store-layout__search-button:hover,
.store-layout__search-button:focus-visible {
color: #ffffff;
border-color: rgba(255, 255, 255, 0.28);
background-color: color-mix(in srgb, white 16%, var(--tenant-header-bg));
color: #8a8a8a;
border-color: #cccccc;
background-color: #ffffff;
}
.store-layout__action-button {
color: #ffffff;
color: #a0a0a0;
}
.store-layout__action-button:hover,
.store-layout__action-button:focus-visible {
color: color-mix(in srgb, white 84%, var(--tenant-primary));
color: #8a8a8a;
}
.store-layout__category-trigger {
font-size: 0.8rem;
letter-spacing: 0.02em;
color: #ffffff;
color: #a0a0a0;
}
.store-layout__category-trigger:hover,
.store-layout__category-trigger:focus-visible {
color: color-mix(in srgb, white 84%, var(--tenant-primary));
color: #8a8a8a;
}
@keyframes store-header-logo-skeleton {
@@ -102,8 +107,4 @@
.store-layout__brand-slot {
min-width: 0;
}
.store-layout__search {
min-width: 0;
}
}

View File

@@ -1,17 +1,15 @@
import { Component, Input } from '@angular/core';
export type StoreHeaderAction = {
label: string;
iconClass: string;
};
import { Component, input, output } from '@angular/core';
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
@Component({
selector: 'app-store-header',
imports: [],
imports: [CartIconComponent, IconButtonComponent],
templateUrl: './store-header.component.html',
styleUrl: './store-header.component.scss'
})
export class StoreHeaderComponent {
@Input({ required: true }) headerActions: StoreHeaderAction[] = [];
@Input() logoUrl: string | null = null;
readonly logoUrl = input<string | null>(null);
readonly cartQuantity = input<number>(0);
readonly cartClick = output<void>();
}

View File

@@ -1,5 +1,27 @@
<div class="store-layout d-flex flex-column flex-grow-1">
<app-store-header [headerActions]="headerActions" [logoUrl]="tenant()?.header_logo ?? null" />
<app-store-header
[cartQuantity]="cartQuantity()"
[logoUrl]="tenant()?.header_logo ?? null"
(cartClick)="isCartOpen.set(!isCartOpen())"
/>
@if (isCartOpen()) {
<div class="store-layout__cart-overlay" (click)="isCartOpen.set(false)"></div>
<div class="store-layout__cart-dropdown card border-0 shadow-lg">
<app-cart
[showClose]="true"
[items]="mappedCartItems()"
[subtotal]="cartSubtotal()"
[discount]="cartDiscount()"
[total]="cartTotal()"
[backgroundColor]="'#ffffff'"
(closed)="isCartOpen.set(false)"
>
<app-button variant="secondary" class="flex-grow-1">Seguir comprando</app-button>
<app-button variant="primary" class="flex-grow-1">Comprar</app-button>
</app-cart>
</div>
}
<main class="flex-grow-1 d-block">
<router-outlet />

View File

@@ -10,3 +10,53 @@
#f6f6f6;
color: #202020;
}
.store-layout {
position: relative;
}
.store-layout__cart-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 1040;
background: transparent;
}
.store-layout__cart-dropdown {
position: absolute;
top: 76px;
right: calc((100% - 1320px) / 2 + 1.5rem);
height: 520px;
max-height: calc(100vh - 120px);
z-index: 1050;
background-color: #ffffff;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.08);
overflow: hidden;
animation: store-layout-slide-down 0.2s ease-out;
@media (max-width: 1400px) {
right: 1.5rem;
}
@media (max-width: 576px) {
right: 1rem;
left: 1rem;
width: auto;
}
}
@keyframes store-layout-slide-down {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -2,8 +2,12 @@ import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { Tenant } from '../../services/tenant.interface';
import { TenantService } from '../../services/tenant.service';
import { CartService } from '../../services/cart/cart.service';
import { ToastService } from '../../services/toast.service';
import { StoreLayoutComponent } from './store-layout.component';
const tenant: Tenant = {
@@ -37,6 +41,25 @@ describe('StoreLayoutComponent', () => {
getTenant: () => tenantState(),
bootstrap: vi.fn().mockResolvedValue(undefined)
}
},
{
provide: CartService,
useValue: {
cart: signal(null).asReadonly(),
loadCart: () => of({ id: 1, items: [], subtotal: '0' }),
updateItemQuantity: vi.fn().mockReturnValue(of({ data: { id: 1, items: [], subtotal: '0' } })),
removeItem: vi.fn().mockReturnValue(of({ data: { id: 1, items: [], subtotal: '0' } }))
}
},
{
provide: ToastService,
useValue: {
show: vi.fn(),
info: vi.fn(),
danger: vi.fn(),
success: vi.fn(),
dismiss: vi.fn()
}
}
]
}).compileComponents();

View File

@@ -1,31 +1,85 @@
import { Component, inject } from '@angular/core';
import { Component, computed, inject, OnInit, signal } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { TenantService } from '../../services/tenant.service';
import { CartService } from '../../services/cart/cart.service';
import { StoreFooterComponent, StoreFooterSection, StoreSocialLink } from './store-footer/store-footer.component';
import { StoreHeaderComponent, StoreHeaderAction } from './store-header/store-header.component';
import { StoreHeaderComponent } from './store-header/store-header.component';
import { CartComponent, CartItemMock } from '../../../shared/components/cart/cart.component';
import { ButtonComponent } from '../../../shared/components/button/button.component';
import { CartItem } from '../../services/cart/cart.interface';
@Component({
selector: 'app-store-layout',
imports: [RouterOutlet, StoreHeaderComponent, StoreFooterComponent],
imports: [RouterOutlet, StoreHeaderComponent, StoreFooterComponent, CartComponent, ButtonComponent],
templateUrl: './store-layout.component.html',
styleUrl: './store-layout.component.scss'
})
export class StoreLayoutComponent {
export class StoreLayoutComponent implements OnInit {
private readonly tenantService = inject(TenantService);
private readonly cartService = inject(CartService);
protected readonly isCartOpen = signal(false);
protected readonly cartSubtotal = computed(() => {
const cart = this.cartService.cart();
return cart ? parseFloat(cart.subtotal) : 0;
});
protected readonly cartDiscount = computed(() => 0);
protected readonly cartTotal = computed(() => this.cartSubtotal() - this.cartDiscount());
protected readonly mappedCartItems = computed<CartItemMock[]>(() => {
const cart = this.cartService.cart();
if (!cart || !cart.items) return [];
return cart.items.map((item) => this.mapCartItemToMock(item));
});
private mapCartItemToMock(item: CartItem): CartItemMock {
const fullName = item.product?.nombre ?? '';
let product = fullName;
let attributes: { label: string; value: string }[] = [];
const match = fullName.match(/^(.*?)\s*\((.*?)\)$/);
if (match) {
product = match[1];
const attributesString = match[2];
attributes = attributesString.split(',').map((attr) => {
const parts = attr.split(':');
if (parts.length === 2) {
return { label: parts[0].trim(), value: parts[1].trim() };
}
return { label: '', value: attr.trim() };
});
}
return {
productVariantId: item.product_variant_id,
imageUrl: item.product?.imagen ?? null,
product,
originalPrice: null,
discountedPrice: parseFloat(item.precio_unitario),
discountPercentage: null,
attributes,
quantity: item.cantidad
};
}
protected readonly currentYear = new Date().getFullYear();
protected readonly tenant = this.tenantService.tenant;
protected readonly headerActions: StoreHeaderAction[] = [
{
label: 'Carrito',
iconClass: 'fa-solid fa-cart-shopping'
},
{
label: 'Mi cuenta',
iconClass: 'fa-solid fa-circle-user'
}
];
protected readonly cartQuantity = computed(() => {
const cart = this.cartService.cart();
if (!cart || !cart.items) return 0;
return cart.items.reduce((total, item) => total + item.cantidad, 0);
});
ngOnInit(): void {
this.cartService.loadCart().subscribe({
error: (err) => console.error('Error loading cart', err)
});
}
protected readonly footerSections: StoreFooterSection[] = [
{

View File

@@ -75,8 +75,8 @@ describe('CartService', () => {
});
it('should add item and update signal', () => {
service.addItem(10, 2).subscribe((cart) => {
expect(cart).toEqual(mockCart);
service.addItem(10, 2).subscribe((res) => {
expect(res.data).toEqual(mockCart);
expect(service.cart()).toEqual(mockCart);
});
@@ -91,8 +91,8 @@ describe('CartService', () => {
const updatedCart = { ...mockCart, subtotal: '30.00' };
updatedCart.items[0].cantidad = 3;
service.updateItemQuantity(10, 3).subscribe((cart) => {
expect(cart).toEqual(updatedCart);
service.updateItemQuantity(10, 3).subscribe((res) => {
expect(res.data).toEqual(updatedCart);
expect(service.cart()).toEqual(updatedCart);
});
@@ -112,8 +112,8 @@ describe('CartService', () => {
subtotal: '0.00'
};
service.removeItem(10).subscribe((cart) => {
expect(cart).toEqual(emptyCart);
service.removeItem(10).subscribe((res) => {
expect(res.data).toEqual(emptyCart);
expect(service.cart()).toEqual(emptyCart);
});

View File

@@ -31,7 +31,7 @@ export class CartService {
);
}
addItem(productVariantId: number, cantidad: number): Observable<Cart> {
addItem(productVariantId: number, cantidad: number): Observable<ApiResponse<Cart>> {
return this.http
.post<ApiResponse<Cart>>(
`${this.tenantApiUrl}/cart/items`,
@@ -39,12 +39,11 @@ export class CartService {
{ withCredentials: true }
)
.pipe(
map((response) => response.data),
tap((cart) => this.cartState.set(cart))
tap((response) => this.cartState.set(response.data))
);
}
updateItemQuantity(productVariantId: number, cantidad: number): Observable<Cart> {
updateItemQuantity(productVariantId: number, cantidad: number): Observable<ApiResponse<Cart>> {
return this.http
.patch<ApiResponse<Cart>>(
`${this.tenantApiUrl}/cart/items/${productVariantId}`,
@@ -52,20 +51,18 @@ export class CartService {
{ withCredentials: true }
)
.pipe(
map((response) => response.data),
tap((cart) => this.cartState.set(cart))
tap((response) => this.cartState.set(response.data))
);
}
removeItem(productVariantId: number): Observable<Cart> {
removeItem(productVariantId: number): Observable<ApiResponse<Cart>> {
return this.http
.delete<ApiResponse<Cart>>(
`${this.tenantApiUrl}/cart/items/${productVariantId}`,
{ withCredentials: true }
)
.pipe(
map((response) => response.data),
tap((cart) => this.cartState.set(cart))
tap((response) => this.cartState.set(response.data))
);
}
}

View File

@@ -31,14 +31,14 @@ export interface ProductAttribute {
export interface ProductVariant {
id: number;
stock: number;
cantidad_maxima: number;
definitions: Record<string, string>;
images: string[];
}
export interface ProductVariantMap {
variant_id: number;
stock: number;
cantidad_maxima: number;
attributes: Record<string, string>;
}

View File

@@ -0,0 +1,223 @@
import '@angular/compiler';
import { Component, inject } from '@angular/core';
import { TestBed, getTestBed } from '@angular/core/testing';
import {
BrowserTestingModule,
platformBrowserTesting
} from '@angular/platform-browser/testing';
import {
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { firstValueFrom } from 'rxjs';
import { ConfirmDeleteModalComponent } from '../../shared/components/confirm-delete-modal/confirm-delete-modal.component';
import { ConfirmModalComponent } from '../../shared/components/confirm-modal/confirm-modal.component';
import {
MODAL_DATA,
ModalRef,
ModalService
} from './modal.service';
@Component({
template: ''
})
class FirstTestModalComponent {}
@Component({
template: ''
})
class SecondTestModalComponent {}
describe('ModalService', () => {
let service: ModalService;
beforeAll(() => {
try {
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting()
);
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
});
afterEach(() => {
TestBed.resetTestingModule();
});
beforeEach(() => {
TestBed.configureTestingModule({
providers: [ModalService]
});
service = TestBed.inject(ModalService);
});
it('opens a modal and stores the normalized config', () => {
const ref = service.open(FirstTestModalComponent, {
title: 'Confirmar compra',
data: { productId: 10 },
size: 'lg',
closeOnBackdrop: false
});
const activeModal = service.activeModal();
expect(activeModal).not.toBeNull();
expect(activeModal?.component).toBe(FirstTestModalComponent);
expect(activeModal?.ref).toBe(ref);
expect(activeModal?.config).toEqual({
title: 'Confirmar compra',
data: { productId: 10 },
size: 'lg',
closeOnBackdrop: false,
closeOnEscape: true,
showCloseButton: true
});
});
it('emits the close result and clears the active modal', () => {
const ref = service.open(FirstTestModalComponent);
const closedSpy = vi.fn();
ref.afterClosed$.subscribe(closedSpy);
ref.close({ confirmed: true });
expect(closedSpy).toHaveBeenCalledWith({ confirmed: true });
expect(service.activeModal()).toBeNull();
expect(ref.dismissReason()).toBeNull();
});
it('tracks dismiss reasons when closing programmatically', () => {
const ref = service.open(FirstTestModalComponent);
const closedSpy = vi.fn();
ref.afterClosed$.subscribe(closedSpy);
ref.dismiss();
expect(closedSpy).toHaveBeenCalledWith(undefined);
expect(ref.dismissReason()).toBe('programmatic');
expect(service.activeModal()).toBeNull();
});
it('dismisses the current modal as replaced when another one opens', () => {
const firstRef = service.open(FirstTestModalComponent);
const firstClosedSpy = vi.fn();
firstRef.afterClosed$.subscribe(firstClosedSpy);
const secondRef = service.open(SecondTestModalComponent, {
title: 'Segundo modal'
});
expect(firstClosedSpy).toHaveBeenCalledWith(undefined);
expect(firstRef.dismissReason()).toBe('replaced');
expect(service.activeModal()?.ref).toBe(secondRef);
expect(service.activeModal()?.component).toBe(SecondTestModalComponent);
});
it('injects MODAL_DATA and ModalRef into opened components through the host injector contract', () => {
const ref = service.open(DataTestModalComponent, {
data: { amount: 3 }
});
const activeModal = service.activeModal();
expect(activeModal?.config.data).toEqual({ amount: 3 });
expect(activeModal?.ref).toBe(ref);
});
it('opens the standard confirm modal with default labels', () => {
const result$ = service.openConfirm({
title: 'Confirmar compra',
content: 'Esto confirmara la compra actual.'
});
const activeModal = service.activeModal();
expect(activeModal?.component).toBe(ConfirmModalComponent);
expect(result$).toBeDefined();
expect(activeModal?.config).toEqual({
title: 'Confirmar compra',
data: {
content: 'Esto confirmara la compra actual.',
confirmLabel: 'Confirmar',
cancelLabel: 'Cancelar'
},
size: 'md',
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true
});
});
it('maps the confirm modal close result to true', async () => {
const result$ = service.openConfirm({
title: 'Confirmar compra',
content: 'Esto confirmara la compra actual.'
});
const activeModal = service.activeModal();
const resultPromise = firstValueFrom(result$);
activeModal?.ref.close(true);
await expect(resultPromise).resolves.toBe(true);
});
it('maps dismissing a confirm modal to false', async () => {
const result$ = service.openConfirmDelete({
title: 'Eliminar producto',
content: 'Se eliminara el producto.'
});
const activeModal = service.activeModal();
const resultPromise = firstValueFrom(result$);
activeModal?.ref.dismiss('escape');
await expect(resultPromise).resolves.toBe(false);
});
it('opens the delete confirm modal preserving modal overrides', () => {
service.openConfirmDelete({
title: 'Eliminar producto',
content: 'Se eliminara el producto.',
confirmLabel: 'Eliminar',
cancelLabel: 'Conservar',
size: 'lg',
closeOnBackdrop: false,
closeOnEscape: false,
showCloseButton: false
});
const activeModal = service.activeModal();
expect(activeModal?.component).toBe(ConfirmDeleteModalComponent);
expect(activeModal?.config).toEqual({
title: 'Eliminar producto',
data: {
content: 'Se eliminara el producto.',
confirmLabel: 'Eliminar',
cancelLabel: 'Conservar'
},
size: 'lg',
closeOnBackdrop: false,
closeOnEscape: false,
showCloseButton: false
});
});
});
@Component({
template: ''
})
class DataTestModalComponent {
readonly data = inject(MODAL_DATA);
readonly modalRef = inject(ModalRef);
}

View File

@@ -0,0 +1,210 @@
import { InjectionToken, Injectable, Type, signal } from '@angular/core';
import { Observable, Subject, map } from 'rxjs';
import { ConfirmDeleteModalComponent } from '../../shared/components/confirm-delete-modal/confirm-delete-modal.component';
import { ConfirmModalComponent } from '../../shared/components/confirm-modal/confirm-modal.component';
export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full';
export type ModalDismissReason =
| 'backdrop'
| 'escape'
| 'programmatic'
| 'replaced';
export interface ModalConfig<TData = unknown> {
title?: string;
data?: TData;
size?: ModalSize;
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
showCloseButton?: boolean;
}
export interface NormalizedModalConfig<TData = unknown>
extends Omit<
ModalConfig<TData>,
'size' | 'closeOnBackdrop' | 'closeOnEscape' | 'showCloseButton'
> {
size: ModalSize;
closeOnBackdrop: boolean;
closeOnEscape: boolean;
showCloseButton: boolean;
}
export interface ConfirmModalData {
content: string;
confirmLabel: string;
cancelLabel: string;
}
export interface ConfirmModalConfig
extends Omit<ModalConfig<ConfirmModalData>, 'data'> {
content: string;
confirmLabel?: string;
cancelLabel?: string;
}
export interface ActiveModalState<TResult = unknown, TData = unknown> {
component: Type<unknown>;
config: NormalizedModalConfig<TData>;
ref: ModalRef<unknown>;
}
export const MODAL_DATA = new InjectionToken<unknown>('MODAL_DATA');
const DEFAULT_MODAL_CONFIG: Pick<
NormalizedModalConfig,
'size' | 'closeOnBackdrop' | 'closeOnEscape' | 'showCloseButton'
> = {
size: 'md',
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true
};
const DEFAULT_CONFIRM_MODAL_LABELS = {
confirmLabel: 'Confirmar',
cancelLabel: 'Cancelar'
} satisfies Pick<ConfirmModalData, 'confirmLabel' | 'cancelLabel'>;
export class ModalRef<TResult = unknown> {
private readonly afterClosedSubject = new Subject<TResult | undefined>();
private closed = false;
readonly afterClosed$: Observable<TResult | undefined> =
this.afterClosedSubject.asObservable();
readonly dismissReason = signal<ModalDismissReason | null>(null);
constructor(
private readonly closeHandler: (result?: TResult) => void,
private readonly dismissHandler: (reason: ModalDismissReason) => void
) {}
close(result?: TResult): void {
if (this.closed) {
return;
}
this.closeHandler(result);
}
dismiss(reason: ModalDismissReason = 'programmatic'): void {
if (this.closed) {
return;
}
this.dismissHandler(reason);
}
finalize(result?: TResult, dismissReason?: ModalDismissReason): void {
if (this.closed) {
return;
}
this.closed = true;
this.dismissReason.set(dismissReason ?? null);
this.afterClosedSubject.next(result);
this.afterClosedSubject.complete();
}
}
@Injectable({
providedIn: 'root'
})
export class ModalService {
private readonly activeModalState = signal<ActiveModalState | null>(null);
readonly activeModal = this.activeModalState.asReadonly();
open<TComponent, TResult = unknown, TData = unknown>(
component: Type<TComponent>,
config: ModalConfig<TData> = {}
): ModalRef<TResult> {
this.activeModalState()?.ref.dismiss('replaced');
let ref!: ModalRef<TResult>;
ref = new ModalRef<TResult>(
(result) => this.close(ref, result),
(reason) => this.dismiss(ref, reason)
);
this.activeModalState.set({
component,
config: this.normalizeConfig(config),
ref: ref as ModalRef<unknown>
});
return ref;
}
openConfirm(config: ConfirmModalConfig): Observable<boolean> {
return this.openConfirmRef(config).afterClosed$.pipe(
map((result) => result === true)
);
}
openConfirmDelete(config: ConfirmModalConfig): Observable<boolean> {
return this.openConfirmDeleteRef(config).afterClosed$.pipe(
map((result) => result === true)
);
}
openConfirmRef(config: ConfirmModalConfig): ModalRef<boolean> {
return this.open(ConfirmModalComponent, this.buildConfirmModalConfig(config));
}
openConfirmDeleteRef(config: ConfirmModalConfig): ModalRef<boolean> {
return this.open(
ConfirmDeleteModalComponent,
this.buildConfirmModalConfig(config)
);
}
private close<TResult>(ref: ModalRef<TResult>, result?: TResult): void {
if (this.activeModalState()?.ref !== ref) {
return;
}
ref.finalize(result);
this.activeModalState.set(null);
}
private dismiss<TResult>(
ref: ModalRef<TResult>,
reason: ModalDismissReason = 'programmatic'
): void {
if (this.activeModalState()?.ref !== ref) {
return;
}
ref.finalize(undefined, reason);
this.activeModalState.set(null);
}
private normalizeConfig<TData>(
config: ModalConfig<TData>
): NormalizedModalConfig<TData> {
return {
...DEFAULT_MODAL_CONFIG,
...config
};
}
private buildConfirmModalConfig(
config: ConfirmModalConfig
): ModalConfig<ConfirmModalData> {
const {
content,
confirmLabel = DEFAULT_CONFIRM_MODAL_LABELS.confirmLabel,
cancelLabel = DEFAULT_CONFIRM_MODAL_LABELS.cancelLabel,
...modalConfig
} = config;
return {
...modalConfig,
data: {
content,
confirmLabel,
cancelLabel
}
};
}
}

View File

@@ -26,7 +26,9 @@
<app-button variant="secondary" [disabled]="true">Secondary</app-button>
<app-button variant="danger" [disabled]="true">Danger</app-button>
<app-button variant="danger-secondary" [disabled]="true">Danger secondary</app-button>
<app-button variant="danger-secondary" [disabled]="true">
Danger secondary
</app-button>
<app-button variant="cancel" [disabled]="true">Cancel</app-button>
</div>
</div>
@@ -46,23 +48,141 @@
<h2>Toasts</h2>
<div class="button-grid">
<app-button (click)="triggerToast('info')">Trigger Info</app-button>
<app-button variant="secondary" (click)="triggerToast('success')">Trigger Success</app-button>
<app-button variant="danger" (click)="triggerToast('danger')">Trigger Danger</app-button>
<app-button variant="secondary" (click)="triggerToast('success')">
Trigger Success
</app-button>
<app-button variant="danger" (click)="triggerToast('danger')">
Trigger Danger
</app-button>
</div>
</div>
<div class="button-group">
<h2>Toasts Persistentes (No se van)</h2>
<div class="button-grid">
<app-button (click)="triggerPersistentToast('info')">Info Persistente</app-button>
<app-button variant="secondary" (click)="triggerPersistentToast('success')">Success Persistente</app-button>
<app-button variant="danger" (click)="triggerPersistentToast('danger')">Danger Persistente</app-button>
<app-button (click)="triggerPersistentToast('info')">
Info Persistente
</app-button>
<app-button
variant="secondary"
(click)="triggerPersistentToast('success')"
>
Success Persistente
</app-button>
<app-button variant="danger" (click)="triggerPersistentToast('danger')">
Danger Persistente
</app-button>
</div>
</div>
<div class="button-group">
<h2>Modal</h2>
<p class="text-muted mb-0">
Demo del modal global con servicio, host y devolucion de resultado.
</p>
<div class="button-grid">
<app-button (click)="openBasicModal()">Abrir confirm modal</app-button>
<app-button variant="danger" (click)="openConfirmDeleteModal()">
Abrir confirm delete
</app-button>
<app-button variant="secondary" (click)="openLockedModal()">
Abrir modal bloqueado
</app-button>
<app-button variant="danger-secondary" (click)="openWideModal()">
Abrir modal ancho
</app-button>
</div>
<div class="modal-showcase__result" data-testid="modal-last-result">
{{ lastModalResult }}
</div>
</div>
</div>
<hr class="section-divider" />
<div class="icon-button-showcase">
<h2>Icon Buttons</h2>
<p class="text-muted mb-4">
Botones de icono pequenos con color base <code>#666666</code>,
deshabilitados con <code>#A0A0A0</code> y estados hover/active con color
de marca (a excepcion de trash que usa danger).
</p>
<div class="icon-button-showcase__grid">
<div class="icon-button-showcase__row">
<span class="icon-button-showcase__label">Disabled</span>
<div class="icon-button-showcase__items">
<app-icon-button variant="user" [disabled]="true" />
<app-icon-button variant="trash" [disabled]="true" />
<app-icon-button variant="angle-left" [disabled]="true" />
<app-icon-button variant="angle-right" [disabled]="true" />
<app-icon-button variant="angle-up" [disabled]="true" />
<app-icon-button variant="angle-down" [disabled]="true" />
<app-icon-button variant="copy" [disabled]="true" />
</div>
</div>
<div class="icon-button-showcase__row">
<span class="icon-button-showcase__label">Normal</span>
<div class="icon-button-showcase__items">
<app-icon-button variant="user" />
<app-icon-button variant="trash" />
<app-icon-button variant="angle-left" />
<app-icon-button variant="angle-right" />
<app-icon-button variant="angle-up" />
<app-icon-button variant="angle-down" />
<app-icon-button variant="copy" />
</div>
</div>
<div class="icon-button-showcase__row">
<span class="icon-button-showcase__label">Active / Hover</span>
<div class="icon-button-showcase__items">
<app-icon-button variant="user" class="hover-preview" />
<app-icon-button variant="trash" class="hover-preview" />
<app-icon-button variant="angle-left" class="hover-preview" />
<app-icon-button variant="angle-right" class="hover-preview" />
<app-icon-button variant="angle-up" class="hover-preview" />
<app-icon-button variant="angle-down" class="hover-preview" />
<app-icon-button variant="copy" class="hover-preview" />
</div>
</div>
</div>
</div>
<div class="icon-button-showcase mt-4">
<h2>Cart Icons</h2>
<p class="text-muted mb-4">
Icono de carrito con badge opcional indicando cantidad.
</p>
<div class="icon-button-showcase__grid">
<div class="icon-button-showcase__row">
<span class="icon-button-showcase__label">Disabled</span>
<div class="icon-button-showcase__items">
<app-cart-icon [disabled]="true" [quantity]="3" />
<app-cart-icon [disabled]="true" />
</div>
</div>
<div class="icon-button-showcase__row">
<span class="icon-button-showcase__label">Normal</span>
<div class="icon-button-showcase__items">
<app-cart-icon [quantity]="3" />
<app-cart-icon />
</div>
</div>
<div class="icon-button-showcase__row">
<span class="icon-button-showcase__label">Active / Hover</span>
<div class="icon-button-showcase__items">
<app-cart-icon [quantity]="3" class="hover-preview" />
<app-cart-icon class="hover-preview" />
</div>
</div>
</div>
</div>
<div class="input-grid">
<div class="input-field">
<label for="search-input">Texto</label>
@@ -190,7 +310,9 @@
/>
</div>
<app-button variant="secondary" (click)="clearFile()">Limpiar archivo</app-button>
<app-button variant="secondary" (click)="clearFile()">
Limpiar archivo
</app-button>
</div>
</div>
@@ -199,7 +321,8 @@
<div class="paginator-showcase">
<h2>Paginator</h2>
<p class="text-muted mb-0">
Componente shared para navegar por paginas con primera, anterior, siguiente y ultima.
Componente shared para navegar por paginas con primera, anterior,
siguiente y ultima.
</p>
<app-paginator
[page]="currentPage"
@@ -207,22 +330,58 @@
(pageChange)="currentPage = $event"
/>
</div>
<hr class="section-divider" />
<div class="cart-showcase">
<div class="cart-showcase__header">
<div>
<h2>Carrito mock</h2>
<p class="text-muted mb-0">
Demo visual aislada del carrito con scroll a partir del cuarto item.
</p>
</div>
@if (!cartVisible) {
<app-button variant="secondary" (click)="showCart()">
Volver a mostrar carrito
</app-button>
}
</div>
@if (cartVisible) {
<div class="cart-showcase__viewport">
<app-cart
[showClose]="true"
[items]="cartMockItems"
[subtotal]="366000"
[discount]="66000"
[total]="300000"
[backgroundColor]="cartBackgroundColor"
(closed)="hideCart()"
/>
</div>
}
</div>
</div>
</section>
<section class="component-demo card shadow-sm mt-4 configuracion-tennant">
<div class="card-body">
<span class="eyebrow">Tenant Configuration</span>
<h2 class="mb-4">Configuración Tenant</h2>
<h2 class="mb-4">Configuracion Tenant</h2>
<!-- Logos section -->
<div class="row mb-4">
<div class="col-md-6 mb-3 mb-md-0">
<div class="tenant-logo-card">
<div class="tenant-logo-card__label">Logo del Header</div>
<div class="tenant-logo-card__container header-bg">
@if (tenant()?.header_logo) {
<img [src]="tenant()?.header_logo" alt="Header Logo" class="tenant-logo-card__img" />
<img
[src]="tenant()?.header_logo"
alt="Header Logo"
class="tenant-logo-card__img"
/>
} @else {
<div class="tenant-logo-card__placeholder">
<i class="fa-solid fa-image mb-2"></i>
@@ -238,7 +397,11 @@
<div class="tenant-logo-card__label">Logo del Footer</div>
<div class="tenant-logo-card__container footer-bg">
@if (tenant()?.footer_logo) {
<img [src]="tenant()?.footer_logo" alt="Footer Logo" class="tenant-logo-card__img" />
<img
[src]="tenant()?.footer_logo"
alt="Footer Logo"
class="tenant-logo-card__img"
/>
} @else {
<div class="tenant-logo-card__placeholder">
<i class="fa-solid fa-image mb-2"></i>
@@ -252,66 +415,95 @@
<hr class="section-divider" />
<!-- Colors section -->
<div class="colors-section">
<h3 class="mb-3">Colores de Marca</h3>
<div class="row">
<div class="col-md-3 mb-3">
<div class="color-swatch-card shadow-sm">
<div class="color-swatch-card__preview" [style.background-color]="'var(--tenant-primary)'"></div>
<div
class="color-swatch-card__preview"
[style.background-color]="'var(--tenant-primary)'"
></div>
<div class="color-swatch-card__body">
<span class="color-swatch-card__title">Primario</span>
<code class="color-swatch-card__value">{{ tenant()?.primary_color || 'Default' }}</code>
<code class="color-swatch-card__value">
{{ tenant()?.primary_color || 'Default' }}
</code>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="color-swatch-card shadow-sm">
<div class="color-swatch-card__preview" [style.background-color]="'var(--tenant-secondary)'"></div>
<div
class="color-swatch-card__preview"
[style.background-color]="'var(--tenant-secondary)'"
></div>
<div class="color-swatch-card__body">
<span class="color-swatch-card__title">Secundario</span>
<code class="color-swatch-card__value">{{ tenant()?.secondary_color || 'Default' }}</code>
<code class="color-swatch-card__value">
{{ tenant()?.secondary_color || 'Default' }}
</code>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="color-swatch-card shadow-sm">
<div class="color-swatch-card__preview" [style.background-color]="'var(--tenant-danger)'"></div>
<div
class="color-swatch-card__preview"
[style.background-color]="'var(--tenant-danger)'"
></div>
<div class="color-swatch-card__body">
<span class="color-swatch-card__title">Danger</span>
<code class="color-swatch-card__value">{{ tenant()?.danger_color || 'Default' }}</code>
<code class="color-swatch-card__value">
{{ tenant()?.danger_color || 'Default' }}
</code>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="color-swatch-card shadow-sm">
<div class="color-swatch-card__preview" [style.background-color]="'var(--tenant-success)'"></div>
<div
class="color-swatch-card__preview"
[style.background-color]="'var(--tenant-success)'"
></div>
<div class="color-swatch-card__body">
<span class="color-swatch-card__title">Success</span>
<code class="color-swatch-card__value">{{ tenant()?.success_color || 'Default' }}</code>
<code class="color-swatch-card__value">
{{ tenant()?.success_color || 'Default' }}
</code>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="color-swatch-card shadow-sm">
<div class="color-swatch-card__preview" [style.background-color]="'var(--tenant-header-bg)'"></div>
<div
class="color-swatch-card__preview"
[style.background-color]="'var(--tenant-header-bg)'"
></div>
<div class="color-swatch-card__body">
<span class="color-swatch-card__title">Header BG</span>
<code class="color-swatch-card__value">{{ tenant()?.header_bg_color || 'Default' }}</code>
<code class="color-swatch-card__value">
{{ tenant()?.header_bg_color || 'Default' }}
</code>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="color-swatch-card shadow-sm">
<div class="color-swatch-card__preview" [style.background-color]="'var(--tenant-footer-bg)'"></div>
<div
class="color-swatch-card__preview"
[style.background-color]="'var(--tenant-footer-bg)'"
></div>
<div class="color-swatch-card__body">
<span class="color-swatch-card__title">Footer BG</span>
<code class="color-swatch-card__value">{{ tenant()?.footer_bg_color || 'Default' }}</code>
<code class="color-swatch-card__value">
{{ tenant()?.footer_bg_color || 'Default' }}
</code>
</div>
</div>
</div>
@@ -324,9 +516,10 @@
<div class="card-body">
<span class="eyebrow">Reusable Component</span>
<h2 class="mb-4">Tarjetas de Producto (ProductCard)</h2>
<p class="text-muted mb-4">
Las tarjetas se adaptan al tamaño del contenedor padre. A continuación se presentan dentro de una grilla de Bootstrap.
Las tarjetas se adaptan al tamano del contenedor padre. A continuacion se
presentan dentro de una grilla de Bootstrap.
</p>
<div class="row">
@@ -346,10 +539,15 @@
</div>
</section>
<app-store-section title="PRODUCTOS" class="component-demo store-section-demo mt-5">
<app-store-section
title="PRODUCTOS"
class="component-demo store-section-demo mt-5"
>
<div class="row">
@for (product of testProducts; track product.title) {
<div class="col-12 col-md-6 col-lg-4 mb-4 d-flex align-items-stretch store-section-demo__item">
<div
class="col-12 col-md-6 col-lg-4 mb-4 d-flex align-items-stretch store-section-demo__item"
>
<app-product-card
[imageUrl]="product.imageUrl"
[title]="product.title"
@@ -363,4 +561,3 @@
</div>
</app-store-section>
</main>

View File

@@ -49,6 +49,49 @@ h1 {
gap: 0.75rem;
}
.modal-showcase__result {
padding: 0.875rem 1rem;
border: 1px dashed rgba(32, 32, 32, 0.14);
border-radius: 0.875rem;
background: linear-gradient(180deg, rgba(248, 248, 248, 0.92), rgba(255, 255, 255, 0.98));
color: #495057;
font-size: 0.95rem;
}
:host ::ng-deep .modal-demo-content {
display: grid;
gap: 1rem;
}
:host ::ng-deep .modal-demo-content__eyebrow {
margin: 0;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #6c757d;
}
:host ::ng-deep .modal-demo-content__title {
margin: 0;
font-size: 1.35rem;
font-weight: 700;
color: #202020;
}
:host ::ng-deep .modal-demo-content__description {
margin: 0;
color: #505050;
line-height: 1.6;
}
:host ::ng-deep .modal-demo-content__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.75rem;
}
.button-group h2 {
margin: 0;
font-size: 1rem;
@@ -62,6 +105,26 @@ h1 {
text-align: center;
}
.cart-showcase {
display: grid;
gap: 1rem;
justify-items: start;
}
.cart-showcase__viewport {
width: 100%;
max-width: 370px;
height: 360px;
}
.cart-showcase__header {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.button-grid--single-char app-button {
min-width: 3rem;
}
@@ -108,6 +171,15 @@ h1 {
.input-grid {
grid-template-columns: minmax(0, 1fr);
}
.cart-showcase__header {
align-items: start;
flex-direction: column;
}
.modal-showcase__result {
font-size: 0.875rem;
}
}
.configuracion-tennant {
@@ -219,3 +291,44 @@ h1 {
}
}
}
.icon-button-showcase {
display: grid;
gap: 1.5rem;
&__grid {
display: grid;
gap: 1.5rem;
background-color: #eaeaea;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid #dee2e6;
}
&__row {
display: grid;
grid-template-columns: 160px 1fr;
align-items: center;
gap: 1.5rem;
@media (max-width: 575.98px) {
grid-template-columns: minmax(0, 1fr);
gap: 0.5rem;
}
}
&__label {
font-size: 0.875rem;
font-weight: 700;
text-transform: uppercase;
color: #495057;
letter-spacing: 0.05em;
}
&__items {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1.5rem;
}
}

View File

@@ -1,9 +1,17 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { describe, expect, it, vi } from 'vitest';
import { ReutilizablesTestPageComponent } from './reutilizables-test-page.component';
import { TestBed, getTestBed } from '@angular/core/testing';
import {
BrowserTestingModule,
platformBrowserTesting
} from '@angular/platform-browser/testing';
import { of } from 'rxjs';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { ModalService } from '../../../../core/services/modal.service';
import { Tenant } from '../../../../core/services/tenant.interface';
import { TenantService } from '../../../../core/services/tenant.service';
import { ToastService } from '../../../../core/services/toast.service';
import { ReutilizablesTestPageComponent } from './reutilizables-test-page.component';
const tenant: Tenant = {
id: 1,
@@ -32,14 +40,54 @@ function createTenantServiceStub(currentTenant: Tenant | null) {
};
}
function createToastServiceStub() {
return {
success: vi.fn(),
danger: vi.fn(),
info: vi.fn()
};
}
function createModalServiceStub() {
return {
open: vi.fn(),
openConfirm: vi.fn().mockReturnValue(of(true)),
openConfirmDelete: vi.fn().mockReturnValue(of(false))
};
}
describe('ReutilizablesTestPageComponent', () => {
beforeAll(() => {
try {
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting()
);
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
});
afterEach(() => {
TestBed.resetTestingModule();
});
it('renders the tenant configuration section with logo sources and swatch colors', async () => {
const modalServiceStub = createModalServiceStub();
await TestBed.configureTestingModule({
imports: [ReutilizablesTestPageComponent],
providers: [
{
provide: TenantService,
useValue: createTenantServiceStub(tenant)
},
{
provide: ToastService,
useValue: createToastServiceStub()
},
{
provide: ModalService,
useValue: modalServiceStub
}
]
}).compileComponents();
@@ -48,20 +96,20 @@ describe('ReutilizablesTestPageComponent', () => {
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
// Check section title
const sectionTitle = element.querySelector('.configuracion-tennant h2');
expect(sectionTitle?.textContent).toContain('Configuración Tenant');
// Check header logo source
const headerImg = element.querySelector('.tenant-logo-card img[alt="Header Logo"]') as HTMLImageElement;
expect(sectionTitle?.textContent).toContain('Configuracion Tenant');
const headerImg = element.querySelector(
'.tenant-logo-card img[alt="Header Logo"]'
) as HTMLImageElement;
expect(headerImg?.src).toBe(tenant.header_logo);
// Check footer logo source
const footerImg = element.querySelector('.tenant-logo-card img[alt="Footer Logo"]') as HTMLImageElement;
const footerImg = element.querySelector(
'.tenant-logo-card img[alt="Footer Logo"]'
) as HTMLImageElement;
expect(footerImg?.src).toBe(tenant.footer_logo);
// Check color hex values
expect(element.textContent).toContain(tenant.primary_color);
expect(element.textContent).toContain(tenant.secondary_color);
expect(element.textContent).toContain(tenant.danger_color);
@@ -69,12 +117,21 @@ describe('ReutilizablesTestPageComponent', () => {
});
it('renders the paginator demo with the initial page status', async () => {
const modalServiceStub = createModalServiceStub();
await TestBed.configureTestingModule({
imports: [ReutilizablesTestPageComponent],
providers: [
{
provide: TenantService,
useValue: createTenantServiceStub(tenant)
},
{
provide: ToastService,
useValue: createToastServiceStub()
},
{
provide: ModalService,
useValue: modalServiceStub
}
]
}).compileComponents();
@@ -85,6 +142,53 @@ describe('ReutilizablesTestPageComponent', () => {
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('app-paginator')).not.toBeNull();
expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe('1/8');
expect(
element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()
).toBe('1/8');
});
it('renders the modal showcase and opens the confirm demos from the buttons', async () => {
const modalServiceStub = createModalServiceStub();
await TestBed.configureTestingModule({
imports: [ReutilizablesTestPageComponent],
providers: [
{
provide: TenantService,
useValue: createTenantServiceStub(tenant)
},
{
provide: ToastService,
useValue: createToastServiceStub()
},
{
provide: ModalService,
useValue: modalServiceStub
}
]
}).compileComponents();
const fixture = TestBed.createComponent(ReutilizablesTestPageComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const buttons = Array.from(
element.querySelectorAll('.button-group app-button button')
);
const confirmButton = buttons.find((button) =>
button.textContent?.includes('Abrir confirm modal')
) as HTMLButtonElement;
const deleteButton = buttons.find((button) =>
button.textContent?.includes('Abrir confirm delete')
) as HTMLButtonElement;
expect(
element.querySelector('[data-testid="modal-last-result"]')?.textContent
).toContain('Todavia no se abrio ningun modal.');
confirmButton.click();
deleteButton.click();
expect(modalServiceStub.openConfirm).toHaveBeenCalled();
expect(modalServiceStub.openConfirmDelete).toHaveBeenCalled();
});
});

View File

@@ -1,21 +1,38 @@
import { Component, inject } from '@angular/core';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import {
CartComponent,
CartItemMock
} from '../../../../shared/components/cart/cart.component';
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
import { InputComponent } from '../../../../shared/components/input/input.component';
import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component';
import { ProductCardComponent } from '../../../../shared/components/product-card/product-card.component';
import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component';
import { ModalService } from '../../../../core/services/modal.service';
import { TenantService } from '../../../../core/services/tenant.service';
import { ToastService } from '../../../../core/services/toast.service';
@Component({
selector: 'app-reutilizables-test-page',
imports: [ButtonComponent, InputComponent, PaginatorComponent, ProductCardComponent, StoreSectionComponent],
imports: [
ButtonComponent,
CartComponent,
InputComponent,
PaginatorComponent,
ProductCardComponent,
StoreSectionComponent,
IconButtonComponent,
CartIconComponent
],
templateUrl: './reutilizables-test-page.component.html',
styleUrl: './reutilizables-test-page.component.scss'
})
export class ReutilizablesTestPageComponent {
private readonly tenantService = inject(TenantService);
private readonly toastService = inject(ToastService);
private readonly modalService = inject(ModalService);
protected readonly tenant = this.tenantService.tenant;
protected textValue = 'Auriculares';
protected numberValue = '24';
@@ -33,11 +50,14 @@ export class ReutilizablesTestPageComponent {
protected clearTrigger = 0;
protected editableDisabled = true;
protected currentPage = 1;
protected cartVisible = true;
protected lastModalResult = 'Todavia no se abrio ningun modal.';
protected readonly paginatorTotalPages = 8;
protected readonly cartBackgroundColor = '#ffffff';
protected readonly testProducts = [
{
title: 'Pantalón recto de scuba negro',
title: 'Pantalon recto de scuba negro',
originalPrice: 95000,
discount: 20,
transferPrice: 74800,
@@ -51,7 +71,7 @@ export class ReutilizablesTestPageComponent {
imageUrl: '/images/zapatillas-running.png'
},
{
title: 'Remera básica blanca (Sin Descuento)',
title: 'Remera basica blanca (Sin Descuento)',
originalPrice: 32000,
discount: null,
transferPrice: 30000,
@@ -59,6 +79,57 @@ export class ReutilizablesTestPageComponent {
}
];
protected readonly cartMockItems: CartItemMock[] = [
{
imageUrl: null,
product: 'Calza termica unisex con proceso sense y cintura con mayor agarre',
originalPrice: 95000,
discountedPrice: 76000,
discountPercentage: 20,
quantity: 1,
attributes: [
{ label: 'Color', value: 'Beige' },
{ label: 'Talle', value: 'S' }
]
},
{
imageUrl: null,
product: 'Pantalon recto de scuba negro',
originalPrice: null,
discountedPrice: 89000,
discountPercentage: null,
quantity: 1,
attributes: [
{ label: 'Color', value: 'Beige' },
{ label: 'Talle', value: 'S' }
]
},
{
imageUrl: null,
product: 'Top de supplex color habano',
originalPrice: 98000,
discountedPrice: 85000,
discountPercentage: 20,
quantity: 1,
attributes: [
{ label: 'Color', value: 'Negro' },
{ label: 'Talle', value: 'XL' }
]
},
{
imageUrl: null,
product: 'Buzo oversize canguro gris',
originalPrice: 72000,
discountedPrice: 69000,
discountPercentage: 5,
quantity: 2,
attributes: [
{ label: 'Color', value: 'Gris' },
{ label: 'Talle', value: 'M' }
]
}
];
protected onProductBuy(productTitle: string): void {
console.log(`Comprando: ${productTitle}`);
alert(`Comprando: ${productTitle}`);
@@ -73,23 +144,94 @@ export class ReutilizablesTestPageComponent {
this.clearTrigger += 1;
}
protected showCart(): void {
this.cartVisible = true;
}
protected hideCart(): void {
this.cartVisible = false;
this.toastService.info('Se disparo el evento de cerrado del carrito mock.');
}
protected triggerToast(type: 'success' | 'danger' | 'info'): void {
if (type === 'success') {
this.toastService.success('¡Operación exitosa! El producto se ha guardado correctamente.');
this.toastService.success(
'Operacion exitosa. El producto se ha guardado correctamente.'
);
} else if (type === 'danger') {
this.toastService.danger('¡Alerta de error! Hubo un problema al procesar la solicitud.');
this.toastService.danger(
'Alerta de error. Hubo un problema al procesar la solicitud.'
);
} else if (type === 'info') {
this.toastService.info('Información del sistema: Hay una nueva actualización disponible.');
this.toastService.info(
'Informacion del sistema: Hay una nueva actualizacion disponible.'
);
}
}
protected triggerPersistentToast(type: 'success' | 'danger' | 'info'): void {
if (type === 'success') {
this.toastService.success('¡Éxito persistente! Esta alerta no se cerrará sola.', 0);
this.toastService.success(
'Exito persistente. Esta alerta no se cerrara sola.',
0
);
} else if (type === 'danger') {
this.toastService.danger('¡Error persistente! Por favor, atienda este problema y ciérrelo manualmente.', 0);
this.toastService.danger(
'Error persistente. Por favor, atienda este problema y cierrelo manualmente.',
0
);
} else if (type === 'info') {
this.toastService.info('Información persistente: Mantendremos este aviso en pantalla.', 0);
this.toastService.info(
'Informacion persistente: Mantendremos este aviso en pantalla.',
0
);
}
}
protected openBasicModal(): void {
this.openConfirmModal({
title: 'Confirmar accion',
content:
'Caso base para verificar apertura, cierre y devolucion de resultado.',
confirmLabel: 'Confirmar'
});
}
protected openConfirmDeleteModal(): void {
this.modalService.openConfirmDelete({
title: 'Eliminar producto',
content:
'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.',
confirmLabel: 'Eliminar',
cancelLabel: 'Conservar'
}).subscribe((confirmed) => {
this.lastModalResult = `Resultado: ${confirmed}`;
});
}
protected openLockedModal(): void {
this.openConfirmModal({
title: 'Modal bloqueado',
content:
'Este modal no se cierra tocando el backdrop ni con la tecla Escape.',
confirmLabel: 'Entendido',
closeOnBackdrop: false,
closeOnEscape: false
});
}
protected openWideModal(): void {
this.openConfirmModal({
title: 'Modal ancho',
content: 'Demuestra una variante visual mas amplia para contenido mas pesado.',
confirmLabel: 'Seguir',
size: 'xl'
});
}
private openConfirmModal(config: Parameters<ModalService['openConfirm']>[0]): void {
this.modalService.openConfirm(config).subscribe((confirmed) => {
this.lastModalResult = `Resultado: ${confirmed}`;
});
}
}

View File

@@ -46,7 +46,7 @@ export class ProductAttributeSelectorComponent {
const optionNormalized = this.normalizeText(option.value || option.label);
const isAvailable = variants.some(variant => {
if (variant.stock <= 0) return false;
if (variant.cantidad_maxima <= 0) return false;
const variantAttrValue = this.getDefaultVariantAttributeValue(attribute, variant.attributes);
if (!variantAttrValue || this.normalizeText(variantAttrValue) !== optionNormalized) {

View File

@@ -53,37 +53,20 @@
<section class="product-detail__section">
<div class="product-detail__purchase">
<div class="product-detail__quantity" aria-label="Selector de cantidad">
<button
type="button"
class="product-detail__quantity-button"
aria-label="Disminuir cantidad"
(click)="decreaseQuantity()"
>
-
</button>
<span class="product-detail__quantity-value">{{ quantity() }}</span>
<button
type="button"
class="product-detail__quantity-button"
aria-label="Aumentar cantidad"
[disabled]="quantity() >= (selectedVariant()?.stock ?? 1)"
(click)="increaseQuantity()"
>
+
</button>
</div>
<app-quantity-selector
[(quantity)]="quantity"
[max]="selectedVariant()?.cantidad_maxima ?? 1"
/>
<div class="product-detail__actions">
<app-button
class="product-detail__cta"
variant="secondary"
type="button"
[disabled]="!selectedVariant() || variantLoading()"
[disabled]="!selectedVariant() || variantLoading() || addingToCart()"
(click)="addToCart()"
>
@if (variantLoading()) {
@if (variantLoading() || addingToCart()) {
<div class="spinner-border spinner-border-sm" role="status"></div>
} @else {
Agregar al carrito

View File

@@ -62,12 +62,6 @@
flex-wrap: wrap;
}
&__quantity-button {
border: 1px solid #dcdcdc;
background: #ffffff;
color: #666666;
transition: border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease;
}
&__purchase {
display: flex;
@@ -76,38 +70,6 @@
flex-wrap: wrap;
}
&__quantity {
display: inline-flex;
align-items: center;
border: 1px solid #d8d8d8;
border-radius: 6px;
overflow: hidden;
min-height: 44px;
background: #f6f6f6;
}
&__quantity-button {
width: 25px;
height: 44px;
padding: 0;
font-size: 20px;
font-weight: 700;
line-height: 1;
color: #6f6f6f;
background: #f6f6f6;
border: 0;
}
&__quantity-value {
min-width: 35px;
text-align: center;
font-size: 16px;
font-weight: 700;
line-height: 1;
color: #6f6f6f;
}
&__actions {
flex: 1 1 0;

View File

@@ -11,6 +11,8 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { ProductDetail } from '../../../../core/services/catalog/catalog.interface';
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { CartService } from '../../../../core/services/cart/cart.service';
import { ToastService } from '../../../../core/services/toast.service';
import { ProductDetailPageComponent } from './product-detail-page.component';
describe('ProductDetailPageComponent', () => {
@@ -33,6 +35,8 @@ describe('ProductDetailPageComponent', () => {
let paramMapSubject: BehaviorSubject<any>;
let catalogServiceStub: any;
let routerStub: any;
let cartServiceStub: any;
let toastServiceStub: any;
beforeAll(() => {
try {
@@ -54,6 +58,14 @@ describe('ProductDetailPageComponent', () => {
routerStub = {
navigate: vi.fn()
};
cartServiceStub = {
addItem: vi.fn().mockReturnValue(of({ message: 'Producto agregado al carrito' }))
};
toastServiceStub = {
success: vi.fn(),
danger: vi.fn(),
info: vi.fn()
};
});
async function configureTestingModule() {
@@ -73,6 +85,14 @@ describe('ProductDetailPageComponent', () => {
{
provide: Router,
useValue: routerStub
},
{
provide: CartService,
useValue: cartServiceStub
},
{
provide: ToastService,
useValue: toastServiceStub
}
]
}).compileComponents();
@@ -112,7 +132,7 @@ describe('ProductDetailPageComponent', () => {
images: ['https://example.com/product.png'],
variant: {
id: 123,
stock: 10,
cantidad_maxima: 10,
images: ['https://example.com/variant1.png', 'https://example.com/variant2.png'],
definitions: {}
}
@@ -214,7 +234,7 @@ describe('ProductDetailPageComponent', () => {
],
variant: {
id: 123,
stock: 10,
cantidad_maxima: 10,
images: ['https://example.com/variant1.png'],
definitions: {
color: 'beige',
@@ -257,9 +277,11 @@ describe('ProductDetailPageComponent', () => {
it('keeps quantity at a minimum of one and increments locally', async () => {
await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent);
fixture.detectChanges();
fixture.componentInstance['selectedVariant'].set({
variant_id: 1,
stock: 10,
cantidad_maxima: 10,
attributes: {}
});
fixture.detectChanges();
@@ -275,13 +297,13 @@ describe('ProductDetailPageComponent', () => {
decreaseButton.click();
fixture.detectChanges();
expect(element.querySelector('.product-detail__quantity-value')?.textContent?.trim()).toBe('1');
expect(element.querySelector('.quantity-selector__value')?.textContent?.trim()).toBe('1');
increaseButton.click();
increaseButton.click();
fixture.detectChanges();
expect(element.querySelector('.product-detail__quantity-value')?.textContent?.trim()).toBe('3');
expect(element.querySelector('.quantity-selector__value')?.textContent?.trim()).toBe('3');
});
it('toggles the full description state when overflow is available', async () => {
@@ -329,4 +351,84 @@ describe('ProductDetailPageComponent', () => {
expect(routerStub.navigate).not.toHaveBeenCalled();
});
it('calls CartService.addItem when variant is selected and Add to Cart is clicked', async () => {
const detailProduct: ProductDetail = {
...mockProduct,
variant: {
id: 123,
cantidad_maxima: 5,
images: [],
definitions: {}
},
variants_map: [
{
variant_id: 123,
cantidad_maxima: 5,
attributes: {}
}
]
};
catalogServiceStub.getProducto.mockReturnValue(of(detailProduct));
await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent);
fixture.detectChanges();
// Select the variant
fixture.componentInstance['selectedVariant'].set(detailProduct.variants_map[0]);
fixture.componentInstance['quantity'].set(3);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const addToCartButton = Array.from(element.querySelectorAll('app-button button')).find(
(btn) => btn.textContent?.trim() === 'Agregar al carrito'
) as HTMLButtonElement;
expect(addToCartButton).toBeDefined();
addToCartButton.click();
fixture.detectChanges();
expect(cartServiceStub.addItem).toHaveBeenCalledWith(123, 3);
expect(toastServiceStub.success).toHaveBeenCalledWith('Producto agregado al carrito');
});
it('shows error toast when CartService.addItem fails', async () => {
const detailProduct: ProductDetail = {
...mockProduct,
variant: {
id: 123,
cantidad_maxima: 5,
images: [],
definitions: {}
},
variants_map: [
{
variant_id: 123,
cantidad_maxima: 5,
attributes: {}
}
]
};
catalogServiceStub.getProducto.mockReturnValue(of(detailProduct));
cartServiceStub.addItem.mockReturnValue(throwError(() => new Error('Failed to add')));
await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent);
fixture.detectChanges();
fixture.componentInstance['selectedVariant'].set(detailProduct.variants_map[0]);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const addToCartButton = Array.from(element.querySelectorAll('app-button button')).find(
(btn) => btn.textContent?.trim() === 'Agregar al carrito'
) as HTMLButtonElement;
addToCartButton.click();
fixture.detectChanges();
expect(cartServiceStub.addItem).toHaveBeenCalled();
expect(toastServiceStub.danger).toHaveBeenCalledWith('No se pudo agregar el producto al carrito.');
});
});

View File

@@ -18,6 +18,7 @@ import { Subscription } from 'rxjs';
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { ToastService } from '../../../../core/services/toast.service';
import { CartService } from '../../../../core/services/cart/cart.service';
import {
ProductAttribute,
ProductAttributeOption,
@@ -28,11 +29,19 @@ import {
import { ProductCarouselComponent } from '../../components/product-carousel/product-carousel.component';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { ProductAttributeSelectorComponent } from '../../components/product-attribute-selector/product-attribute-selector.component';
import { QuantitySelectorComponent } from '../../../../shared/components/quantity-selector/quantity-selector.component';
@Component({
selector: 'app-product-detail-page',
standalone: true,
imports: [CommonModule, RouterModule, ProductCarouselComponent, ButtonComponent, ProductAttributeSelectorComponent],
imports: [
CommonModule,
RouterModule,
ProductCarouselComponent,
ButtonComponent,
ProductAttributeSelectorComponent,
QuantitySelectorComponent
],
templateUrl: './product-detail-page.component.html',
styleUrl: './product-detail-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
@@ -42,6 +51,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
private readonly router = inject(Router);
private readonly catalogService = inject(CatalogService);
private readonly toastService = inject(ToastService);
private readonly cartService = inject(CartService);
private readonly attributeSelector = viewChild(ProductAttributeSelectorComponent);
private readonly carouselHost = viewChild<ElementRef<HTMLElement>>('carouselHost');
private readonly descriptionBody = viewChild<ElementRef<HTMLElement>>('descriptionBody');
@@ -68,6 +78,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
});
protected readonly loading = signal(false);
protected readonly variantLoading = signal(false);
protected readonly addingToCart = signal(false);
protected readonly error = signal<string | null>(null);
protected readonly selectedVariant = signal<ProductVariantMap | null>(null);
protected readonly quantity = signal(1);
@@ -129,6 +140,8 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
this.productSub = this.catalogService.getProducto(id).subscribe({
next: (prod) => {
this.product.set(prod);
const matchingVariant = prod.variants_map.find((v) => v.variant_id === prod.variant?.id) || null;
this.selectedVariant.set(matchingVariant);
this.quantity.set(1);
this.descriptionExpanded.set(false);
this.descriptionHasOverflow.set(false);
@@ -148,6 +161,8 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
this.productSub = this.catalogService.getProducto(productId, variantId).subscribe({
next: (prod) => {
this.product.set(prod);
const matchingVariant = prod.variants_map.find((v) => v.variant_id === prod.variant?.id) || null;
this.selectedVariant.set(matchingVariant);
this.variantLoading.set(false);
},
error: (err: HttpErrorResponse) => {
@@ -159,7 +174,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
if (!currentProduct) return null;
const updatedVariantsMap = currentProduct.variants_map.map((v) => {
if (v.variant_id === variantId) {
return { ...v, stock: 0 };
return { ...v, cantidad_maxima: 0 };
}
return v;
});
@@ -194,8 +209,8 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
}
this.selectedVariant.set(variant);
if (variant && this.quantity() > variant.stock) {
this.quantity.set(Math.max(1, variant.stock));
if (variant && this.quantity() > variant.cantidad_maxima) {
this.quantity.set(Math.max(1, variant.cantidad_maxima));
}
const currentProduct = this.product();
@@ -204,13 +219,28 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
}
}
protected increaseQuantity(): void {
const currentStock = this.selectedVariant()?.stock ?? 1;
this.quantity.update((current) => current < currentStock ? current + 1 : current);
}
protected decreaseQuantity(): void {
this.quantity.update((current) => Math.max(1, current - 1));
protected addToCart(): void {
const variant = this.selectedVariant();
if (!variant) {
this.toastService.danger('Por favor, selecciona una variante.');
return;
}
this.addingToCart.set(true);
this.cartService.addItem(variant.variant_id, this.quantity()).subscribe({
next: (res) => {
const msg = res.message || 'Producto agregado al carrito';
this.toastService.success(msg);
this.addingToCart.set(false);
},
error: (err: HttpErrorResponse) => {
const errorMessage = err.error?.message || 'No se pudo agregar el producto al carrito.';
this.toastService.danger(errorMessage);
this.addingToCart.set(false);
}
});
}
protected toggleDescription(): void {

View File

@@ -0,0 +1,13 @@
<button
type="button"
[disabled]="disabled()"
[attr.aria-label]="ariaLabel()"
class="cart-icon"
>
<div class="cart-icon__container">
<i class="fa-solid fa-cart-shopping cart-icon__glyph" aria-hidden="true"></i>
@if (hasQuantity()) {
<span class="cart-icon__badge" data-testid="cart-badge">{{ quantity() }}</span>
}
</div>
</button>

View File

@@ -0,0 +1,79 @@
:host {
display: inline-block;
vertical-align: middle;
}
.cart-icon {
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
padding: 4px;
margin: 0;
color: #666666;
cursor: pointer;
outline: none;
transition: color 0.15s ease-in-out, transform 0.1s ease-in-out;
border-radius: 4px;
// Active state subtle scale down
&:active:not(:disabled) {
transform: scale(0.92);
}
// Focus ring for accessibility
&:focus-visible {
outline: 2px solid var(--tenant-primary);
outline-offset: 2px;
}
// Disabled state
&:disabled {
color: #A0A0A0;
cursor: not-allowed;
pointer-events: none;
}
}
// Hover/Active/Focus colors (non-disabled)
.cart-icon:hover:not(:disabled),
.cart-icon:focus-visible:not(:disabled),
.cart-icon.hover-preview:not(:disabled),
:host(.hover-preview) .cart-icon:not(:disabled) {
color: var(--tenant-primary);
}
.cart-icon__container {
position: relative;
display: inline-flex;
}
.cart-icon__glyph {
display: inline-flex;
align-items: center;
justify-content: center;
width: 23px;
height: 23px;
font-size: 23px;
line-height: 1;
}
.cart-icon__badge {
position: absolute;
top: -8px;
right: -8px;
min-width: 18px;
height: 18px;
border-radius: 9px;
background-color: var(--tenant-primary);
color: #ffffff;
font-size: 10px;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 4px;
line-height: 1;
box-sizing: border-box;
}

View File

@@ -0,0 +1,60 @@
import { TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import { CartIconComponent } from './cart-icon.component';
describe('CartIconComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CartIconComponent]
}).compileComponents();
});
function setup(quantity?: number | null, disabled = false) {
const fixture = TestBed.createComponent(CartIconComponent);
if (quantity !== undefined) {
fixture.componentRef.setInput('quantity', quantity);
}
fixture.componentRef.setInput('disabled', disabled);
fixture.detectChanges();
return {
fixture,
element: fixture.nativeElement as HTMLElement
};
}
it('renders the cart shopping icon', () => {
const { element } = setup();
expect(element.querySelector('.fa-cart-shopping')).not.toBeNull();
});
it('does not render the badge when quantity is undefined', () => {
const { element } = setup();
expect(element.querySelector('[data-testid="cart-badge"]')).toBeNull();
});
it('does not render the badge when quantity is 0', () => {
const { element } = setup(0);
expect(element.querySelector('[data-testid="cart-badge"]')).toBeNull();
});
it('renders the badge with correct quantity when positive', () => {
const { element } = setup(3);
const badge = element.querySelector('[data-testid="cart-badge"]');
expect(badge).not.toBeNull();
expect(badge?.textContent?.trim()).toBe('3');
});
it('disables the button when disabled is true', () => {
const { element } = setup(undefined, true);
const button = element.querySelector('button');
expect(button?.disabled).toBe(true);
});
it('enables the button when disabled is false', () => {
const { element } = setup(undefined, false);
const button = element.querySelector('button');
expect(button?.disabled).toBe(false);
});
});

View File

@@ -0,0 +1,19 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
@Component({
selector: 'app-cart-icon',
imports: [],
templateUrl: './cart-icon.component.html',
styleUrl: './cart-icon.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CartIconComponent {
readonly quantity = input<number | null | undefined>(undefined);
readonly disabled = input(false);
readonly ariaLabel = input<string>('Carrito de compras');
protected readonly hasQuantity = computed(() => {
const q = this.quantity();
return q !== null && q !== undefined && q > 0;
});
}

View File

@@ -0,0 +1,53 @@
<article class="w-100 rounded-0 cart-item">
<div class="position-relative overflow-hidden cart-item-media">
@if (discountPercentage() && discountPercentage()! > 0) {
<span class="position-absolute top-0 start-0 z-1 rounded-0 cart-item-discount-badge">-{{ discountPercentage() }}%</span>
}
@if (imageUrl()) {
<img class="w-100 h-100 object-fit-cover d-block" [src]="imageUrl()" [alt]="product()" />
} @else {
<div class="w-100 h-100 cart-item-placeholder" aria-hidden="true"></div>
}
</div>
<div class="d-grid min-w-0 cart-item-content">
<div class="d-grid align-items-start cart-item-top-row">
<h3 class="m-0 text-uppercase fw-normal cart-item-product">{{ product() }}</h3>
<div class="text-nowrap cart-item-prices">
@if (formattedOriginalPrice(); as originalPrice) {
<span class="text-decoration-line-through fw-light cart-item-original-price">{{ originalPrice }}</span>
}
<span class="fw-normal cart-item-discounted-price">{{ formattedDiscountedPrice() }}</span>
</div>
</div>
<div class="d-grid cart-item-attributes">
@for (attribute of attributes(); track attribute.label + attribute.value) {
<div class="cart-item-attribute">
<span class="fw-normal cart-item-attribute-label">{{ attribute.label }}: </span>
<span class="fw-bold">{{ attribute.value }}</span>
</div>
}
</div>
<div class="d-flex justify-content-end align-items-center mt-auto cart-item-actions">
<app-quantity-selector
size="small"
[quantity]="quantity()"
(quantityChange)="onQuantityChange($event)"
(increase)="onIncrease()"
(decrease)="onDecrease()"
/>
<app-icon-button
variant="trash"
class="cart-item-remove-btn"
(click)="onRemove()"
/>
</div>
</div>
</article>

View File

@@ -0,0 +1,108 @@
:host {
display: block;
--item-media-width: 64px;
--item-media-height: 72px;
}
@media (max-width: 420px) {
:host {
--item-media-width: 56px;
--item-media-height: 64px;
}
}
.cart-item {
display: grid;
grid-template-columns: var(--item-media-width) minmax(0, 1fr);
gap: 0.75rem;
padding: 10px 12px;
background-color: #f5f5f5;
}
.cart-item-media {
width: var(--item-media-width);
height: var(--item-media-height);
border-radius: 6px;
background-color: #ffffff;
border: 1px solid #eaeaea;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.cart-item-discount-badge {
padding: 2px 5px;
border-bottom-right-radius: 4px;
background-color: #6173ff;
color: #ffffff;
font-size: 8px;
font-weight: 500;
line-height: 1;
}
.cart-item-placeholder {
background: linear-gradient(135deg, #d2d2d2, #ececec);
}
.cart-item-content {
gap: 0.5rem;
}
.cart-item-top-row {
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
}
.cart-item-product {
color: #666666;
font-size: 12px;
line-height: 1.25;
font-weight: 325;
}
.cart-item-prices {
display: grid;
justify-items: end;
line-height: 1.1;
}
.cart-item-original-price {
color: #a0a0a0;
font-size: 10px;
font-weight: 325;
}
.cart-item-discounted-price {
color: #a0a0a0;
font-size: 13px;
font-weight: 425;
}
.cart-item-attributes {
gap: 0.125rem;
}
.cart-item-attribute {
color: #666666;
font-size: 11px;
line-height: 1.2;
}
.cart-item-attribute-label {
font-weight: 325;
}
.cart-item-actions {
gap: 0.125rem;
}
.cart-item-remove-btn {
margin-left: 0.35rem;
}

View File

@@ -0,0 +1,61 @@
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component';
import { IconButtonComponent } from '../icon-button/icon-button.component';
export interface CartItemAttribute {
label: string;
value: string;
}
@Component({
selector: 'app-cart-item',
standalone: true,
imports: [QuantitySelectorComponent, IconButtonComponent],
templateUrl: './cart-item.component.html',
styleUrl: './cart-item.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CartItemComponent {
readonly imageUrl = input<string | null>(null);
readonly product = input<string>('');
readonly originalPrice = input<number | null>(null);
readonly discountedPrice = input<number>(0);
readonly discountPercentage = input<number | null>(null);
readonly attributes = input<CartItemAttribute[]>([]);
readonly quantity = input<number>(1);
readonly quantityChange = output<number>();
readonly remove = output<void>();
readonly increase = output<void>();
readonly decrease = output<void>();
protected onQuantityChange(newQuantity: number): void {
this.quantityChange.emit(newQuantity);
}
protected onRemove(): void {
this.remove.emit();
}
protected onIncrease(): void {
this.increase.emit();
}
protected onDecrease(): void {
this.decrease.emit();
}
protected readonly formattedOriginalPrice = computed(() => {
const price = this.originalPrice();
return price === null ? null : this.formatCurrency(price);
});
protected readonly formattedDiscountedPrice = computed(() => this.formatCurrency(this.discountedPrice()));
private formatCurrency(value: number): string {
const rounded = Math.round(value);
const parts = rounded.toString().split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, '.');
return `$ ${parts.join(',')}`;
}
}

View File

@@ -0,0 +1,50 @@
<section class="d-flex flex-column h-100 overflow-hidden text-secondary" [style.background-color]="backgroundColor()">
<header class="d-flex align-items-center justify-content-between bg-transparent cart-header">
<h2 class="m-0 text-uppercase fw-bold cart-title">{{ title() }}</h2>
@if (showClose()) {
<button class="btn btn-link p-0 border-0 d-inline-flex align-items-center justify-content-center cart-close-btn" type="button" aria-label="Cerrar carrito" (click)="closed.emit()">
<i class="fa-solid fa-xmark"></i>
</button>
}
</header>
<div class="d-grid gap-2 flex-grow-1 overflow-y-auto cart-items-container">
@for (item of items(); track item.product + item.discountedPrice + resetKey(); let idx = $index) {
<app-cart-item
[imageUrl]="item.imageUrl"
[product]="item.product"
[originalPrice]="item.originalPrice"
[discountedPrice]="item.discountedPrice"
[discountPercentage]="item.discountPercentage"
[attributes]="item.attributes"
[quantity]="item.quantity"
(quantityChange)="onItemQuantityChange(idx, $event)"
(remove)="onItemRemove(idx)"
/>
}
</div>
<footer class="d-grid gap-1 bg-transparent cart-footer">
<div class="d-flex align-items-baseline justify-content-between gap-3">
<span class="cart-summary-text-muted">Subtotal:</span>
<span class="cart-summary-text-muted-bold">{{ formattedSubtotal() }}</span>
</div>
<div class="d-flex align-items-baseline justify-content-between gap-3">
<span class="cart-summary-text-muted">Descuento:</span>
<span class="cart-summary-text-muted-bold">{{ formattedDiscount() }}</span>
</div>
<div class="d-flex align-items-baseline justify-content-between gap-3 mt-1">
<span class="cart-total-text">TOTAL:</span>
<span class="cart-total-text">{{ formattedTotal() }}</span>
</div>
<div class="cart-actions mt-3">
<ng-content />
</div>
</footer>
</section>

View File

@@ -0,0 +1,65 @@
:host {
display: block;
width: 100%;
height: 100%;
}
.cart-header {
padding: 12px 8px 0 24px;
}
.cart-title {
color: #a0a0a0;
font-size: 13px;
letter-spacing: 0.04em;
}
.cart-close-btn {
width: 24px;
height: 24px;
font-size: 14px;
color: #a0a0a0;
}
.cart-items-container {
min-height: 0;
padding: 0 0 0 24px;
scrollbar-color: #d5d5d5 transparent;
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
border-radius: 999px;
background-color: #d5d5d5;
}
}
.cart-footer {
padding: 8px 22px 14px 24px;
}
.cart-summary-text-muted {
color: #a0a0a0;
font-size: 13px;
font-weight: 325;
}
.cart-summary-text-muted-bold {
color: #a0a0a0;
font-size: 13px;
font-weight: 700;
}
.cart-total-text {
color: #666666;
font-size: 15px;
font-weight: 700;
}
.cart-actions {
display: flex;
gap: 12px;
}

View File

@@ -0,0 +1,144 @@
import '@angular/compiler';
import { signal } from '@angular/core';
import { TestBed, getTestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import {
BrowserTestingModule,
platformBrowserTesting
} from '@angular/platform-browser/testing';
import { of } from 'rxjs';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { CartService } from '../../../core/services/cart/cart.service';
import { ModalService } from '../../../core/services/modal.service';
import { ToastService } from '../../../core/services/toast.service';
import { CartComponent } from './cart.component';
describe('CartComponent', () => {
beforeAll(() => {
try {
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting()
);
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
});
afterEach(() => {
TestBed.resetTestingModule();
});
it('opens a confirm delete modal before removing an item', async () => {
const removeItem = vi.fn().mockReturnValue(of({ message: 'Producto eliminado.' }));
const openConfirmDelete = vi.fn().mockReturnValue(of(true));
await TestBed.configureTestingModule({
imports: [CartComponent],
providers: [
{
provide: CartService,
useValue: {
cart: signal(null).asReadonly(),
updateItemQuantity: vi.fn(),
removeItem
}
},
{
provide: ModalService,
useValue: {
openConfirmDelete
}
},
{
provide: ToastService,
useValue: {
success: vi.fn(),
info: vi.fn(),
danger: vi.fn()
}
}
]
}).compileComponents();
const fixture = TestBed.createComponent(CartComponent);
fixture.componentRef.setInput('items', [
{
productVariantId: 10,
imageUrl: null,
product: 'Producto de prueba',
originalPrice: null,
discountedPrice: 1000,
discountPercentage: null,
attributes: [],
quantity: 1
}
]);
fixture.detectChanges();
fixture.debugElement.query(By.css('app-cart-item')).triggerEventHandler('remove');
expect(openConfirmDelete).toHaveBeenCalledWith({
title: 'Eliminar producto',
content:
'Se eliminara "Producto de prueba" del carrito. Esta accion no se puede deshacer.',
confirmLabel: 'Eliminar',
cancelLabel: 'Cancelar'
});
expect(removeItem).toHaveBeenCalledWith(10);
});
it('does not remove the item when the delete confirmation is cancelled', async () => {
const removeItem = vi.fn().mockReturnValue(of({}));
const openConfirmDelete = vi.fn().mockReturnValue(of(false));
await TestBed.configureTestingModule({
imports: [CartComponent],
providers: [
{
provide: CartService,
useValue: {
cart: signal(null).asReadonly(),
updateItemQuantity: vi.fn(),
removeItem
}
},
{
provide: ModalService,
useValue: {
openConfirmDelete
}
},
{
provide: ToastService,
useValue: {
success: vi.fn(),
info: vi.fn(),
danger: vi.fn()
}
}
]
}).compileComponents();
const fixture = TestBed.createComponent(CartComponent);
fixture.componentRef.setInput('items', [
{
productVariantId: 10,
imageUrl: null,
product: 'Producto de prueba',
originalPrice: null,
discountedPrice: 1000,
discountPercentage: null,
attributes: [],
quantity: 1
}
]);
fixture.detectChanges();
fixture.debugElement.query(By.css('app-cart-item')).triggerEventHandler('remove');
expect(openConfirmDelete).toHaveBeenCalled();
expect(removeItem).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,161 @@
import { ChangeDetectionStrategy, Component, computed, inject, input, output, signal } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { Subject, EMPTY } from 'rxjs';
import { catchError, debounceTime, groupBy, mergeMap, switchMap, tap } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CartItemAttribute, CartItemComponent } from '../cart-item/cart-item.component';
import { ModalService } from '../../../core/services/modal.service';
import { CartService } from '../../../core/services/cart/cart.service';
import { ToastService } from '../../../core/services/toast.service';
export interface CartItemMock {
productVariantId?: number;
imageUrl: string | null;
product: string;
originalPrice: number | null;
discountedPrice: number;
discountPercentage: number | null;
attributes: CartItemAttribute[];
quantity: number;
}
@Component({
selector: 'app-cart',
standalone: true,
imports: [CartItemComponent],
templateUrl: './cart.component.html',
styleUrl: './cart.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CartComponent {
private readonly cartService = inject(CartService);
private readonly modalService = inject(ModalService);
private readonly toastService = inject(ToastService);
private readonly quantityUpdates$ = new Subject<{ productVariantId: number; quantity: number }>();
readonly title = input<string>('CARRITO');
readonly showClose = input<boolean>(false);
readonly items = input<CartItemMock[]>([]);
readonly subtotal = input<number>(0);
readonly discount = input<number>(0);
readonly total = input<number>(0);
readonly backgroundColor = input<string>('#ffffff');
readonly closed = output<void>();
protected readonly resetKey = signal(0);
constructor() {
this.quantityUpdates$.pipe(
groupBy(update => update.productVariantId),
mergeMap(group$ => group$.pipe(
debounceTime(1000),
switchMap(update => this.cartService.updateItemQuantity(update.productVariantId, update.quantity).pipe(
tap({
next: (res) => {
const msg = res.message || 'Cantidad de producto actualizada.';
this.toastService.success(msg);
},
error: (err: HttpErrorResponse) => {
console.error('Error updating cart quantity', err);
const msg = err.error?.message || 'Error al actualizar la cantidad del producto.';
this.toastService.danger(msg);
this.resetKey.update(k => k + 1);
}
}),
catchError(() => EMPTY)
))
)),
takeUntilDestroyed()
).subscribe();
}
protected onItemQuantityChange(index: number, newQuantity: number): void {
const mockItem = this.items()[index];
const productVariantId = mockItem?.productVariantId;
if (productVariantId) {
this.quantityUpdates$.next({
productVariantId,
quantity: newQuantity
});
} else {
const item = this.cartService.cart()?.items[index];
if (item) {
this.quantityUpdates$.next({
productVariantId: item.product_variant_id,
quantity: newQuantity
});
}
}
}
protected onItemRemove(index: number): void {
const target = this.resolveRemoveTarget(index);
if (!target) {
return;
}
this.modalService.openConfirmDelete({
title: 'Eliminar producto',
content: `Se eliminara "${target.productName}" del carrito. Esta accion no se puede deshacer.`,
confirmLabel: 'Eliminar',
cancelLabel: 'Cancelar'
}).subscribe((confirmed) => {
if (confirmed) {
this.removeItem(target.productVariantId);
}
});
}
protected readonly formattedSubtotal = computed(() => this.formatCurrency(this.subtotal()));
protected readonly formattedDiscount = computed(() => this.formatCurrency(this.discount()));
protected readonly formattedTotal = computed(() => this.formatCurrency(this.total()));
private formatCurrency(value: number): string {
const rounded = Math.round(value);
const parts = rounded.toString().split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, '.');
return `$ ${parts.join(',')}`;
}
private resolveRemoveTarget(
index: number
): { productVariantId: number; productName: string } | null {
const mockItem = this.items()[index];
const productVariantId = mockItem?.productVariantId;
if (productVariantId) {
return {
productVariantId,
productName: mockItem.product
};
}
const item = this.cartService.cart()?.items[index];
if (!item) {
return null;
}
return {
productVariantId: item.product_variant_id,
productName: item.product?.nombre ?? 'este producto'
};
}
private removeItem(productVariantId: number): void {
this.cartService.removeItem(productVariantId).subscribe({
next: (res) => {
const msg = res.message || 'Producto eliminado del carrito.';
this.toastService.info(msg);
},
error: (err: HttpErrorResponse) => {
console.error('Error removing item from cart', err);
const msg = err.error?.message || 'Error al eliminar el producto del carrito.';
this.toastService.danger(msg);
}
});
}
}

View File

@@ -0,0 +1,12 @@
<div class="confirm-modal">
<p class="confirm-modal__content">{{ data.content }}</p>
<div class="confirm-modal__actions">
<app-button variant="danger-secondary" (click)="cancel()">
{{ data.cancelLabel }}
</app-button>
<app-button variant="danger" (click)="confirm()">
{{ data.confirmLabel }}
</app-button>
</div>
</div>

View File

@@ -0,0 +1,19 @@
.confirm-modal {
display: grid;
gap: 1.5rem;
justify-items: center;
text-align: center;
}
.confirm-modal__content {
margin: 0;
color: #666666;
line-height: 1.5;
}
.confirm-modal__actions {
display: flex;
justify-content: center;
gap: 0.75rem;
flex-wrap: wrap;
}

View File

@@ -0,0 +1,96 @@
import '@angular/compiler';
import { TestBed, getTestBed } from '@angular/core/testing';
import {
BrowserTestingModule,
platformBrowserTesting
} from '@angular/platform-browser/testing';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import {
ConfirmModalData,
MODAL_DATA,
ModalRef
} from '../../../core/services/modal.service';
import { ConfirmDeleteModalComponent } from './confirm-delete-modal.component';
describe('ConfirmDeleteModalComponent', () => {
const data: ConfirmModalData = {
content: 'Se eliminara el elemento seleccionado.',
confirmLabel: 'Eliminar',
cancelLabel: 'Cancelar'
};
beforeAll(() => {
try {
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting()
);
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
});
afterEach(() => {
TestBed.resetTestingModule();
});
it('renders the configured content and uses danger for the confirm action', async () => {
await TestBed.configureTestingModule({
imports: [ConfirmDeleteModalComponent],
providers: [
{
provide: MODAL_DATA,
useValue: data
},
{
provide: ModalRef,
useValue: {
close: vi.fn()
}
}
]
}).compileComponents();
const fixture = TestBed.createComponent(ConfirmDeleteModalComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const buttons = element.querySelectorAll('button');
expect(element.textContent).toContain(data.content);
expect(element.textContent).toContain(data.confirmLabel);
expect(buttons[1].className).toContain('btn-danger');
});
it('closes with false on cancel and true on confirm', async () => {
const closeSpy = vi.fn();
await TestBed.configureTestingModule({
imports: [ConfirmDeleteModalComponent],
providers: [
{
provide: MODAL_DATA,
useValue: data
},
{
provide: ModalRef,
useValue: {
close: closeSpy
}
}
]
}).compileComponents();
const fixture = TestBed.createComponent(ConfirmDeleteModalComponent);
fixture.detectChanges();
const buttons = fixture.nativeElement.querySelectorAll('button');
buttons[0].click();
buttons[1].click();
expect(closeSpy).toHaveBeenNthCalledWith(1, false);
expect(closeSpy).toHaveBeenNthCalledWith(2, true);
});
});

View File

@@ -0,0 +1,28 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import {
ConfirmModalData,
MODAL_DATA,
ModalRef
} from '../../../core/services/modal.service';
import { ButtonComponent } from '../button/button.component';
@Component({
selector: 'app-confirm-delete-modal',
imports: [ButtonComponent],
templateUrl: './confirm-delete-modal.component.html',
styleUrl: './confirm-delete-modal.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ConfirmDeleteModalComponent {
protected readonly data = inject<ConfirmModalData>(MODAL_DATA);
private readonly modalRef = inject<ModalRef<boolean>>(ModalRef);
protected cancel(): void {
this.modalRef.close(false);
}
protected confirm(): void {
this.modalRef.close(true);
}
}

View File

@@ -0,0 +1,12 @@
<div class="confirm-modal">
<p class="confirm-modal__content">{{ data.content }}</p>
<div class="confirm-modal__actions">
<app-button variant="secondary" (click)="cancel()">
{{ data.cancelLabel }}
</app-button>
<app-button (click)="confirm()">
{{ data.confirmLabel }}
</app-button>
</div>
</div>

View File

@@ -0,0 +1,19 @@
.confirm-modal {
display: grid;
gap: 1.5rem;
justify-items: center;
text-align: center;
}
.confirm-modal__content {
margin: 0;
color: #666666;
line-height: 1.5;
}
.confirm-modal__actions {
display: flex;
justify-content: center;
gap: 0.75rem;
flex-wrap: wrap;
}

View File

@@ -0,0 +1,95 @@
import '@angular/compiler';
import { TestBed, getTestBed } from '@angular/core/testing';
import {
BrowserTestingModule,
platformBrowserTesting
} from '@angular/platform-browser/testing';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import {
ConfirmModalData,
MODAL_DATA,
ModalRef
} from '../../../core/services/modal.service';
import { ConfirmModalComponent } from './confirm-modal.component';
describe('ConfirmModalComponent', () => {
const data: ConfirmModalData = {
content: 'Se confirmara la operacion seleccionada.',
confirmLabel: 'Aceptar',
cancelLabel: 'Volver'
};
beforeAll(() => {
try {
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting()
);
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
});
afterEach(() => {
TestBed.resetTestingModule();
});
it('renders the configured content and labels', async () => {
await TestBed.configureTestingModule({
imports: [ConfirmModalComponent],
providers: [
{
provide: MODAL_DATA,
useValue: data
},
{
provide: ModalRef,
useValue: {
close: vi.fn()
}
}
]
}).compileComponents();
const fixture = TestBed.createComponent(ConfirmModalComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain(data.content);
expect(element.textContent).toContain(data.confirmLabel);
expect(element.textContent).toContain(data.cancelLabel);
});
it('closes with false on cancel and true on confirm', async () => {
const closeSpy = vi.fn();
await TestBed.configureTestingModule({
imports: [ConfirmModalComponent],
providers: [
{
provide: MODAL_DATA,
useValue: data
},
{
provide: ModalRef,
useValue: {
close: closeSpy
}
}
]
}).compileComponents();
const fixture = TestBed.createComponent(ConfirmModalComponent);
fixture.detectChanges();
const buttons = fixture.nativeElement.querySelectorAll('button');
buttons[0].click();
buttons[1].click();
expect(closeSpy).toHaveBeenNthCalledWith(1, false);
expect(closeSpy).toHaveBeenNthCalledWith(2, true);
});
});

View File

@@ -0,0 +1,28 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import {
ConfirmModalData,
MODAL_DATA,
ModalRef
} from '../../../core/services/modal.service';
import { ButtonComponent } from '../button/button.component';
@Component({
selector: 'app-confirm-modal',
imports: [ButtonComponent],
templateUrl: './confirm-modal.component.html',
styleUrl: './confirm-modal.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ConfirmModalComponent {
protected readonly data = inject<ConfirmModalData>(MODAL_DATA);
private readonly modalRef = inject<ModalRef<boolean>>(ModalRef);
protected cancel(): void {
this.modalRef.close(false);
}
protected confirm(): void {
this.modalRef.close(true);
}
}

View File

@@ -0,0 +1,8 @@
<button
type="button"
[disabled]="disabled()"
[ngClass]="buttonClasses()"
[attr.aria-label]="ariaLabel() || variant()"
>
<i [ngClass]="iconClass()"></i>
</button>

View File

@@ -0,0 +1,76 @@
:host {
display: inline-block;
vertical-align: middle;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
padding: 4px;
margin: 0;
color: #666666;
cursor: pointer;
outline: none;
transition: color 0.15s ease-in-out, transform 0.1s ease-in-out;
border-radius: 4px;
i {
display: inline-flex;
align-items: center;
justify-content: center;
width: 13px;
height: 13px;
font-size: 13px;
line-height: 1;
}
// Active state subtle scale down
&:active:not(:disabled) {
transform: scale(0.92);
}
// Focus ring for accessibility
&:focus-visible {
outline: 2px solid var(--tenant-primary);
outline-offset: 2px;
}
// Disabled state
&:disabled {
color: #A0A0A0;
cursor: not-allowed;
pointer-events: none;
}
}
// Hover/Active/Focus colors (non-disabled)
.icon-btn:hover:not(:disabled),
.icon-btn:focus-visible:not(:disabled),
.icon-btn.hover-preview:not(:disabled),
:host(.hover-preview) .icon-btn:not(:disabled) {
color: var(--tenant-primary);
}
// Trash is special (uses danger color for hover/active)
.icon-btn--trash {
&:hover:not(:disabled),
&:focus-visible:not(:disabled),
&.hover-preview:not(:disabled) {
color: var(--tenant-danger);
}
}
:host(.hover-preview) .icon-btn--trash:not(:disabled) {
color: var(--tenant-danger);
}
.icon-btn--user {
i {
width: 23px;
height: 23px;
font-size: 23px;
}
}

View File

@@ -0,0 +1,41 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { NgClass } from '@angular/common';
export type IconButtonVariant =
| 'user'
| 'trash'
| 'angle-right'
| 'angle-left'
| 'angle-up'
| 'angle-down'
| 'copy';
@Component({
selector: 'app-icon-button',
imports: [NgClass],
templateUrl: './icon-button.component.html',
styleUrl: './icon-button.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class IconButtonComponent {
readonly variant = input.required<IconButtonVariant>();
readonly disabled = input(false);
readonly ariaLabel = input<string>('');
protected readonly iconClass = computed(() => {
const classes: Record<IconButtonVariant, string> = {
user: 'fa-regular fa-circle-user',
trash: 'fa-solid fa-trash',
'angle-right': 'fa-solid fa-angle-right',
'angle-left': 'fa-solid fa-angle-left',
'angle-up': 'fa-solid fa-angle-up',
'angle-down': 'fa-solid fa-angle-down',
copy: 'fa-solid fa-copy'
};
return classes[this.variant()];
});
protected readonly buttonClasses = computed(() => {
return ['icon-btn', `icon-btn--${this.variant()}`];
});
}

View File

@@ -14,7 +14,7 @@
right: 1rem;
top: 50%;
z-index: 1;
color: #495057;
color: #a0a0a0;
transform: translateY(-50%);
pointer-events: none;
}
@@ -23,7 +23,8 @@
min-height: 40px;
padding: 0.625rem 1rem;
border-radius: 0.5rem;
color: #212529;
border-color: #cccccc;
color: #666666;
font-size: 15px;
font-weight: 400;
@@ -34,6 +35,10 @@
&:disabled {
opacity: 1;
}
&::placeholder {
color: #666666;
}
}
.input-with-search-icon {
@@ -116,7 +121,7 @@ input[type='file'].form-control {
padding: 0;
border: 0;
background: transparent;
color: #495057;
color: #a0a0a0;
transform: translateY(-50%);
cursor: pointer;
transition: color 0.2s ease;
@@ -127,10 +132,10 @@ input[type='file'].form-control {
}
.input-icon-active {
color: #adb5bd;
color: #a0a0a0;
&:hover {
color: #495057;
color: #8a8a8a;
}
}
@@ -145,7 +150,7 @@ input[type='file'].form-control {
right: 1rem;
top: 50%;
z-index: 1;
color: #495057;
color: #a0a0a0;
transform: translateY(-50%);
pointer-events: none;
}

View File

@@ -0,0 +1,11 @@
@if (activeModal(); as modal) {
<app-modal-shell
[title]="modal.config.title"
[size]="modal.config.size"
[showCloseButton]="modal.config.showCloseButton"
(backdropClick)="onBackdropClick()"
(closeRequested)="onCloseRequested()"
>
<ng-container *ngComponentOutlet="modal.component; injector: contentInjector()" />
</app-modal-shell>
}

View File

@@ -0,0 +1,196 @@
import '@angular/compiler';
import { Component, inject } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { TestBed, getTestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import {
BrowserTestingModule,
platformBrowserTesting
} from '@angular/platform-browser/testing';
import {
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import {
MODAL_DATA,
ModalRef,
ModalService
} from '../../../core/services/modal.service';
import { ModalHostComponent } from './modal-host.component';
@Component({
standalone: true,
template: `
<div class="modal-test-content">
<span class="modal-test-title">{{ data?.title }}</span>
<button type="button" class="modal-test-close" (click)="close()">Cerrar</button>
</div>
`
})
class ModalContentTestComponent {
readonly data = inject<{ title: string } | null>(MODAL_DATA);
readonly modalRef = inject<ModalRef<string>>(ModalRef);
close(): void {
this.modalRef.close('accepted');
}
}
describe('ModalHostComponent', () => {
let service: ModalService;
let doc: Document;
beforeAll(() => {
try {
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting()
);
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
});
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ModalHostComponent]
}).compileComponents();
service = TestBed.inject(ModalService);
doc = TestBed.inject(DOCUMENT);
});
afterEach(() => {
doc.body.style.overflow = '';
TestBed.resetTestingModule();
});
it('renders nothing when no modal is active', () => {
const fixture = TestBed.createComponent(ModalHostComponent);
fixture.detectChanges();
expect(fixture.nativeElement.textContent?.trim()).toBe('');
expect(doc.body.style.overflow).toBe('');
});
it('opens and renders the requested component with its title and data', () => {
const fixture = TestBed.createComponent(ModalHostComponent);
service.open(ModalContentTestComponent, {
title: 'Editar producto',
data: { title: 'Contenido del modal' }
});
fixture.detectChanges();
const title = fixture.nativeElement.querySelector('.modal-shell__title');
const content = fixture.nativeElement.querySelector('.modal-test-title');
expect(title?.textContent).toContain('Editar producto');
expect(content?.textContent).toContain('Contenido del modal');
expect(doc.body.style.overflow).toBe('hidden');
});
it('closes with a result from the child component', () => {
const fixture = TestBed.createComponent(ModalHostComponent);
const ref = service.open(ModalContentTestComponent, {
data: { title: 'Cerrar' }
});
const closedSpy = vi.fn();
ref.afterClosed$.subscribe(closedSpy);
fixture.detectChanges();
const closeButton = fixture.nativeElement.querySelector('.modal-test-close') as HTMLButtonElement;
closeButton.click();
fixture.detectChanges();
expect(closedSpy).toHaveBeenCalledWith('accepted');
expect(service.activeModal()).toBeNull();
expect(doc.body.style.overflow).toBe('');
});
it('closes on backdrop click when enabled', () => {
const fixture = TestBed.createComponent(ModalHostComponent);
const ref = service.open(ModalContentTestComponent, {
data: { title: 'Backdrop' },
closeOnBackdrop: true
});
fixture.detectChanges();
const backdrop = fixture.nativeElement.querySelector('.modal-shell') as HTMLDivElement;
backdrop.click();
fixture.detectChanges();
expect(service.activeModal()).toBeNull();
expect(ref.dismissReason()).toBe('backdrop');
});
it('does not close on backdrop click when disabled', () => {
const fixture = TestBed.createComponent(ModalHostComponent);
const ref = service.open(ModalContentTestComponent, {
data: { title: 'Persistente' },
closeOnBackdrop: false
});
fixture.detectChanges();
const backdrop = fixture.nativeElement.querySelector('.modal-shell') as HTMLDivElement;
backdrop.click();
fixture.detectChanges();
expect(service.activeModal()?.ref).toBe(ref);
});
it('closes on Escape when enabled', () => {
const fixture = TestBed.createComponent(ModalHostComponent);
const ref = service.open(ModalContentTestComponent, {
data: { title: 'Escape' },
closeOnEscape: true
});
fixture.detectChanges();
doc.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
fixture.detectChanges();
expect(service.activeModal()).toBeNull();
expect(ref.dismissReason()).toBe('escape');
});
it('keeps the modal open on Escape when disabled', () => {
const fixture = TestBed.createComponent(ModalHostComponent);
const ref = service.open(ModalContentTestComponent, {
data: { title: 'No Escape' },
closeOnEscape: false
});
fixture.detectChanges();
doc.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
fixture.detectChanges();
expect(service.activeModal()?.ref).toBe(ref);
});
it('closes from the shell close button with a programmatic dismiss reason', () => {
const fixture = TestBed.createComponent(ModalHostComponent);
const ref = service.open(ModalContentTestComponent, {
title: 'Con cierre',
data: { title: 'Boton' }
});
fixture.detectChanges();
const closeButton = fixture.debugElement.query(By.css('.btn-close')).nativeElement as HTMLButtonElement;
closeButton.click();
fixture.detectChanges();
expect(service.activeModal()).toBeNull();
expect(ref.dismissReason()).toBe('programmatic');
});
});

View File

@@ -0,0 +1,93 @@
import { DOCUMENT, NgComponentOutlet, isPlatformBrowser } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
Injector,
PLATFORM_ID,
computed,
effect,
inject,
viewChild
} from '@angular/core';
import {
MODAL_DATA,
ModalRef,
ModalService
} from '../../../core/services/modal.service';
import { ModalShellComponent } from '../modal-shell/modal-shell.component';
@Component({
selector: 'app-modal-host',
imports: [NgComponentOutlet, ModalShellComponent],
templateUrl: './modal-host.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ModalHostComponent {
private readonly modalService = inject(ModalService);
private readonly injector = inject(Injector);
private readonly document = inject(DOCUMENT);
private readonly platformId = inject(PLATFORM_ID);
private readonly modalShell = viewChild(ModalShellComponent);
private readonly isBrowser = isPlatformBrowser(this.platformId);
readonly activeModal = this.modalService.activeModal;
protected readonly contentInjector = computed<Injector | undefined>(() => {
const modal = this.activeModal();
if (!modal) {
return undefined;
}
return Injector.create({
providers: [
{ provide: ModalRef, useValue: modal.ref },
{ provide: MODAL_DATA, useValue: modal.config.data ?? null }
],
parent: this.injector
});
});
constructor() {
effect((onCleanup) => {
const modal = this.activeModal();
if (!this.isBrowser || !modal) {
return;
}
const body = this.document.body;
const previousOverflow = body.style.overflow;
body.style.overflow = 'hidden';
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || !this.activeModal()?.config.closeOnEscape) {
return;
}
event.preventDefault();
this.activeModal()?.ref.dismiss('escape');
};
this.document.addEventListener('keydown', onKeyDown);
queueMicrotask(() => this.modalShell()?.focusInitialElement());
onCleanup(() => {
this.document.removeEventListener('keydown', onKeyDown);
body.style.overflow = previousOverflow;
});
});
}
protected onBackdropClick(): void {
const modal = this.activeModal();
if (modal?.config.closeOnBackdrop) {
modal.ref.dismiss('backdrop');
}
}
protected onCloseRequested(): void {
this.activeModal()?.ref.dismiss('programmatic');
}
}

View File

@@ -0,0 +1,39 @@
<div class="modal-shell" (click)="backdropClick.emit()">
<div
class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-shell__dialog"
[ngClass]="dialogClass()"
(click)="$event.stopPropagation()"
>
<div
#panel
class="modal-content border-0 shadow-lg modal-shell__content"
role="dialog"
aria-modal="true"
[attr.aria-labelledby]="ariaLabelledBy"
tabindex="-1"
>
@if (title() || showCloseButton()) {
<header class="modal-header border-0 modal-shell__header">
@if (title()) {
<h2 class="modal-title modal-shell__title" [id]="titleId">{{ title() }}</h2>
} @else {
<span></span>
}
@if (showCloseButton()) {
<button
type="button"
class="btn-close"
aria-label="Cerrar modal"
(click)="closeRequested.emit()"
></button>
}
</header>
}
<div class="modal-body modal-shell__body">
<ng-content />
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,97 @@
:host {
display: contents;
}
.modal-shell {
position: fixed;
inset: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(16, 18, 22, 0.48);
backdrop-filter: blur(2px);
}
.modal-shell__dialog {
width: min(100%, 40rem);
margin: 0;
}
.modal-shell__dialog.modal-sm {
width: min(100%, 24rem);
}
.modal-shell__dialog.modal-lg {
width: min(100%, 52rem);
}
.modal-shell__dialog.modal-xl {
width: min(100%, 68rem);
}
.modal-shell__dialog--full {
width: min(100%, 92rem);
height: min(100%, calc(100dvh - 2rem));
}
.modal-shell__content {
max-height: calc(100dvh - 2rem);
border-radius: 1.25rem;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 248, 248, 0.98));
}
.modal-shell__dialog--full .modal-shell__content {
height: 100%;
max-height: 100%;
}
.modal-shell__header {
position: relative;
justify-content: center;
align-items: flex-start;
padding: 1.25rem 1.25rem 0.75rem;
}
.modal-shell__title {
margin: 0;
width: 100%;
font-size: clamp(1.15rem, 1rem + 0.4vw, 1.5rem);
font-weight: 700;
line-height: 1.15;
color: #202020;
text-align: center;
}
.modal-shell__header .btn-close {
position: absolute;
top: 1.25rem;
right: 1.25rem;
}
.modal-shell__body {
padding: 0 1.25rem 1.25rem;
color: #303030;
}
@media (max-width: 576px) {
.modal-shell {
padding: 0.75rem;
align-items: flex-end;
}
.modal-shell__dialog,
.modal-shell__dialog.modal-sm,
.modal-shell__dialog.modal-lg,
.modal-shell__dialog.modal-xl,
.modal-shell__dialog--full {
width: 100%;
}
.modal-shell__content {
max-height: min(100dvh - 1.5rem, 48rem);
border-radius: 1.25rem 1.25rem 0.75rem 0.75rem;
}
}

View File

@@ -0,0 +1,64 @@
import { NgClass } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
ElementRef,
computed,
input,
output,
viewChild
} from '@angular/core';
import { ModalSize } from '../../../core/services/modal.service';
@Component({
selector: 'app-modal-shell',
imports: [NgClass],
templateUrl: './modal-shell.component.html',
styleUrl: './modal-shell.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ModalShellComponent {
private readonly panel = viewChild.required<ElementRef<HTMLElement>>('panel');
readonly title = input<string | undefined>();
readonly size = input<ModalSize>('md');
readonly showCloseButton = input(true);
readonly backdropClick = output<void>();
readonly closeRequested = output<void>();
protected readonly dialogClass = computed(() => {
const size = this.size();
return {
'modal-sm': size === 'sm',
'modal-lg': size === 'lg',
'modal-xl': size === 'xl',
'modal-shell__dialog--full': size === 'full'
};
});
protected readonly titleId = `modal-title-${Math.random().toString(36).slice(2, 9)}`;
protected get ariaLabelledBy(): string | null {
return this.title() ? this.titleId : null;
}
focusInitialElement(): void {
const panel = this.panel().nativeElement;
const focusTarget = panel.querySelector<HTMLElement>(
[
'[autofocus]',
'button:not([disabled])',
'[href]',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(', ')
);
(focusTarget ?? panel).focus();
}
}

View File

@@ -0,0 +1,23 @@
<div class="quantity-selector d-inline-flex align-items-center overflow-hidden rounded" [class.quantity-selector--small]="size() === 'small'" aria-label="Selector de cantidad">
<button
type="button"
class="quantity-selector__button border-0 p-0 fw-bold lh-1"
aria-label="Disminuir cantidad"
[disabled]="quantity() <= min()"
(click)="onDecrease()"
>
-
</button>
<span class="quantity-selector__value flex-grow-1 min-w-0 text-center fw-bold lh-1 user-select-none">{{ quantity() }}</span>
<button
type="button"
class="quantity-selector__button border-0 p-0 fw-bold lh-1"
aria-label="Aumentar cantidad"
[disabled]="quantity() >= max()"
(click)="onIncrease()"
>
+
</button>
</div>

View File

@@ -0,0 +1,62 @@
:host {
display: inline-block;
}
.quantity-selector {
border: 1px solid #d8d8d8;
width: 78px;
height: 40px;
min-height: 40px;
background: #f6f6f6;
box-sizing: border-box;
&__button {
width: 24px;
height: 100%;
font-size: 20px;
color: #6f6f6f;
background: #f6f6f6;
transition: background-color 0.2s ease, color 0.2s ease;
cursor: pointer;
&:not(:disabled):hover {
background-color: #e9e9e9;
color: #333333;
}
&:disabled {
color: #A0A0A0;
opacity: 1;
cursor: not-allowed;
}
}
&__value {
font-size: 16px;
color: #6f6f6f;
}
&--small {
width: 46px;
height: 21px;
min-height: 21px;
border: 1px solid #cfcfcf;
background: #ffffff;
.quantity-selector__button {
width: 15px;
font-size: 10px;
background: #ffffff;
color: #666666;
&:not(:disabled):hover {
background-color: #f0f0f0;
}
}
.quantity-selector__value {
font-size: 10px;
color: #666666;
}
}
}

View File

@@ -0,0 +1,33 @@
import { ChangeDetectionStrategy, Component, input, model, output } from '@angular/core';
@Component({
selector: 'app-quantity-selector',
standalone: true,
imports: [],
templateUrl: './quantity-selector.component.html',
styleUrl: './quantity-selector.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class QuantitySelectorComponent {
readonly quantity = model<number>(1);
readonly min = input<number>(1);
readonly max = input<number>(100);
readonly size = input<'small' | 'medium'>('medium');
readonly increase = output<void>();
readonly decrease = output<void>();
protected onDecrease(): void {
if (this.quantity() > this.min()) {
this.quantity.set(this.quantity() - 1);
this.decrease.emit();
}
}
protected onIncrease(): void {
if (this.quantity() < this.max()) {
this.quantity.set(this.quantity() + 1);
this.increase.emit();
}
}
}