feat(cart): refactor cart item structure and update related components for consistency
This commit is contained in:
@@ -2,7 +2,11 @@ import { Component, computed, inject, OnInit, signal } from '@angular/core';
|
||||
import { Router, 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 {
|
||||
StoreFooterComponent,
|
||||
StoreFooterSection,
|
||||
StoreSocialLink,
|
||||
} from './store-footer/store-footer.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';
|
||||
@@ -11,9 +15,15 @@ import { AuthService } from '../../services/auth/auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-store-layout',
|
||||
imports: [RouterOutlet, StoreHeaderComponent, StoreFooterComponent, CartComponent, ButtonComponent],
|
||||
imports: [
|
||||
RouterOutlet,
|
||||
StoreHeaderComponent,
|
||||
StoreFooterComponent,
|
||||
CartComponent,
|
||||
ButtonComponent,
|
||||
],
|
||||
templateUrl: './store-layout.component.html',
|
||||
styleUrl: './store-layout.component.scss'
|
||||
styleUrl: './store-layout.component.scss',
|
||||
})
|
||||
export class StoreLayoutComponent implements OnInit {
|
||||
private readonly tenantService = inject(TenantService);
|
||||
@@ -57,14 +67,14 @@ export class StoreLayoutComponent implements OnInit {
|
||||
}
|
||||
|
||||
return {
|
||||
productVariantId: item.product_variant_id,
|
||||
cartItemId: item.id,
|
||||
imageUrl: item.product?.imagen ?? null,
|
||||
product,
|
||||
originalPrice: null,
|
||||
discountedPrice: parseFloat(item.precio_unitario),
|
||||
discountPercentage: null,
|
||||
attributes,
|
||||
quantity: item.cantidad
|
||||
quantity: item.cantidad,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -81,7 +91,7 @@ export class StoreLayoutComponent implements OnInit {
|
||||
|
||||
ngOnInit(): void {
|
||||
this.cartService.loadCart().subscribe({
|
||||
error: (err) => console.error('Error loading cart', err)
|
||||
error: (err) => console.error('Error loading cart', err),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -92,7 +102,7 @@ export class StoreLayoutComponent implements OnInit {
|
||||
protected onLogoutClick(): void {
|
||||
this.authService.logout().subscribe({
|
||||
next: () => void this.router.navigate(['/login']),
|
||||
error: (err) => console.error('Error logging out', err)
|
||||
error: (err) => console.error('Error logging out', err),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -101,34 +111,33 @@ export class StoreLayoutComponent implements OnInit {
|
||||
void this.router.navigate(['/checkout']);
|
||||
}
|
||||
|
||||
|
||||
protected readonly footerSections: StoreFooterSection[] = [
|
||||
{
|
||||
heading: 'Cuenta',
|
||||
links: ['Mi cuenta', 'Mis compras', 'Cerrar sesion']
|
||||
links: ['Mi cuenta', 'Mis compras', 'Cerrar sesion'],
|
||||
},
|
||||
{
|
||||
heading: 'Ayuda',
|
||||
links: ['Contacto', 'Ayuda', 'Preguntas frecuentes']
|
||||
}
|
||||
links: ['Contacto', 'Ayuda', 'Preguntas frecuentes'],
|
||||
},
|
||||
];
|
||||
|
||||
protected readonly socialLinks: StoreSocialLink[] = [
|
||||
{
|
||||
label: 'Instagram',
|
||||
iconClass: 'fa-brands fa-instagram'
|
||||
iconClass: 'fa-brands fa-instagram',
|
||||
},
|
||||
{
|
||||
label: 'WhatsApp',
|
||||
iconClass: 'fa-brands fa-whatsapp'
|
||||
iconClass: 'fa-brands fa-whatsapp',
|
||||
},
|
||||
{
|
||||
label: 'Facebook',
|
||||
iconClass: 'fa-brands fa-facebook'
|
||||
iconClass: 'fa-brands fa-facebook',
|
||||
},
|
||||
{
|
||||
label: 'LinkedIn',
|
||||
iconClass: 'fa-brands fa-linkedin-in'
|
||||
}
|
||||
iconClass: 'fa-brands fa-linkedin-in',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@ export interface CartItemProduct {
|
||||
imagen: string | null;
|
||||
}
|
||||
|
||||
export type BuyableType = 'variant' | 'bundle';
|
||||
|
||||
export interface CartItem {
|
||||
id: number;
|
||||
cantidad: number;
|
||||
precio_unitario: string;
|
||||
product_id: number;
|
||||
product_variant_id: number;
|
||||
buyable_type: BuyableType;
|
||||
buyable_id: number;
|
||||
product: CartItemProduct | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,21 +21,21 @@ describe('CartService', () => {
|
||||
id: 1,
|
||||
cantidad: 2,
|
||||
precio_unitario: '10.00',
|
||||
product_id: 5,
|
||||
product_variant_id: 10,
|
||||
buyable_type: 'variant',
|
||||
buyable_id: 10,
|
||||
product: {
|
||||
nombre: 'Test Product (Size: M)',
|
||||
imagen: null
|
||||
}
|
||||
}
|
||||
imagen: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
subtotal: '20.00'
|
||||
subtotal: '20.00',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
tenantServiceMock = {
|
||||
getTenantApiUrl: vi.fn().mockReturnValue('http://api.test/tenants/acme'),
|
||||
tenant: vi.fn().mockReturnValue({ codigo: 'acme' })
|
||||
tenant: vi.fn().mockReturnValue({ codigo: 'acme' }),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
@@ -43,8 +43,8 @@ describe('CartService', () => {
|
||||
CartService,
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: TenantService, useValue: tenantServiceMock }
|
||||
]
|
||||
{ provide: TenantService, useValue: tenantServiceMock },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(CartService);
|
||||
@@ -83,19 +83,23 @@ describe('CartService', () => {
|
||||
tenant_codigo: 'acme',
|
||||
status: 'active',
|
||||
items: [],
|
||||
subtotal: '0.00'
|
||||
subtotal: '0.00',
|
||||
});
|
||||
});
|
||||
|
||||
it('should add item and update signal', () => {
|
||||
service.addItem(10, 2).subscribe((res) => {
|
||||
service.addItem('variant', 10, 2).subscribe((res) => {
|
||||
expect(res.data).toEqual(mockCart);
|
||||
expect(service.cart()).toEqual(mockCart);
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('http://api.test/tenants/acme/cart/items');
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.body).toEqual({ product_variant_id: 10, cantidad: 2 });
|
||||
expect(req.request.body).toEqual({
|
||||
buyable_type: 'variant',
|
||||
buyable_id: 10,
|
||||
cantidad: 2,
|
||||
});
|
||||
expect(req.request.withCredentials).toBe(true);
|
||||
req.flush({ data: mockCart });
|
||||
});
|
||||
@@ -122,7 +126,7 @@ describe('CartService', () => {
|
||||
tenant_codigo: 'acme',
|
||||
status: 'active',
|
||||
items: [],
|
||||
subtotal: '0.00'
|
||||
subtotal: '0.00',
|
||||
};
|
||||
|
||||
service.removeItem(10).subscribe((res) => {
|
||||
|
||||
@@ -4,10 +4,10 @@ import { catchError, map, Observable, tap } from 'rxjs';
|
||||
|
||||
import { ApiResponse } from '../api-response.interface';
|
||||
import { TenantService } from '../tenant.service';
|
||||
import { Cart } from './cart.interface';
|
||||
import { BuyableType, Cart } from './cart.interface';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class CartService {
|
||||
private readonly http = inject(HttpClient);
|
||||
@@ -29,29 +29,31 @@ export class CartService {
|
||||
tenant_codigo: this.tenantService.tenant()?.codigo ?? '',
|
||||
status: 'active',
|
||||
items: [],
|
||||
subtotal: '0.00'
|
||||
subtotal: '0.00',
|
||||
});
|
||||
}
|
||||
|
||||
loadCart(): Observable<Cart> {
|
||||
return this.http
|
||||
.get<ApiResponse<Cart>>(`${this.tenantApiUrl}/cart`, {
|
||||
withCredentials: true
|
||||
withCredentials: true,
|
||||
})
|
||||
.pipe(
|
||||
map((response) => response.data),
|
||||
tap((cart) => this.cartState.set(cart))
|
||||
tap((cart) => this.cartState.set(cart)),
|
||||
);
|
||||
}
|
||||
|
||||
addItem(productVariantId: number, cantidad: number): Observable<ApiResponse<Cart>> {
|
||||
addItem(
|
||||
buyableType: BuyableType,
|
||||
buyableId: number,
|
||||
cantidad: number,
|
||||
): Observable<ApiResponse<Cart>> {
|
||||
this.isUpdatingState.set(true);
|
||||
return this.http
|
||||
.post<ApiResponse<Cart>>(
|
||||
`${this.tenantApiUrl}/cart/items`,
|
||||
{ product_variant_id: productVariantId, cantidad },
|
||||
{ withCredentials: true }
|
||||
)
|
||||
.post<
|
||||
ApiResponse<Cart>
|
||||
>(`${this.tenantApiUrl}/cart/items`, { buyable_type: buyableType, buyable_id: buyableId, cantidad }, { withCredentials: true })
|
||||
.pipe(
|
||||
tap((response) => {
|
||||
this.cartState.set(response.data);
|
||||
@@ -60,18 +62,16 @@ export class CartService {
|
||||
catchError((error) => {
|
||||
this.isUpdatingState.set(false);
|
||||
throw error;
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
updateItemQuantity(productVariantId: number, cantidad: number): Observable<ApiResponse<Cart>> {
|
||||
updateItemQuantity(cartItemId: number, cantidad: number): Observable<ApiResponse<Cart>> {
|
||||
this.isUpdatingState.set(true);
|
||||
return this.http
|
||||
.patch<ApiResponse<Cart>>(
|
||||
`${this.tenantApiUrl}/cart/items/${productVariantId}`,
|
||||
{ cantidad },
|
||||
{ withCredentials: true }
|
||||
)
|
||||
.patch<
|
||||
ApiResponse<Cart>
|
||||
>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, { cantidad }, { withCredentials: true })
|
||||
.pipe(
|
||||
tap((response) => {
|
||||
this.cartState.set(response.data);
|
||||
@@ -80,17 +80,16 @@ export class CartService {
|
||||
catchError((error) => {
|
||||
this.isUpdatingState.set(false);
|
||||
throw error;
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
removeItem(productVariantId: number): Observable<ApiResponse<Cart>> {
|
||||
removeItem(cartItemId: number): Observable<ApiResponse<Cart>> {
|
||||
this.isUpdatingState.set(true);
|
||||
return this.http
|
||||
.delete<ApiResponse<Cart>>(
|
||||
`${this.tenantApiUrl}/cart/items/${productVariantId}`,
|
||||
{ withCredentials: true }
|
||||
)
|
||||
.delete<
|
||||
ApiResponse<Cart>
|
||||
>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, { withCredentials: true })
|
||||
.pipe(
|
||||
tap((response) => {
|
||||
this.cartState.set(response.data);
|
||||
@@ -99,7 +98,7 @@ export class CartService {
|
||||
catchError((error) => {
|
||||
this.isUpdatingState.set(false);
|
||||
throw error;
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, effect, inject, OnInit, signal, untracked, ViewChild } from '@angular/core';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
untracked,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import { FormBuilder, Validators } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { startWith } from 'rxjs';
|
||||
@@ -14,7 +24,13 @@ import { StepComponent } from '../../../../shared/components/stepper/step.compon
|
||||
import { StepperComponent } from '../../../../shared/components/stepper/stepper.component';
|
||||
import { CheckoutDataStepComponent } from './checkout-data-step.component';
|
||||
import { CheckoutPaymentStepComponent } from './checkout-payment-step.component';
|
||||
import { CheckoutForm, PaymentMethod, PaymentMethodOption, TransferAccount, TransferField } from './checkout-page.models';
|
||||
import {
|
||||
CheckoutForm,
|
||||
PaymentMethod,
|
||||
PaymentMethodOption,
|
||||
TransferAccount,
|
||||
TransferField,
|
||||
} from './checkout-page.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-checkout-page',
|
||||
@@ -24,11 +40,11 @@ import { CheckoutForm, PaymentMethod, PaymentMethodOption, TransferAccount, Tran
|
||||
StepperComponent,
|
||||
StepComponent,
|
||||
CheckoutDataStepComponent,
|
||||
CheckoutPaymentStepComponent
|
||||
CheckoutPaymentStepComponent,
|
||||
],
|
||||
templateUrl: './checkout-page.component.html',
|
||||
styleUrl: './checkout-page.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class CheckoutPageComponent implements OnInit {
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
@@ -44,7 +60,7 @@ export class CheckoutPageComponent implements OnInit {
|
||||
nombre: ['', [Validators.required]],
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
dni: ['', [Validators.required]],
|
||||
telefono: ['', [Validators.required]]
|
||||
telefono: ['', [Validators.required]],
|
||||
});
|
||||
|
||||
protected readonly cartSubtotal = computed(() => {
|
||||
@@ -66,7 +82,7 @@ export class CheckoutPageComponent implements OnInit {
|
||||
protected readonly paymentMethods: ReadonlyArray<PaymentMethodOption> = [
|
||||
{ id: 'qr', label: 'QR' },
|
||||
{ id: 'transfer', label: 'Transferencia' },
|
||||
{ id: 'telepagos', label: 'TelePagos' }
|
||||
{ id: 'telepagos', label: 'TelePagos' },
|
||||
];
|
||||
protected readonly selectedPaymentMethod = signal<PaymentMethod>('qr');
|
||||
protected readonly copiedTransferField = signal<TransferField | null>(null);
|
||||
@@ -74,13 +90,15 @@ export class CheckoutPageComponent implements OnInit {
|
||||
titular: 'Nombre y Apellido',
|
||||
entidad: 'TelePagos',
|
||||
cvu: '0000000000000000000000',
|
||||
alias: 'telepagos.ar'
|
||||
alias: 'telepagos.ar',
|
||||
});
|
||||
|
||||
protected readonly isCreatingPurchase = signal(false);
|
||||
protected readonly createdPurchaseId = signal<number | null>(null);
|
||||
protected readonly isGeneratingIntent = signal(false);
|
||||
protected readonly isPaymentLoading = computed(() => this.isGeneratingIntent() || this.cartService.isUpdating());
|
||||
protected readonly isPaymentLoading = computed(
|
||||
() => this.isGeneratingIntent() || this.cartService.isUpdating(),
|
||||
);
|
||||
protected readonly qrData = signal<string | null>(null);
|
||||
|
||||
constructor() {
|
||||
@@ -88,7 +106,7 @@ export class CheckoutPageComponent implements OnInit {
|
||||
// We only want to trigger the intent generation when the cart changes.
|
||||
// So we track the cart, but untrack the other signals to prevent duplicate calls.
|
||||
const cart = this.cartService.cart();
|
||||
|
||||
|
||||
untracked(() => {
|
||||
const purchaseId = this.createdPurchaseId();
|
||||
if (cart && purchaseId) {
|
||||
@@ -108,7 +126,7 @@ export class CheckoutPageComponent implements OnInit {
|
||||
nombre: user.nombre_apellido,
|
||||
email: user.email,
|
||||
dni: user.dni ?? '',
|
||||
telefono: user.telefono ?? ''
|
||||
telefono: user.telefono ?? '',
|
||||
});
|
||||
this.form.controls.nombre.disable();
|
||||
this.form.controls.email.disable();
|
||||
@@ -138,14 +156,14 @@ export class CheckoutPageComponent implements OnInit {
|
||||
}
|
||||
|
||||
return {
|
||||
productVariantId: item.product_variant_id,
|
||||
cartItemId: item.id,
|
||||
imageUrl: item.product?.imagen ?? null,
|
||||
product,
|
||||
originalPrice: null,
|
||||
discountedPrice: parseFloat(item.precio_unitario),
|
||||
discountPercentage: null,
|
||||
attributes,
|
||||
quantity: item.cantidad
|
||||
quantity: item.cantidad,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -174,12 +192,11 @@ export class CheckoutPageComponent implements OnInit {
|
||||
|
||||
const response = await this.checkoutService.createPurchase(tenant.codigo, payload);
|
||||
this.createdPurchaseId.set(response.id);
|
||||
|
||||
|
||||
this.stepper.next();
|
||||
|
||||
|
||||
// Auto trigger intent for default option
|
||||
void this.selectPaymentMethod(this.selectedPaymentMethod());
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to create purchase:', error);
|
||||
// Here we could show an alert or toast
|
||||
@@ -205,8 +222,12 @@ export class CheckoutPageComponent implements OnInit {
|
||||
this.isGeneratingIntent.set(true);
|
||||
|
||||
try {
|
||||
const response = await this.checkoutService.generatePaymentIntent(tenant.codigo, purchaseId, method);
|
||||
|
||||
const response = await this.checkoutService.generatePaymentIntent(
|
||||
tenant.codigo,
|
||||
purchaseId,
|
||||
method,
|
||||
);
|
||||
|
||||
if (method === 'qr' && response.qr_data?.qr_code) {
|
||||
this.qrData.set(response.qr_data.qr_code);
|
||||
} else if (method === 'transfer' && response.transfer_data) {
|
||||
@@ -214,7 +235,7 @@ export class CheckoutPageComponent implements OnInit {
|
||||
titular: response.transfer_data.titular,
|
||||
entidad: response.transfer_data.entidad,
|
||||
cvu: response.transfer_data.cvu,
|
||||
alias: response.transfer_data.alias
|
||||
alias: response.transfer_data.alias,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -427,7 +427,7 @@ describe('ProductDetailPageComponent', () => {
|
||||
addToCartButton.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(cartServiceStub.addItem).toHaveBeenCalledWith(123, 3);
|
||||
expect(cartServiceStub.addItem).toHaveBeenCalledWith('variant', 123, 3);
|
||||
expect(toastServiceStub.success).toHaveBeenCalledWith('Producto agregado al carrito');
|
||||
});
|
||||
|
||||
|
||||
@@ -229,7 +229,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
this.addingToCart.set(true);
|
||||
this.cartService.addItem(variant.variant_id, this.quantity()).subscribe({
|
||||
this.cartService.addItem('variant', variant.variant_id, this.quantity()).subscribe({
|
||||
next: (res) => {
|
||||
const msg = res.message || 'Producto agregado al carrito';
|
||||
this.toastService.success(msg);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user