feat: enhance cart functionality with quantity update and removal features, integrating toast notifications for user feedback

This commit is contained in:
2026-07-02 10:38:26 -03:00
parent f2cc42e0cc
commit c9d8265790
5 changed files with 91 additions and 13 deletions

View File

@@ -7,6 +7,7 @@ import { of } from 'rxjs';
import { Tenant } from '../../services/tenant.interface'; import { Tenant } from '../../services/tenant.interface';
import { TenantService } from '../../services/tenant.service'; import { TenantService } from '../../services/tenant.service';
import { CartService } from '../../services/cart/cart.service'; import { CartService } from '../../services/cart/cart.service';
import { ToastService } from '../../services/toast.service';
import { StoreLayoutComponent } from './store-layout.component'; import { StoreLayoutComponent } from './store-layout.component';
const tenant: Tenant = { const tenant: Tenant = {
@@ -45,7 +46,19 @@ describe('StoreLayoutComponent', () => {
provide: CartService, provide: CartService,
useValue: { useValue: {
cart: signal(null).asReadonly(), cart: signal(null).asReadonly(),
loadCart: () => of({ id: 1, items: [], subtotal: '0' }) 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()
} }
} }
] ]

View File

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

View File

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

View File

@@ -10,7 +10,7 @@
</header> </header>
<div class="d-grid gap-2 flex-grow-1 overflow-y-auto cart-items-container"> <div class="d-grid gap-2 flex-grow-1 overflow-y-auto cart-items-container">
@for (item of items(); track item.product + item.discountedPrice) { @for (item of items(); track item.product + item.discountedPrice + resetKey(); let idx = $index) {
<app-cart-item <app-cart-item
[imageUrl]="item.imageUrl" [imageUrl]="item.imageUrl"
[product]="item.product" [product]="item.product"
@@ -19,6 +19,8 @@
[discountPercentage]="item.discountPercentage" [discountPercentage]="item.discountPercentage"
[attributes]="item.attributes" [attributes]="item.attributes"
[quantity]="item.quantity" [quantity]="item.quantity"
(quantityChange)="onItemQuantityChange(idx, $event)"
(remove)="onItemRemove(idx)"
/> />
} }
</div> </div>

View File

@@ -1,5 +1,11 @@
import { ChangeDetectionStrategy, Component, computed, input, output } 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';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CartItemAttribute, CartItemComponent } from '../cart-item/cart-item.component'; import { CartItemAttribute, CartItemComponent } from '../cart-item/cart-item.component';
import { CartService } from '../../../core/services/cart/cart.service';
import { ToastService } from '../../../core/services/toast.service';
export interface CartItemMock { export interface CartItemMock {
imageUrl: string | null; imageUrl: string | null;
@@ -20,6 +26,10 @@ export interface CartItemMock {
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class CartComponent { export class CartComponent {
private readonly cartService = inject(CartService);
private readonly toastService = inject(ToastService);
private readonly quantityUpdates$ = new Subject<{ productVariantId: number; quantity: number }>();
readonly title = input<string>('CARRITO'); readonly title = input<string>('CARRITO');
readonly showClose = input<boolean>(false); readonly showClose = input<boolean>(false);
readonly items = input<CartItemMock[]>([]); readonly items = input<CartItemMock[]>([]);
@@ -30,6 +40,60 @@ export class CartComponent {
readonly closed = output<void>(); 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 item = this.cartService.cart()?.items[index];
if (item) {
this.quantityUpdates$.next({
productVariantId: item.product_variant_id,
quantity: newQuantity
});
}
}
protected onItemRemove(index: number): void {
const item = this.cartService.cart()?.items[index];
if (item) {
this.cartService.removeItem(item.product_variant_id).subscribe({
next: (res) => {
const msg = res.message || 'Producto eliminado del carrito.';
this.toastService.success(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);
}
});
}
}
protected readonly formattedSubtotal = computed(() => this.formatCurrency(this.subtotal())); protected readonly formattedSubtotal = computed(() => this.formatCurrency(this.subtotal()));
protected readonly formattedDiscount = computed(() => this.formatCurrency(this.discount())); protected readonly formattedDiscount = computed(() => this.formatCurrency(this.discount()));
protected readonly formattedTotal = computed(() => this.formatCurrency(this.total())); protected readonly formattedTotal = computed(() => this.formatCurrency(this.total()));
@@ -41,3 +105,4 @@ export class CartComponent {
return `$ ${parts.join(',')}`; return `$ ${parts.join(',')}`;
} }
} }