feat(cart): refactor cart item structure and update related components for consistency

This commit is contained in:
2026-07-16 10:02:24 -03:00
parent e4195a8567
commit 8b785fd631
10 changed files with 246 additions and 195 deletions

View File

@@ -1,16 +1,28 @@
<section class="d-flex flex-column h-100 overflow-hidden text-secondary" [style.background-color]="backgroundColor()">
<section
class="d-flex flex-column h-100 overflow-hidden text-secondary"
[style.background-color]="backgroundColor()"
>
<header class="d-flex align-items-center p-3 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()">
<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 w-100 flex-grow-1 overflow-y-auto cart-items-container">
@for (item of items(); track item.productVariantId || item.product + item.discountedPrice; let idx = $index) {
@for (
item of items();
track item.cartItemId || item.product + item.discountedPrice;
let idx = $index
) {
<app-cart-item
[imageUrl]="item.imageUrl"
[product]="item.product"
@@ -46,6 +58,3 @@
</div>
</footer>
</section>

View File

@@ -2,10 +2,7 @@ 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 { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { of, throwError } from 'rxjs';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
@@ -17,10 +14,7 @@ import { CartComponent } from './cart.component';
describe('CartComponent', () => {
beforeAll(() => {
try {
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting()
);
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
@@ -42,38 +36,38 @@ describe('CartComponent', () => {
useValue: {
cart: signal(null).asReadonly(),
updateItemQuantity: vi.fn(),
removeItem
}
removeItem,
},
},
{
provide: ModalService,
useValue: {
openConfirmDelete
}
openConfirmDelete,
},
},
{
provide: ToastService,
useValue: {
success: vi.fn(),
info: vi.fn(),
danger: vi.fn()
}
}
]
danger: vi.fn(),
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(CartComponent);
fixture.componentRef.setInput('items', [
{
productVariantId: 10,
cartItemId: 10,
imageUrl: null,
product: 'Producto de prueba',
originalPrice: null,
discountedPrice: 1000,
discountPercentage: null,
attributes: [],
quantity: 1
}
quantity: 1,
},
]);
fixture.detectChanges();
@@ -81,10 +75,9 @@ describe('CartComponent', () => {
expect(openConfirmDelete).toHaveBeenCalledWith({
title: 'Eliminar producto',
content:
'Se eliminara "Producto de prueba" del carrito. Esta accion no se puede deshacer.',
content: 'Se eliminara "Producto de prueba" del carrito. Esta accion no se puede deshacer.',
confirmLabel: 'Eliminar',
cancelLabel: 'Cancelar'
cancelLabel: 'Cancelar',
});
expect(removeItem).toHaveBeenCalledWith(10);
});
@@ -101,38 +94,38 @@ describe('CartComponent', () => {
useValue: {
cart: signal(null).asReadonly(),
updateItemQuantity: vi.fn(),
removeItem
}
removeItem,
},
},
{
provide: ModalService,
useValue: {
openConfirmDelete
}
openConfirmDelete,
},
},
{
provide: ToastService,
useValue: {
success: vi.fn(),
info: vi.fn(),
danger: vi.fn()
}
}
]
danger: vi.fn(),
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(CartComponent);
fixture.componentRef.setInput('items', [
{
productVariantId: 10,
cartItemId: 10,
imageUrl: null,
product: 'Producto de prueba',
originalPrice: null,
discountedPrice: 1000,
discountPercentage: null,
attributes: [],
quantity: 1
}
quantity: 1,
},
]);
fixture.detectChanges();
@@ -155,36 +148,36 @@ describe('CartComponent', () => {
useValue: {
cart: signal(null).asReadonly(),
updateItemQuantity,
removeItem: vi.fn()
}
removeItem: vi.fn(),
},
},
{
provide: ModalService,
useValue: {}
useValue: {},
},
{
provide: ToastService,
useValue: {
success: vi.fn(),
info: vi.fn(),
danger
}
}
]
danger,
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(CartComponent);
const component = fixture.componentInstance;
const item = {
productVariantId: 10,
cartItemId: 10,
imageUrl: null,
product: 'Producto de prueba',
originalPrice: null,
discountedPrice: 1000,
discountPercentage: null,
attributes: [],
quantity: 1
quantity: 1,
};
fixture.componentRef.setInput('items', [item]);
@@ -221,36 +214,36 @@ describe('CartComponent', () => {
useValue: {
cart: signal(null).asReadonly(),
updateItemQuantity,
removeItem: vi.fn()
}
removeItem: vi.fn(),
},
},
{
provide: ModalService,
useValue: {}
useValue: {},
},
{
provide: ToastService,
useValue: {
success,
info: vi.fn(),
danger: vi.fn()
}
}
]
danger: vi.fn(),
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(CartComponent);
const component = fixture.componentInstance;
const item = {
productVariantId: 10,
cartItemId: 10,
imageUrl: null,
product: 'Producto de prueba',
originalPrice: null,
discountedPrice: 1000,
discountPercentage: null,
attributes: [],
quantity: 1
quantity: 1,
};
fixture.componentRef.setInput('items', [item]);

