feat: enhance cart functionality with quantity update and removal features, integrating toast notifications for user feedback
This commit is contained in:
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(',')}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user