feat: add product detail page with variant selection and cart integration
This commit is contained in:
@@ -81,9 +81,10 @@
|
|||||||
class="product-detail__cta"
|
class="product-detail__cta"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="!selectedVariant() || variantLoading()"
|
[disabled]="!selectedVariant() || variantLoading() || addingToCart()"
|
||||||
|
(click)="addToCart()"
|
||||||
>
|
>
|
||||||
@if (variantLoading()) {
|
@if (variantLoading() || addingToCart()) {
|
||||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||||
} @else {
|
} @else {
|
||||||
Agregar al carrito
|
Agregar al carrito
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
|
|
||||||
import { ProductDetail } from '../../../../core/services/catalog/catalog.interface';
|
import { ProductDetail } from '../../../../core/services/catalog/catalog.interface';
|
||||||
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
|
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
|
||||||
|
import { CartService } from '../../../../core/services/cart/cart.service';
|
||||||
|
import { ToastService } from '../../../../core/services/toast.service';
|
||||||
import { ProductDetailPageComponent } from './product-detail-page.component';
|
import { ProductDetailPageComponent } from './product-detail-page.component';
|
||||||
|
|
||||||
describe('ProductDetailPageComponent', () => {
|
describe('ProductDetailPageComponent', () => {
|
||||||
@@ -33,6 +35,8 @@ describe('ProductDetailPageComponent', () => {
|
|||||||
let paramMapSubject: BehaviorSubject<any>;
|
let paramMapSubject: BehaviorSubject<any>;
|
||||||
let catalogServiceStub: any;
|
let catalogServiceStub: any;
|
||||||
let routerStub: any;
|
let routerStub: any;
|
||||||
|
let cartServiceStub: any;
|
||||||
|
let toastServiceStub: any;
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
try {
|
try {
|
||||||
@@ -54,6 +58,14 @@ describe('ProductDetailPageComponent', () => {
|
|||||||
routerStub = {
|
routerStub = {
|
||||||
navigate: vi.fn()
|
navigate: vi.fn()
|
||||||
};
|
};
|
||||||
|
cartServiceStub = {
|
||||||
|
addItem: vi.fn().mockReturnValue(of({}))
|
||||||
|
};
|
||||||
|
toastServiceStub = {
|
||||||
|
success: vi.fn(),
|
||||||
|
danger: vi.fn(),
|
||||||
|
info: vi.fn()
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
async function configureTestingModule() {
|
async function configureTestingModule() {
|
||||||
@@ -73,6 +85,14 @@ describe('ProductDetailPageComponent', () => {
|
|||||||
{
|
{
|
||||||
provide: Router,
|
provide: Router,
|
||||||
useValue: routerStub
|
useValue: routerStub
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: CartService,
|
||||||
|
useValue: cartServiceStub
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ToastService,
|
||||||
|
useValue: toastServiceStub
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
@@ -329,4 +349,84 @@ describe('ProductDetailPageComponent', () => {
|
|||||||
|
|
||||||
expect(routerStub.navigate).not.toHaveBeenCalled();
|
expect(routerStub.navigate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('calls CartService.addItem when variant is selected and Add to Cart is clicked', async () => {
|
||||||
|
const detailProduct: ProductDetail = {
|
||||||
|
...mockProduct,
|
||||||
|
variant: {
|
||||||
|
id: 123,
|
||||||
|
stock: 5,
|
||||||
|
images: [],
|
||||||
|
definitions: {}
|
||||||
|
},
|
||||||
|
variants_map: [
|
||||||
|
{
|
||||||
|
variant_id: 123,
|
||||||
|
stock: 5,
|
||||||
|
attributes: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
catalogServiceStub.getProducto.mockReturnValue(of(detailProduct));
|
||||||
|
|
||||||
|
await configureTestingModule();
|
||||||
|
const fixture = TestBed.createComponent(ProductDetailPageComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
// Select the variant
|
||||||
|
fixture.componentInstance['selectedVariant'].set(detailProduct.variants_map[0]);
|
||||||
|
fixture.componentInstance['quantity'].set(3);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
const addToCartButton = Array.from(element.querySelectorAll('app-button button')).find(
|
||||||
|
(btn) => btn.textContent?.trim() === 'Agregar al carrito'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
|
||||||
|
expect(addToCartButton).toBeDefined();
|
||||||
|
addToCartButton.click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(cartServiceStub.addItem).toHaveBeenCalledWith(123, 3);
|
||||||
|
expect(toastServiceStub.success).toHaveBeenCalledWith('Producto agregado al carrito');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error toast when CartService.addItem fails', async () => {
|
||||||
|
const detailProduct: ProductDetail = {
|
||||||
|
...mockProduct,
|
||||||
|
variant: {
|
||||||
|
id: 123,
|
||||||
|
stock: 5,
|
||||||
|
images: [],
|
||||||
|
definitions: {}
|
||||||
|
},
|
||||||
|
variants_map: [
|
||||||
|
{
|
||||||
|
variant_id: 123,
|
||||||
|
stock: 5,
|
||||||
|
attributes: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
catalogServiceStub.getProducto.mockReturnValue(of(detailProduct));
|
||||||
|
cartServiceStub.addItem.mockReturnValue(throwError(() => new Error('Failed to add')));
|
||||||
|
|
||||||
|
await configureTestingModule();
|
||||||
|
const fixture = TestBed.createComponent(ProductDetailPageComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
fixture.componentInstance['selectedVariant'].set(detailProduct.variants_map[0]);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
const addToCartButton = Array.from(element.querySelectorAll('app-button button')).find(
|
||||||
|
(btn) => btn.textContent?.trim() === 'Agregar al carrito'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
|
||||||
|
addToCartButton.click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(cartServiceStub.addItem).toHaveBeenCalled();
|
||||||
|
expect(toastServiceStub.danger).toHaveBeenCalledWith('No se pudo agregar el producto al carrito.');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { Subscription } from 'rxjs';
|
|||||||
|
|
||||||
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
|
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
|
||||||
import { ToastService } from '../../../../core/services/toast.service';
|
import { ToastService } from '../../../../core/services/toast.service';
|
||||||
|
import { CartService } from '../../../../core/services/cart/cart.service';
|
||||||
import {
|
import {
|
||||||
ProductAttribute,
|
ProductAttribute,
|
||||||
ProductAttributeOption,
|
ProductAttributeOption,
|
||||||
@@ -42,6 +43,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
|
|||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly catalogService = inject(CatalogService);
|
private readonly catalogService = inject(CatalogService);
|
||||||
private readonly toastService = inject(ToastService);
|
private readonly toastService = inject(ToastService);
|
||||||
|
private readonly cartService = inject(CartService);
|
||||||
private readonly attributeSelector = viewChild(ProductAttributeSelectorComponent);
|
private readonly attributeSelector = viewChild(ProductAttributeSelectorComponent);
|
||||||
private readonly carouselHost = viewChild<ElementRef<HTMLElement>>('carouselHost');
|
private readonly carouselHost = viewChild<ElementRef<HTMLElement>>('carouselHost');
|
||||||
private readonly descriptionBody = viewChild<ElementRef<HTMLElement>>('descriptionBody');
|
private readonly descriptionBody = viewChild<ElementRef<HTMLElement>>('descriptionBody');
|
||||||
@@ -68,6 +70,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
protected readonly loading = signal(false);
|
protected readonly loading = signal(false);
|
||||||
protected readonly variantLoading = signal(false);
|
protected readonly variantLoading = signal(false);
|
||||||
|
protected readonly addingToCart = signal(false);
|
||||||
protected readonly error = signal<string | null>(null);
|
protected readonly error = signal<string | null>(null);
|
||||||
protected readonly selectedVariant = signal<ProductVariantMap | null>(null);
|
protected readonly selectedVariant = signal<ProductVariantMap | null>(null);
|
||||||
protected readonly quantity = signal(1);
|
protected readonly quantity = signal(1);
|
||||||
@@ -213,6 +216,27 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
|
|||||||
this.quantity.update((current) => Math.max(1, current - 1));
|
this.quantity.update((current) => Math.max(1, current - 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected addToCart(): void {
|
||||||
|
const variant = this.selectedVariant();
|
||||||
|
if (!variant) {
|
||||||
|
this.toastService.danger('Por favor, selecciona una variante.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.addingToCart.set(true);
|
||||||
|
this.cartService.addItem(variant.variant_id, this.quantity()).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.toastService.success('Producto agregado al carrito');
|
||||||
|
this.addingToCart.set(false);
|
||||||
|
},
|
||||||
|
error: (err: HttpErrorResponse) => {
|
||||||
|
const errorMessage = err.error?.message || 'No se pudo agregar el producto al carrito.';
|
||||||
|
this.toastService.danger(errorMessage);
|
||||||
|
this.addingToCart.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected toggleDescription(): void {
|
protected toggleDescription(): void {
|
||||||
this.descriptionExpanded.update((current) => !current);
|
this.descriptionExpanded.update((current) => !current);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user