View File

@@ -1,4 +1,12 @@
import { ChangeDetectionStrategy, Component, computed, inject, input, output, signal } from '@angular/core';
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';
@@ -9,7 +17,7 @@ import { CartService } from '../../../core/services/cart/cart.service';
import { ToastService } from '../../../core/services/toast.service';
export interface CartItemMock {
productVariantId?: number;
cartItemId?: number;
imageUrl: string | null;
product: string;
originalPrice: number | null;
@@ -25,13 +33,13 @@ export interface CartItemMock {
imports: [CartItemComponent],
templateUrl: './cart.component.html',
styleUrl: './cart.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
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 }>();
private readonly quantityUpdates$ = new Subject<{ cartItemId: number; quantity: number }>();
readonly title = input<string>('CARRITO');
readonly showClose = input<boolean>(false);
@@ -46,68 +54,75 @@ export class CartComponent {
protected readonly quantityOverrides = signal<Record<number, number>>({});
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);
this.clearOverride(update.productVariantId);
},
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.clearOverride(update.productVariantId);
}
}),
catchError(() => EMPTY)
))
)),
takeUntilDestroyed()
).subscribe();
this.quantityUpdates$
.pipe(
groupBy((update) => update.cartItemId),
mergeMap((group$) =>
group$.pipe(
debounceTime(1000),
switchMap((update) =>
this.cartService.updateItemQuantity(update.cartItemId, update.quantity).pipe(
tap({
next: (res) => {
const msg = res.message || 'Cantidad de producto actualizada.';
this.toastService.success(msg);
this.clearOverride(update.cartItemId);
},
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.clearOverride(update.cartItemId);
},
}),
catchError(() => EMPTY),
),
),
),
),
takeUntilDestroyed(),
)
.subscribe();
}
protected getItemQuantity(item: CartItemMock): number {
if (item.productVariantId !== undefined && this.quantityOverrides()[item.productVariantId] !== undefined) {
return this.quantityOverrides()[item.productVariantId];
if (item.cartItemId !== undefined && this.quantityOverrides()[item.cartItemId] !== undefined) {
return this.quantityOverrides()[item.cartItemId];
}
return item.quantity;
}
private clearOverride(productVariantId: number): void {
private clearOverride(cartItemId: number): void {
this.quantityOverrides.update((overrides) => {
const copy = { ...overrides };
delete copy[productVariantId];
delete copy[cartItemId];
return copy;
});
}
protected onItemQuantityChange(index: number, newQuantity: number): void {
const mockItem = this.items()[index];
const productVariantId = mockItem?.productVariantId;
if (productVariantId) {
const cartItemId = mockItem?.cartItemId;
if (cartItemId) {
this.quantityOverrides.update((overrides) => ({
...overrides,
[productVariantId]: newQuantity
[cartItemId]: newQuantity,
}));
this.quantityUpdates$.next({
productVariantId,
quantity: newQuantity
cartItemId,
quantity: newQuantity,
});
} else {
const item = this.cartService.cart()?.items[index];
if (item) {
this.quantityOverrides.update((overrides) => ({
...overrides,
[item.product_variant_id]: newQuantity
[item.id]: newQuantity,
}));
this.quantityUpdates$.next({
productVariantId: item.product_variant_id,
quantity: newQuantity
cartItemId: item.id,
quantity: newQuantity,
});
}
}
@@ -120,16 +135,18 @@ export class CartComponent {
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);
}
});
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.cartItemId);
}
});
}
protected readonly formattedSubtotal = computed(() => this.formatCurrency(this.subtotal()));
@@ -143,16 +160,14 @@ export class CartComponent {
return `$ ${parts.join(',')}`;
}
private resolveRemoveTarget(
index: number
): { productVariantId: number; productName: string } | null {
private resolveRemoveTarget(index: number): { cartItemId: number; productName: string } | null {
const mockItem = this.items()[index];
const productVariantId = mockItem?.productVariantId;
const cartItemId = mockItem?.cartItemId;
if (productVariantId) {
if (cartItemId) {
return {
productVariantId,
productName: mockItem.product
cartItemId,
productName: mockItem.product,
};
}
@@ -163,13 +178,13 @@ export class CartComponent {
}
return {
productVariantId: item.product_variant_id,
productName: item.product?.nombre ?? 'este producto'
cartItemId: item.id,
productName: item.product?.nombre ?? 'este producto',
};
}
private removeItem(productVariantId: number): void {
this.cartService.removeItem(productVariantId).subscribe({
private removeItem(cartItemId: number): void {
this.cartService.removeItem(cartItemId).subscribe({
next: (res) => {
const msg = res.message || 'Producto eliminado del carrito.';
this.toastService.info(msg);
@@ -178,8 +193,7 @@ export class CartComponent {
console.error('Error removing item from cart', err);
const msg = err.error?.message || 'Error al eliminar el producto del carrito.';
this.toastService.danger(msg);
}
},
});
}
}