From 16eea32ce7cbd3c250e560153906566c30f915c4 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 2 Jul 2026 09:27:01 -0300 Subject: [PATCH] feat: implement cart icon component with quantity badge and integrate into store header for improved UI --- src/app/app.routes.spec.ts | 10 +++ .../store-header/store-header.component.html | 24 +++--- .../store-header/store-header.component.ts | 15 ++-- .../store-layout/store-layout.component.html | 2 +- .../store-layout.component.spec.ts | 10 +++ .../store-layout/store-layout.component.ts | 30 ++++--- .../reutilizables-test-page.component.html | 36 +++++++++ .../reutilizables-test-page.component.ts | 4 +- .../cart-icon/cart-icon.component.html | 13 +++ .../cart-icon/cart-icon.component.scss | 79 +++++++++++++++++++ .../cart-icon/cart-icon.component.spec.ts | 60 ++++++++++++++ .../cart-icon/cart-icon.component.ts | 19 +++++ 12 files changed, 266 insertions(+), 36 deletions(-) create mode 100644 src/app/shared/components/cart-icon/cart-icon.component.html create mode 100644 src/app/shared/components/cart-icon/cart-icon.component.scss create mode 100644 src/app/shared/components/cart-icon/cart-icon.component.spec.ts create mode 100644 src/app/shared/components/cart-icon/cart-icon.component.ts diff --git a/src/app/app.routes.spec.ts b/src/app/app.routes.spec.ts index 88f86c5..8e29615 100644 --- a/src/app/app.routes.spec.ts +++ b/src/app/app.routes.spec.ts @@ -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(); diff --git a/src/app/core/layout/store-layout/store-header/store-header.component.html b/src/app/core/layout/store-layout/store-header/store-header.component.html index 2b901c5..7c365dc 100644 --- a/src/app/core/layout/store-layout/store-header/store-header.component.html +++ b/src/app/core/layout/store-layout/store-header/store-header.component.html @@ -6,8 +6,8 @@ data-testid="store-header-brand-slot" aria-label="Logo del comercio" > - @if (logoUrl) { - + @if (logoUrl()) { + } @else {
- @for (action of headerActions; track action.label) { - - } + +
diff --git a/src/app/core/layout/store-layout/store-header/store-header.component.ts b/src/app/core/layout/store-layout/store-header/store-header.component.ts index a823085..941e7b4 100644 --- a/src/app/core/layout/store-layout/store-header/store-header.component.ts +++ b/src/app/core/layout/store-layout/store-header/store-header.component.ts @@ -1,17 +1,14 @@ -import { Component, Input } from '@angular/core'; - -export type StoreHeaderAction = { - label: string; - iconClass: string; -}; +import { Component, input } 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(null); + readonly cartQuantity = input(0); } diff --git a/src/app/core/layout/store-layout/store-layout.component.html b/src/app/core/layout/store-layout/store-layout.component.html index 79b0636..0db4524 100644 --- a/src/app/core/layout/store-layout/store-layout.component.html +++ b/src/app/core/layout/store-layout/store-layout.component.html @@ -1,5 +1,5 @@
- +
diff --git a/src/app/core/layout/store-layout/store-layout.component.spec.ts b/src/app/core/layout/store-layout/store-layout.component.spec.ts index c1fb842..5700e82 100644 --- a/src/app/core/layout/store-layout/store-layout.component.spec.ts +++ b/src/app/core/layout/store-layout/store-layout.component.spec.ts @@ -2,8 +2,11 @@ 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 { StoreLayoutComponent } from './store-layout.component'; const tenant: Tenant = { @@ -37,6 +40,13 @@ describe('StoreLayoutComponent', () => { getTenant: () => tenantState(), bootstrap: vi.fn().mockResolvedValue(undefined) } + }, + { + provide: CartService, + useValue: { + cart: signal(null).asReadonly(), + loadCart: () => of({ id: 1, items: [], subtotal: '0' }) + } } ] }).compileComponents(); diff --git a/src/app/core/layout/store-layout/store-layout.component.ts b/src/app/core/layout/store-layout/store-layout.component.ts index 932864a..bce675b 100644 --- a/src/app/core/layout/store-layout/store-layout.component.ts +++ b/src/app/core/layout/store-layout/store-layout.component.ts @@ -1,8 +1,9 @@ -import { Component, inject } from '@angular/core'; +import { Component, computed, inject, OnInit } 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'; @Component({ selector: 'app-store-layout', @@ -10,22 +11,25 @@ import { StoreHeaderComponent, StoreHeaderAction } from './store-header/store-he 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 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[] = [ { diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html index 92ad170..48c167c 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html @@ -114,6 +114,42 @@
+
+

Cart Icons

+

+ Icono de carrito con badge opcional indicando cantidad. +

+ +
+ +
+ Disabled +
+ + +
+
+ + +
+ Normal +
+ + +
+
+ + +
+ Active / Hover +
+ + +
+
+
+
+
diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts index c1db031..013d736 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts @@ -6,6 +6,7 @@ import { PaginatorComponent } from '../../../../shared/components/paginator/pagi import { ProductCardComponent } from '../../../../shared/components/product-card/product-card.component'; import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component'; import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component'; +import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component'; import { TenantService } from '../../../../core/services/tenant.service'; import { ToastService } from '../../../../core/services/toast.service'; @@ -18,7 +19,8 @@ import { ToastService } from '../../../../core/services/toast.service'; PaginatorComponent, ProductCardComponent, StoreSectionComponent, - IconButtonComponent + IconButtonComponent, + CartIconComponent ], templateUrl: './reutilizables-test-page.component.html', styleUrl: './reutilizables-test-page.component.scss' diff --git a/src/app/shared/components/cart-icon/cart-icon.component.html b/src/app/shared/components/cart-icon/cart-icon.component.html new file mode 100644 index 0000000..7cf6e49 --- /dev/null +++ b/src/app/shared/components/cart-icon/cart-icon.component.html @@ -0,0 +1,13 @@ + diff --git a/src/app/shared/components/cart-icon/cart-icon.component.scss b/src/app/shared/components/cart-icon/cart-icon.component.scss new file mode 100644 index 0000000..49426b6 --- /dev/null +++ b/src/app/shared/components/cart-icon/cart-icon.component.scss @@ -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; +} diff --git a/src/app/shared/components/cart-icon/cart-icon.component.spec.ts b/src/app/shared/components/cart-icon/cart-icon.component.spec.ts new file mode 100644 index 0000000..eaca57a --- /dev/null +++ b/src/app/shared/components/cart-icon/cart-icon.component.spec.ts @@ -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); + }); +}); diff --git a/src/app/shared/components/cart-icon/cart-icon.component.ts b/src/app/shared/components/cart-icon/cart-icon.component.ts new file mode 100644 index 0000000..25d59fc --- /dev/null +++ b/src/app/shared/components/cart-icon/cart-icon.component.ts @@ -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(undefined); + readonly disabled = input(false); + readonly ariaLabel = input('Carrito de compras'); + + protected readonly hasQuantity = computed(() => { + const q = this.quantity(); + return q !== null && q !== undefined && q > 0; + }); +}