Compare commits

...

2 Commits

17 changed files with 665 additions and 361 deletions

View File

@@ -2,7 +2,11 @@ import { Component, computed, inject, OnInit, signal } from '@angular/core';
import { Router, RouterOutlet } from '@angular/router'; import { Router, RouterOutlet } from '@angular/router';
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 { 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 { StoreHeaderComponent } from './store-header/store-header.component';
import { CartComponent, CartItemMock } from '../../../shared/components/cart/cart.component'; import { CartComponent, CartItemMock } from '../../../shared/components/cart/cart.component';
import { ButtonComponent } from '../../../shared/components/button/button.component'; import { ButtonComponent } from '../../../shared/components/button/button.component';
@@ -11,9 +15,15 @@ import { AuthService } from '../../services/auth/auth.service';
@Component({ @Component({
selector: 'app-store-layout', selector: 'app-store-layout',
imports: [RouterOutlet, StoreHeaderComponent, StoreFooterComponent, CartComponent, ButtonComponent], imports: [
RouterOutlet,
StoreHeaderComponent,
StoreFooterComponent,
CartComponent,
ButtonComponent,
],
templateUrl: './store-layout.component.html', templateUrl: './store-layout.component.html',
styleUrl: './store-layout.component.scss' styleUrl: './store-layout.component.scss',
}) })
export class StoreLayoutComponent implements OnInit { export class StoreLayoutComponent implements OnInit {
private readonly tenantService = inject(TenantService); private readonly tenantService = inject(TenantService);
@@ -57,14 +67,14 @@ export class StoreLayoutComponent implements OnInit {
} }
return { return {
productVariantId: item.product_variant_id, cartItemId: item.id,
imageUrl: item.product?.imagen ?? null, imageUrl: item.product?.imagen ?? null,
product, product,
originalPrice: null, originalPrice: null,
discountedPrice: parseFloat(item.precio_unitario), discountedPrice: parseFloat(item.precio_unitario),
discountPercentage: null, discountPercentage: null,
attributes, attributes,
quantity: item.cantidad quantity: item.cantidad,
}; };
} }
@@ -81,7 +91,7 @@ export class StoreLayoutComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.cartService.loadCart().subscribe({ 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 { protected onLogoutClick(): void {
this.authService.logout().subscribe({ this.authService.logout().subscribe({
next: () => void this.router.navigate(['/login']), 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']); void this.router.navigate(['/checkout']);
} }
protected readonly footerSections: StoreFooterSection[] = [ protected readonly footerSections: StoreFooterSection[] = [
{ {
heading: 'Cuenta', heading: 'Cuenta',
links: ['Mi cuenta', 'Mis compras', 'Cerrar sesion'] links: ['Mi cuenta', 'Mis compras', 'Cerrar sesion'],
}, },
{ {
heading: 'Ayuda', heading: 'Ayuda',
links: ['Contacto', 'Ayuda', 'Preguntas frecuentes'] links: ['Contacto', 'Ayuda', 'Preguntas frecuentes'],
} },
]; ];
protected readonly socialLinks: StoreSocialLink[] = [ protected readonly socialLinks: StoreSocialLink[] = [
{ {
label: 'Instagram', label: 'Instagram',
iconClass: 'fa-brands fa-instagram' iconClass: 'fa-brands fa-instagram',
}, },
{ {
label: 'WhatsApp', label: 'WhatsApp',
iconClass: 'fa-brands fa-whatsapp' iconClass: 'fa-brands fa-whatsapp',
}, },
{ {
label: 'Facebook', label: 'Facebook',
iconClass: 'fa-brands fa-facebook' iconClass: 'fa-brands fa-facebook',
}, },
{ {
label: 'LinkedIn', label: 'LinkedIn',
iconClass: 'fa-brands fa-linkedin-in' iconClass: 'fa-brands fa-linkedin-in',
} },
]; ];
} }

View File

@@ -3,12 +3,14 @@ export interface CartItemProduct {
imagen: string | null; imagen: string | null;
} }
export type BuyableType = 'variant' | 'bundle';
export interface CartItem { export interface CartItem {
id: number; id: number;
cantidad: number; cantidad: number;
precio_unitario: string; precio_unitario: string;
product_id: number; buyable_type: BuyableType;
product_variant_id: number; buyable_id: number;
product: CartItemProduct | null; product: CartItemProduct | null;
} }

View File

@@ -21,21 +21,21 @@ describe('CartService', () => {
id: 1, id: 1,
cantidad: 2, cantidad: 2,
precio_unitario: '10.00', precio_unitario: '10.00',
product_id: 5, buyable_type: 'variant',
product_variant_id: 10, buyable_id: 10,
product: { product: {
nombre: 'Test Product (Size: M)', nombre: 'Test Product (Size: M)',
imagen: null imagen: null,
} },
} },
], ],
subtotal: '20.00' subtotal: '20.00',
}; };
beforeEach(() => { beforeEach(() => {
tenantServiceMock = { tenantServiceMock = {
getTenantApiUrl: vi.fn().mockReturnValue('http://api.test/tenants/acme'), getTenantApiUrl: vi.fn().mockReturnValue('http://api.test/tenants/acme'),
tenant: vi.fn().mockReturnValue({ codigo: 'acme' }) tenant: vi.fn().mockReturnValue({ codigo: 'acme' }),
}; };
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -43,8 +43,8 @@ describe('CartService', () => {
CartService, CartService,
provideHttpClient(), provideHttpClient(),
provideHttpClientTesting(), provideHttpClientTesting(),
{ provide: TenantService, useValue: tenantServiceMock } { provide: TenantService, useValue: tenantServiceMock },
] ],
}); });
service = TestBed.inject(CartService); service = TestBed.inject(CartService);
@@ -83,19 +83,23 @@ describe('CartService', () => {
tenant_codigo: 'acme', tenant_codigo: 'acme',
status: 'active', status: 'active',
items: [], items: [],
subtotal: '0.00' subtotal: '0.00',
}); });
}); });
it('should add item and update signal', () => { 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(res.data).toEqual(mockCart);
expect(service.cart()).toEqual(mockCart); expect(service.cart()).toEqual(mockCart);
}); });
const req = httpMock.expectOne('http://api.test/tenants/acme/cart/items'); const req = httpMock.expectOne('http://api.test/tenants/acme/cart/items');
expect(req.request.method).toBe('POST'); 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); expect(req.request.withCredentials).toBe(true);
req.flush({ data: mockCart }); req.flush({ data: mockCart });
}); });
@@ -122,7 +126,7 @@ describe('CartService', () => {
tenant_codigo: 'acme', tenant_codigo: 'acme',
status: 'active', status: 'active',
items: [], items: [],
subtotal: '0.00' subtotal: '0.00',
}; };
service.removeItem(10).subscribe((res) => { service.removeItem(10).subscribe((res) => {

View File

@@ -4,10 +4,10 @@ import { catchError, map, Observable, tap } from 'rxjs';
import { ApiResponse } from '../api-response.interface'; import { ApiResponse } from '../api-response.interface';
import { TenantService } from '../tenant.service'; import { TenantService } from '../tenant.service';
import { Cart } from './cart.interface'; import { BuyableType, Cart } from './cart.interface';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root',
}) })
export class CartService { export class CartService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
@@ -29,29 +29,31 @@ export class CartService {
tenant_codigo: this.tenantService.tenant()?.codigo ?? '', tenant_codigo: this.tenantService.tenant()?.codigo ?? '',
status: 'active', status: 'active',
items: [], items: [],
subtotal: '0.00' subtotal: '0.00',
}); });
} }
loadCart(): Observable<Cart> { loadCart(): Observable<Cart> {
return this.http return this.http
.get<ApiResponse<Cart>>(`${this.tenantApiUrl}/cart`, { .get<ApiResponse<Cart>>(`${this.tenantApiUrl}/cart`, {
withCredentials: true withCredentials: true,
}) })
.pipe( .pipe(
map((response) => response.data), 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); this.isUpdatingState.set(true);
return this.http return this.http
.post<ApiResponse<Cart>>( .post<
`${this.tenantApiUrl}/cart/items`, ApiResponse<Cart>
{ product_variant_id: productVariantId, cantidad }, >(`${this.tenantApiUrl}/cart/items`, { buyable_type: buyableType, buyable_id: buyableId, cantidad }, { withCredentials: true })
{ withCredentials: true }
)
.pipe( .pipe(
tap((response) => { tap((response) => {
this.cartState.set(response.data); this.cartState.set(response.data);
@@ -60,18 +62,16 @@ export class CartService {
catchError((error) => { catchError((error) => {
this.isUpdatingState.set(false); this.isUpdatingState.set(false);
throw error; throw error;
}) }),
); );
} }
updateItemQuantity(productVariantId: number, cantidad: number): Observable<ApiResponse<Cart>> { updateItemQuantity(cartItemId: number, cantidad: number): Observable<ApiResponse<Cart>> {
this.isUpdatingState.set(true); this.isUpdatingState.set(true);
return this.http return this.http
.patch<ApiResponse<Cart>>( .patch<
`${this.tenantApiUrl}/cart/items/${productVariantId}`, ApiResponse<Cart>
{ cantidad }, >(`${this.tenantApiUrl}/cart/items/${cartItemId}`, { cantidad }, { withCredentials: true })
{ withCredentials: true }
)
.pipe( .pipe(
tap((response) => { tap((response) => {
this.cartState.set(response.data); this.cartState.set(response.data);
@@ -80,17 +80,16 @@ export class CartService {
catchError((error) => { catchError((error) => {
this.isUpdatingState.set(false); this.isUpdatingState.set(false);
throw error; throw error;
}) }),
); );
} }
removeItem(productVariantId: number): Observable<ApiResponse<Cart>> { removeItem(cartItemId: number): Observable<ApiResponse<Cart>> {
this.isUpdatingState.set(true); this.isUpdatingState.set(true);
return this.http return this.http
.delete<ApiResponse<Cart>>( .delete<
`${this.tenantApiUrl}/cart/items/${productVariantId}`, ApiResponse<Cart>
{ withCredentials: true } >(`${this.tenantApiUrl}/cart/items/${cartItemId}`, { withCredentials: true })
)
.pipe( .pipe(
tap((response) => { tap((response) => {
this.cartState.set(response.data); this.cartState.set(response.data);
@@ -99,7 +98,7 @@ export class CartService {
catchError((error) => { catchError((error) => {
this.isUpdatingState.set(false); this.isUpdatingState.set(false);
throw error; throw error;
}) }),
); );
} }
} }

View File

@@ -29,16 +29,22 @@ export interface ProductAttribute {
options: ProductAttributeOption[]; options: ProductAttributeOption[];
} }
export type InventoryPolicy = 'tracked' | 'unlimited';
export interface ProductVariant { export interface ProductVariant {
id: number; id: number;
cantidad_maxima: number; inventory_policy: InventoryPolicy;
cantidad_maxima: number | null;
cantidad_vendida: number;
definitions: Record<string, string>; definitions: Record<string, string>;
images: string[]; images: string[];
} }
export interface ProductVariantMap { export interface ProductVariantMap {
variant_id: number; variant_id: number;
cantidad_maxima: number; inventory_policy: InventoryPolicy;
cantidad_maxima: number | null;
cantidad_vendida: number;
attributes: Record<string, string>; attributes: Record<string, string>;
} }
@@ -47,4 +53,3 @@ export interface ProductDetail extends Product {
variants_map: ProductVariantMap[]; variants_map: ProductVariantMap[];
variant: ProductVariant | null; variant: ProductVariant | null;
} }

View File

@@ -0,0 +1,75 @@
import { TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import { ProductAttribute } from '../../../../core/services/catalog/catalog.interface';
import { ProductAttributeSelectorComponent } from './product-attribute-selector.component';
describe('ProductAttributeSelectorComponent', () => {
const sizeAttribute: ProductAttribute = {
id: 1,
codigo: 'size',
nombre: 'Size',
is_required: true,
metadata_schema: null,
type: 'select',
options: [
{ id: 10, value: 'S', label: 'S', sort_order: 0, metadata: null },
{ id: 20, value: 'M', label: 'M', sort_order: 1, metadata: null },
],
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProductAttributeSelectorComponent],
}).compileComponents();
});
it('keeps an unlimited option available when maximum quantity is null', () => {
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
fixture.componentRef.setInput('attributes', [sizeAttribute]);
fixture.componentRef.setInput('variantsMap', [
{
variant_id: 1,
inventory_policy: 'unlimited',
cantidad_maxima: null,
cantidad_vendida: 0,
attributes: { size: 'S' },
},
]);
fixture.detectChanges();
const buttons = Array.from(
fixture.nativeElement.querySelectorAll('.attribute-selector__text-option'),
) as HTMLButtonElement[];
expect(buttons[0].disabled).toBe(false);
expect(buttons[1].disabled).toBe(true);
});
it('disables tracked options without available stock', () => {
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
fixture.componentRef.setInput('attributes', [sizeAttribute]);
fixture.componentRef.setInput('variantsMap', [
{
variant_id: 1,
inventory_policy: 'tracked',
cantidad_maxima: 0,
cantidad_vendida: 0,
attributes: { size: 'S' },
},
{
variant_id: 2,
inventory_policy: 'tracked',
cantidad_maxima: 2,
cantidad_vendida: 0,
attributes: { size: 'M' },
},
]);
fixture.detectChanges();
const buttons = Array.from(
fixture.nativeElement.querySelectorAll('.attribute-selector__text-option'),
) as HTMLButtonElement[];
expect(buttons[0].disabled).toBe(true);
expect(buttons[1].disabled).toBe(false);
});
});

View File

@@ -6,14 +6,14 @@ import {
input, input,
output, output,
signal, signal,
untracked untracked,
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { import {
ProductAttribute, ProductAttribute,
ProductAttributeOption, ProductAttributeOption,
ProductVariant, ProductVariant,
ProductVariantMap ProductVariantMap,
} from '../../../../core/services/catalog/catalog.interface'; } from '../../../../core/services/catalog/catalog.interface';
@Component({ @Component({
@@ -22,7 +22,7 @@ import {
imports: [CommonModule], imports: [CommonModule],
templateUrl: './product-attribute-selector.component.html', templateUrl: './product-attribute-selector.component.html',
styleUrl: './product-attribute-selector.component.scss', styleUrl: './product-attribute-selector.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class ProductAttributeSelectorComponent { export class ProductAttributeSelectorComponent {
public attributes = input<ProductAttribute[]>([]); public attributes = input<ProductAttribute[]>([]);
@@ -44,28 +44,36 @@ export class ProductAttributeSelectorComponent {
availability[attribute.id] = {}; availability[attribute.id] = {};
for (const option of attribute.options) { for (const option of attribute.options) {
const optionNormalized = this.normalizeText(option.value || option.label); const optionNormalized = this.normalizeText(option.value || option.label);
const isAvailable = variants.some(variant => {
if (variant.cantidad_maxima <= 0) return false;
const variantAttrValue = this.getDefaultVariantAttributeValue(attribute, variant.attributes); const isAvailable = variants.some((variant) => {
if (!this.isVariantAvailable(variant)) return false;
const variantAttrValue = this.getDefaultVariantAttributeValue(
attribute,
variant.attributes,
);
if (!variantAttrValue || this.normalizeText(variantAttrValue) !== optionNormalized) { if (!variantAttrValue || this.normalizeText(variantAttrValue) !== optionNormalized) {
return false; return false;
} }
for (const otherAttr of attributes) { for (const otherAttr of attributes) {
if (otherAttr.id === attribute.id) continue; if (otherAttr.id === attribute.id) continue;
const selectedOptionId = selections[otherAttr.id]; const selectedOptionId = selections[otherAttr.id];
if (selectedOptionId !== undefined) { if (selectedOptionId !== undefined) {
const selectedOption = otherAttr.options.find(o => o.id === selectedOptionId); const selectedOption = otherAttr.options.find((o) => o.id === selectedOptionId);
if (selectedOption) { if (selectedOption) {
const selectedNormalized = this.normalizeText(selectedOption.value || selectedOption.label); const selectedNormalized = this.normalizeText(
const vAttrValue = this.getDefaultVariantAttributeValue(otherAttr, variant.attributes); selectedOption.value || selectedOption.label,
if (!vAttrValue || this.normalizeText(vAttrValue) !== selectedNormalized) { );
return false; const vAttrValue = this.getDefaultVariantAttributeValue(
} otherAttr,
variant.attributes,
);
if (!vAttrValue || this.normalizeText(vAttrValue) !== selectedNormalized) {
return false;
} }
} }
}
} }
return true; return true;
}); });
@@ -85,7 +93,7 @@ export class ProductAttributeSelectorComponent {
effect(() => { effect(() => {
const attributes = this.attributes(); const attributes = this.attributes();
const defaultVariant = this.defaultVariant(); const defaultVariant = this.defaultVariant();
untracked(() => { untracked(() => {
this.initializeSelections(attributes, defaultVariant); this.initializeSelections(attributes, defaultVariant);
}); });
@@ -95,24 +103,27 @@ export class ProductAttributeSelectorComponent {
const selections = this.selectedAttributeOptions(); const selections = this.selectedAttributeOptions();
const variantsMap = this.variantsMap(); const variantsMap = this.variantsMap();
const attributes = this.attributes(); const attributes = this.attributes();
untracked(() => { untracked(() => {
this.emitMatchingVariant(selections, variantsMap, attributes); this.emitMatchingVariant(selections, variantsMap, attributes);
}); });
}); });
} }
protected hasSelectedOption(attribute: ProductAttribute, option: ProductAttributeOption): boolean { protected hasSelectedOption(
attribute: ProductAttribute,
option: ProductAttributeOption,
): boolean {
return this.selectedAttributeOptions()[attribute.id] === option.id; return this.selectedAttributeOptions()[attribute.id] === option.id;
} }
protected selectAttributeOption( protected selectAttributeOption(
attribute: ProductAttribute, attribute: ProductAttribute,
option: ProductAttributeOption option: ProductAttributeOption,
): void { ): void {
this.selectedAttributeOptions.update((current) => ({ this.selectedAttributeOptions.update((current) => ({
...current, ...current,
[attribute.id]: option.id [attribute.id]: option.id,
})); }));
} }
@@ -124,7 +135,10 @@ export class ProductAttributeSelectorComponent {
return this.findFirstHexValue(option.metadata) ?? '#D9D9D9'; return this.findFirstHexValue(option.metadata) ?? '#D9D9D9';
} }
private initializeSelections(attributes: ProductAttribute[], defaultVariant: ProductVariant | null): void { private initializeSelections(
attributes: ProductAttribute[],
defaultVariant: ProductVariant | null,
): void {
const selections: Record<number, number> = {}; const selections: Record<number, number> = {};
for (const attribute of attributes) { for (const attribute of attributes) {
@@ -139,7 +153,7 @@ export class ProductAttributeSelectorComponent {
private findDefaultOptionId( private findDefaultOptionId(
attribute: ProductAttribute, attribute: ProductAttribute,
variant: ProductVariant | null variant: ProductVariant | null,
): number | null { ): number | null {
if (!variant) { if (!variant) {
return null; return null;
@@ -152,7 +166,7 @@ export class ProductAttributeSelectorComponent {
const normalizedValue = this.normalizeText(defaultValue); const normalizedValue = this.normalizeText(defaultValue);
const matchByValue = attribute.options.find( const matchByValue = attribute.options.find(
(option) => this.normalizeText(option.value) === normalizedValue (option) => this.normalizeText(option.value) === normalizedValue,
); );
if (matchByValue) { if (matchByValue) {
@@ -160,7 +174,7 @@ export class ProductAttributeSelectorComponent {
} }
const matchByLabel = attribute.options.find( const matchByLabel = attribute.options.find(
(option) => this.normalizeText(option.label) === normalizedValue (option) => this.normalizeText(option.label) === normalizedValue,
); );
return matchByLabel?.id ?? null; return matchByLabel?.id ?? null;
@@ -168,7 +182,7 @@ export class ProductAttributeSelectorComponent {
private getDefaultVariantAttributeValue( private getDefaultVariantAttributeValue(
attribute: ProductAttribute, attribute: ProductAttribute,
variantAttributes: Record<string, string> variantAttributes: Record<string, string>,
): string | null { ): string | null {
const normalizedCodigo = this.normalizeText(attribute.codigo); const normalizedCodigo = this.normalizeText(attribute.codigo);
const normalizedNombre = this.normalizeText(attribute.nombre); const normalizedNombre = this.normalizeText(attribute.nombre);
@@ -192,6 +206,10 @@ export class ProductAttributeSelectorComponent {
.toLowerCase(); .toLowerCase();
} }
private isVariantAvailable(variant: ProductVariantMap): boolean {
return variant.inventory_policy === 'unlimited' || (variant.cantidad_maxima ?? 0) > 0;
}
private findFirstHexValue(value: unknown): string | null { private findFirstHexValue(value: unknown): string | null {
if (typeof value === 'string') { if (typeof value === 'string') {
const match = value.match(/#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b/); const match = value.match(/#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b/);
@@ -223,7 +241,7 @@ export class ProductAttributeSelectorComponent {
private emitMatchingVariant( private emitMatchingVariant(
selections: Record<number, number>, selections: Record<number, number>,
variantsMap: ProductVariantMap[], variantsMap: ProductVariantMap[],
attributes: ProductAttribute[] attributes: ProductAttribute[],
): void { ): void {
if (attributes.length === 0 || variantsMap.length === 0) { if (attributes.length === 0 || variantsMap.length === 0) {
this.variantChange.emit(null); this.variantChange.emit(null);
@@ -235,12 +253,12 @@ export class ProductAttributeSelectorComponent {
for (const attr of attributes) { for (const attr of attributes) {
const selectedOptionId = selections[attr.id]; const selectedOptionId = selections[attr.id];
if (selectedOptionId === undefined) { if (selectedOptionId === undefined) {
allSelected = false; allSelected = false;
break; break;
} }
const option = attr.options.find(o => o.id === selectedOptionId); const option = attr.options.find((o) => o.id === selectedOptionId);
if (option) { if (option) {
selectedValuesById[attr.id] = this.normalizeText(option.value || option.label); selectedValuesById[attr.id] = this.normalizeText(option.value || option.label);
} }
} }
@@ -249,12 +267,12 @@ export class ProductAttributeSelectorComponent {
return; return;
} }
const matchingVariant = variantsMap.find(vMap => { const matchingVariant = variantsMap.find((vMap) => {
return attributes.every(attr => { return attributes.every((attr) => {
const selectedValue = selectedValuesById[attr.id]; const selectedValue = selectedValuesById[attr.id];
const vMapValue = this.getDefaultVariantAttributeValue(attr, vMap.attributes); const vMapValue = this.getDefaultVariantAttributeValue(attr, vMap.attributes);
if (!vMapValue) return false; if (!vMapValue) return false;
return this.normalizeText(vMapValue) === selectedValue; return this.normalizeText(vMapValue) === selectedValue;
}); });
}); });

View File

@@ -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 { FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { startWith } from 'rxjs'; 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 { StepperComponent } from '../../../../shared/components/stepper/stepper.component';
import { CheckoutDataStepComponent } from './checkout-data-step.component'; import { CheckoutDataStepComponent } from './checkout-data-step.component';
import { CheckoutPaymentStepComponent } from './checkout-payment-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({ @Component({
selector: 'app-checkout-page', selector: 'app-checkout-page',
@@ -24,11 +40,11 @@ import { CheckoutForm, PaymentMethod, PaymentMethodOption, TransferAccount, Tran
StepperComponent, StepperComponent,
StepComponent, StepComponent,
CheckoutDataStepComponent, CheckoutDataStepComponent,
CheckoutPaymentStepComponent CheckoutPaymentStepComponent,
], ],
templateUrl: './checkout-page.component.html', templateUrl: './checkout-page.component.html',
styleUrl: './checkout-page.component.scss', styleUrl: './checkout-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class CheckoutPageComponent implements OnInit { export class CheckoutPageComponent implements OnInit {
private readonly formBuilder = inject(FormBuilder); private readonly formBuilder = inject(FormBuilder);
@@ -44,7 +60,7 @@ export class CheckoutPageComponent implements OnInit {
nombre: ['', [Validators.required]], nombre: ['', [Validators.required]],
email: ['', [Validators.required, Validators.email]], email: ['', [Validators.required, Validators.email]],
dni: ['', [Validators.required]], dni: ['', [Validators.required]],
telefono: ['', [Validators.required]] telefono: ['', [Validators.required]],
}); });
protected readonly cartSubtotal = computed(() => { protected readonly cartSubtotal = computed(() => {
@@ -66,7 +82,7 @@ export class CheckoutPageComponent implements OnInit {
protected readonly paymentMethods: ReadonlyArray<PaymentMethodOption> = [ protected readonly paymentMethods: ReadonlyArray<PaymentMethodOption> = [
{ id: 'qr', label: 'QR' }, { id: 'qr', label: 'QR' },
{ id: 'transfer', label: 'Transferencia' }, { id: 'transfer', label: 'Transferencia' },
{ id: 'telepagos', label: 'TelePagos' } { id: 'telepagos', label: 'TelePagos' },
]; ];
protected readonly selectedPaymentMethod = signal<PaymentMethod>('qr'); protected readonly selectedPaymentMethod = signal<PaymentMethod>('qr');
protected readonly copiedTransferField = signal<TransferField | null>(null); protected readonly copiedTransferField = signal<TransferField | null>(null);
@@ -74,13 +90,15 @@ export class CheckoutPageComponent implements OnInit {
titular: 'Nombre y Apellido', titular: 'Nombre y Apellido',
entidad: 'TelePagos', entidad: 'TelePagos',
cvu: '0000000000000000000000', cvu: '0000000000000000000000',
alias: 'telepagos.ar' alias: 'telepagos.ar',
}); });
protected readonly isCreatingPurchase = signal(false); protected readonly isCreatingPurchase = signal(false);
protected readonly createdPurchaseId = signal<number | null>(null); protected readonly createdPurchaseId = signal<number | null>(null);
protected readonly isGeneratingIntent = signal(false); 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); protected readonly qrData = signal<string | null>(null);
constructor() { constructor() {
@@ -88,7 +106,7 @@ export class CheckoutPageComponent implements OnInit {
// We only want to trigger the intent generation when the cart changes. // 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. // So we track the cart, but untrack the other signals to prevent duplicate calls.
const cart = this.cartService.cart(); const cart = this.cartService.cart();
untracked(() => { untracked(() => {
const purchaseId = this.createdPurchaseId(); const purchaseId = this.createdPurchaseId();
if (cart && purchaseId) { if (cart && purchaseId) {
@@ -108,7 +126,7 @@ export class CheckoutPageComponent implements OnInit {
nombre: user.nombre_apellido, nombre: user.nombre_apellido,
email: user.email, email: user.email,
dni: user.dni ?? '', dni: user.dni ?? '',
telefono: user.telefono ?? '' telefono: user.telefono ?? '',
}); });
this.form.controls.nombre.disable(); this.form.controls.nombre.disable();
this.form.controls.email.disable(); this.form.controls.email.disable();
@@ -138,14 +156,14 @@ export class CheckoutPageComponent implements OnInit {
} }
return { return {
productVariantId: item.product_variant_id, cartItemId: item.id,
imageUrl: item.product?.imagen ?? null, imageUrl: item.product?.imagen ?? null,
product, product,
originalPrice: null, originalPrice: null,
discountedPrice: parseFloat(item.precio_unitario), discountedPrice: parseFloat(item.precio_unitario),
discountPercentage: null, discountPercentage: null,
attributes, 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); const response = await this.checkoutService.createPurchase(tenant.codigo, payload);
this.createdPurchaseId.set(response.id); this.createdPurchaseId.set(response.id);
this.stepper.next(); this.stepper.next();
// Auto trigger intent for default option // Auto trigger intent for default option
void this.selectPaymentMethod(this.selectedPaymentMethod()); void this.selectPaymentMethod(this.selectedPaymentMethod());
} catch (error) { } catch (error) {
console.error('Failed to create purchase:', error); console.error('Failed to create purchase:', error);
// Here we could show an alert or toast // Here we could show an alert or toast
@@ -205,8 +222,12 @@ export class CheckoutPageComponent implements OnInit {
this.isGeneratingIntent.set(true); this.isGeneratingIntent.set(true);
try { 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) { if (method === 'qr' && response.qr_data?.qr_code) {
this.qrData.set(response.qr_data.qr_code); this.qrData.set(response.qr_data.qr_code);
} else if (method === 'transfer' && response.transfer_data) { } else if (method === 'transfer' && response.transfer_data) {
@@ -214,7 +235,7 @@ export class CheckoutPageComponent implements OnInit {
titular: response.transfer_data.titular, titular: response.transfer_data.titular,
entidad: response.transfer_data.entidad, entidad: response.transfer_data.entidad,
cvu: response.transfer_data.cvu, cvu: response.transfer_data.cvu,
alias: response.transfer_data.alias alias: response.transfer_data.alias,
}); });
} }
} catch (error) { } catch (error) {

View File

@@ -1,118 +1,119 @@
<section class="product-detail"> <section class="product-detail">
@if (loading()) { @if (loading()) {
<div class="text-center py-5"> <div class="text-center py-5">
<div class="spinner-border text-primary" role="status"> <div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Cargando...</span> <span class="visually-hidden">Cargando...</span>
</div>
</div>
} @else if (error()) {
<div class="alert alert-danger text-center mb-0" role="alert">
{{ error() }}
</div>
} @else if (product(); as prod) {
<div class="row g-4 g-xl-5 align-items-start">
<div class="col-12 col-lg-5">
<div #carouselHost class="product-detail__carousel">
<app-product-carousel [images]="carouselImages()" />
</div> </div>
</div> </div>
} @else if (error()) {
<div class="alert alert-danger text-center mb-0" role="alert">
{{ error() }}
</div>
} @else if (product(); as prod) {
<div class="row g-4 g-xl-5 align-items-start">
<div class="col-12 col-lg-5">
<div #carouselHost class="product-detail__carousel">
<app-product-carousel [images]="carouselImages()" />
</div>
</div>
<div class="col-12 col-lg-7"> <div class="col-12 col-lg-7">
<div class="product-detail__panel"> <div class="product-detail__panel">
<section class="product-detail__section"> <section class="product-detail__section">
<h1 class="product-detail__title mb-0">{{ prod.nombre }}</h1> <h1 class="product-detail__title mb-0">{{ prod.nombre }}</h1>
<div class="product-detail__title-price-divider"></div> <div class="product-detail__title-price-divider"></div>
<div class="product-detail__price-group"> <div class="product-detail__price-group">
<span class="product-detail__price-current"> <span class="product-detail__price-current">
{{ getFormattedPrice(prod.precio) }} {{ getFormattedPrice(prod.precio) }}
</span> </span>
@if (oldPrice(); as oldPrice) { @if (oldPrice(); as oldPrice) {
<span class="product-detail__price-previous">{{ oldPrice }}</span> <span class="product-detail__price-previous">{{ oldPrice }}</span>
}
</div>
</section>
@if (hasRenderableAttributes()) {
<div class="product-detail__divider"></div>
<section class="product-detail__section product-detail__section--attributes">
<app-product-attribute-selector
[attributes]="renderableAttributes()"
[variantsMap]="prod.variants_map"
[defaultVariant]="prod.variant"
(variantChange)="onVariantChange($event)"
/>
</section>
}
<div class="product-detail__divider"></div>
<section class="product-detail__section">
<div class="product-detail__purchase">
<app-quantity-selector
[(quantity)]="quantity"
[max]="selectedVariant()?.cantidad_maxima ?? 1"
/>
<div class="product-detail__actions">
<app-button
class="product-detail__cta"
variant="secondary"
type="button"
[disabled]="!selectedVariant() || variantLoading() || addingToCart()"
(click)="addToCart()"
>
@if (variantLoading() || addingToCart()) {
<div class="spinner-border spinner-border-sm" role="status"></div>
} @else {
Agregar al carrito
}
</app-button>
<app-button class="product-detail__cta" type="button" [disabled]="!selectedVariant() || variantLoading()">
@if (variantLoading()) {
<div class="spinner-border spinner-border-sm" role="status"></div>
} @else {
Comprar
}
</app-button>
</div>
</div>
</section>
<div class="product-detail__divider"></div>
<section class="product-detail__section product-detail__section--description">
<h2 class="product-detail__description-heading">DESCRIPCIÓN</h2>
<div
#descriptionBody
class="product-detail__description-body"
[class.product-detail__description-body--expanded]="descriptionExpanded()"
[style.max-height.px]="descriptionExpanded() ? descriptionMaxHeight() || null : null"
>
{{ prod.descripcion }}
</div>
@if (showDescriptionToggle()) {
<button
type="button"
class="product-detail__description-toggle"
(click)="toggleDescription()"
>
{{ descriptionExpanded() ? 'Mostrar menos' : 'Mostrar más' }}
</button>
} }
</div>
</section>
@if (hasRenderableAttributes()) {
<div class="product-detail__divider"></div>
<section class="product-detail__section product-detail__section--attributes">
<app-product-attribute-selector
[attributes]="renderableAttributes()"
[variantsMap]="prod.variants_map"
[defaultVariant]="prod.variant"
(variantChange)="onVariantChange($event)"
/>
</section> </section>
</div> }
<div class="product-detail__divider"></div>
<section class="product-detail__section">
<div class="product-detail__purchase">
<app-quantity-selector [(quantity)]="quantity" [max]="selectedVariantMax()" />
<div class="product-detail__actions">
<app-button
class="product-detail__cta"
variant="secondary"
type="button"
[disabled]="!selectedVariantAvailable() || variantLoading() || addingToCart()"
(click)="addToCart()"
>
@if (variantLoading() || addingToCart()) {
<div class="spinner-border spinner-border-sm" role="status"></div>
} @else {
Agregar al carrito
}
</app-button>
<app-button
class="product-detail__cta"
type="button"
[disabled]="!selectedVariantAvailable() || variantLoading()"
>
@if (variantLoading()) {
<div class="spinner-border spinner-border-sm" role="status"></div>
} @else {
Comprar
}
</app-button>
</div>
</div>
</section>
<div class="product-detail__divider"></div>
<section class="product-detail__section product-detail__section--description">
<h2 class="product-detail__description-heading">DESCRIPCIÓN</h2>
<div
#descriptionBody
class="product-detail__description-body"
[class.product-detail__description-body--expanded]="descriptionExpanded()"
[style.max-height.px]="descriptionExpanded() ? descriptionMaxHeight() || null : null"
>
{{ prod.descripcion }}
</div>
@if (showDescriptionToggle()) {
<button
type="button"
class="product-detail__description-toggle"
(click)="toggleDescription()"
>
{{ descriptionExpanded() ? 'Mostrar menos' : 'Mostrar más' }}
</button>
}
</section>
</div> </div>
</div> </div>
} @else { </div>
<div class="alert alert-warning text-center mb-0" role="alert"> } @else {
No se encontró el producto especificado. <div class="alert alert-warning text-center mb-0" role="alert">
</div> No se encontró el producto especificado.
} </div>
}
</section> </section>

View File

@@ -155,7 +155,9 @@ describe('ProductDetailPageComponent', () => {
images: ['https://example.com/product.png'], images: ['https://example.com/product.png'],
variant: { variant: {
id: 123, id: 123,
inventory_policy: 'tracked',
cantidad_maxima: 10, cantidad_maxima: 10,
cantidad_vendida: 0,
images: ['https://example.com/variant1.png', 'https://example.com/variant2.png'], images: ['https://example.com/variant1.png', 'https://example.com/variant2.png'],
definitions: {}, definitions: {},
}, },
@@ -260,7 +262,9 @@ describe('ProductDetailPageComponent', () => {
], ],
variant: { variant: {
id: 123, id: 123,
inventory_policy: 'tracked',
cantidad_maxima: 10, cantidad_maxima: 10,
cantidad_vendida: 0,
images: ['https://example.com/variant1.png'], images: ['https://example.com/variant1.png'],
definitions: { definitions: {
color: 'beige', color: 'beige',
@@ -309,7 +313,9 @@ describe('ProductDetailPageComponent', () => {
fixture.componentInstance['selectedVariant'].set({ fixture.componentInstance['selectedVariant'].set({
variant_id: 1, variant_id: 1,
inventory_policy: 'tracked',
cantidad_maxima: 10, cantidad_maxima: 10,
cantidad_vendida: 0,
attributes: {}, attributes: {},
}); });
fixture.detectChanges(); fixture.detectChanges();
@@ -385,14 +391,18 @@ describe('ProductDetailPageComponent', () => {
...mockProduct, ...mockProduct,
variant: { variant: {
id: 123, id: 123,
inventory_policy: 'tracked',
cantidad_maxima: 5, cantidad_maxima: 5,
cantidad_vendida: 0,
images: [], images: [],
definitions: {}, definitions: {},
}, },
variants_map: [ variants_map: [
{ {
variant_id: 123, variant_id: 123,
inventory_policy: 'tracked',
cantidad_maxima: 5, cantidad_maxima: 5,
cantidad_vendida: 0,
attributes: {}, attributes: {},
}, },
], ],
@@ -417,7 +427,7 @@ describe('ProductDetailPageComponent', () => {
addToCartButton.click(); addToCartButton.click();
fixture.detectChanges(); fixture.detectChanges();
expect(cartServiceStub.addItem).toHaveBeenCalledWith(123, 3); expect(cartServiceStub.addItem).toHaveBeenCalledWith('variant', 123, 3);
expect(toastServiceStub.success).toHaveBeenCalledWith('Producto agregado al carrito'); expect(toastServiceStub.success).toHaveBeenCalledWith('Producto agregado al carrito');
}); });
@@ -426,14 +436,18 @@ describe('ProductDetailPageComponent', () => {
...mockProduct, ...mockProduct,
variant: { variant: {
id: 123, id: 123,
inventory_policy: 'tracked',
cantidad_maxima: 5, cantidad_maxima: 5,
cantidad_vendida: 0,
images: [], images: [],
definitions: {}, definitions: {},
}, },
variants_map: [ variants_map: [
{ {
variant_id: 123, variant_id: 123,
inventory_policy: 'tracked',
cantidad_maxima: 5, cantidad_maxima: 5,
cantidad_vendida: 0,
attributes: {}, attributes: {},
}, },
], ],
@@ -461,4 +475,71 @@ describe('ProductDetailPageComponent', () => {
'No se pudo agregar el producto al carrito.', 'No se pudo agregar el producto al carrito.',
); );
}); });
it('allows unlimited variants to increase quantity without a maximum', async () => {
const unlimitedVariant = {
variant_id: 321,
inventory_policy: 'unlimited' as const,
cantidad_maxima: null,
cantidad_vendida: 10,
attributes: {},
};
resolveProduct({
...mockProduct,
variant: {
id: 321,
inventory_policy: 'unlimited',
cantidad_maxima: null,
cantidad_vendida: 10,
images: [],
definitions: {},
},
variants_map: [unlimitedVariant],
});
await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent);
fixture.detectChanges();
const increaseButton = fixture.nativeElement.querySelector(
'app-quantity-selector button:last-child',
) as HTMLButtonElement;
expect(increaseButton.disabled).toBe(false);
increaseButton.click();
fixture.detectChanges();
expect(fixture.componentInstance['quantity']()).toBe(2);
});
it('disables purchase actions for tracked variants without stock', async () => {
const trackedVariant = {
variant_id: 654,
inventory_policy: 'tracked' as const,
cantidad_maxima: 0,
cantidad_vendida: 5,
attributes: {},
};
resolveProduct({
...mockProduct,
variant: {
id: 654,
inventory_policy: 'tracked',
cantidad_maxima: 0,
cantidad_vendida: 5,
images: [],
definitions: {},
},
variants_map: [trackedVariant],
});
await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent);
fixture.detectChanges();
const purchaseButtons = Array.from(
fixture.nativeElement.querySelectorAll('app-button button'),
) as HTMLButtonElement[];
expect(purchaseButtons).toHaveLength(2);
expect(purchaseButtons.every((button) => button.disabled)).toBe(true);
});
}); });

View File

@@ -80,6 +80,14 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
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);
protected readonly selectedVariantMax = computed<number | null>(() => {
const variant = this.selectedVariant();
return variant ? variant.cantidad_maxima : 1;
});
protected readonly selectedVariantAvailable = computed(() => {
const variant = this.selectedVariant();
return variant !== null && this.isVariantAvailable(variant);
});
protected readonly descriptionExpanded = signal(false); protected readonly descriptionExpanded = signal(false);
protected readonly descriptionMaxHeight = signal(0); protected readonly descriptionMaxHeight = signal(0);
protected readonly descriptionHasOverflow = signal(false); protected readonly descriptionHasOverflow = signal(false);
@@ -143,15 +151,9 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
this.product.update((currentProduct) => { this.product.update((currentProduct) => {
if (!currentProduct) return null; if (!currentProduct) return null;
const updatedVariantsMap = currentProduct.variants_map.map((v) => {
if (v.variant_id === variantId) {
return { ...v, cantidad_maxima: 0 };
}
return v;
});
return { return {
...currentProduct, ...currentProduct,
variants_map: updatedVariantsMap, variants_map: currentProduct.variants_map.filter((v) => v.variant_id !== variantId),
}; };
}); });
@@ -209,7 +211,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
} }
this.selectedVariant.set(variant); this.selectedVariant.set(variant);
if (variant && this.quantity() > variant.cantidad_maxima) { if (variant && variant.cantidad_maxima !== null && this.quantity() > variant.cantidad_maxima) {
this.quantity.set(Math.max(1, variant.cantidad_maxima)); this.quantity.set(Math.max(1, variant.cantidad_maxima));
} }
@@ -221,13 +223,13 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
protected addToCart(): void { protected addToCart(): void {
const variant = this.selectedVariant(); const variant = this.selectedVariant();
if (!variant) { if (!variant || !this.isVariantAvailable(variant)) {
this.toastService.danger('Por favor, selecciona una variante.'); this.toastService.danger('Por favor, selecciona una variante.');
return; return;
} }
this.addingToCart.set(true); 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) => { next: (res) => {
const msg = res.message || 'Producto agregado al carrito'; const msg = res.message || 'Producto agregado al carrito';
this.toastService.success(msg); this.toastService.success(msg);
@@ -241,6 +243,10 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
}); });
} }
private isVariantAvailable(variant: ProductVariantMap): boolean {
return variant.inventory_policy === 'unlimited' || (variant.cantidad_maxima ?? 0) > 0;
}
protected toggleDescription(): void { protected toggleDescription(): void {
this.descriptionExpanded.update((current) => !current); this.descriptionExpanded.update((current) => !current);
} }

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"> <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> <h2 class="m-0 text-uppercase fw-bold cart-title">{{ title() }}</h2>
@if (showClose()) { @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> <i class="fa-solid fa-xmark"></i>
</button> </button>
} }
</header> </header>
<div class="d-grid w-100 flex-grow-1 overflow-y-auto cart-items-container"> <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 <app-cart-item
[imageUrl]="item.imageUrl" [imageUrl]="item.imageUrl"
[product]="item.product" [product]="item.product"
@@ -46,6 +58,3 @@
</div> </div>
</footer> </footer>
</section> </section>

View File

@@ -2,10 +2,7 @@ import '@angular/compiler';
import { signal } from '@angular/core'; import { signal } from '@angular/core';
import { TestBed, getTestBed } from '@angular/core/testing'; import { TestBed, getTestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
BrowserTestingModule,
platformBrowserTesting
} from '@angular/platform-browser/testing';
import { of, throwError } from 'rxjs'; import { of, throwError } from 'rxjs';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
@@ -17,10 +14,7 @@ import { CartComponent } from './cart.component';
describe('CartComponent', () => { describe('CartComponent', () => {
beforeAll(() => { beforeAll(() => {
try { try {
getTestBed().initTestEnvironment( getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
BrowserTestingModule,
platformBrowserTesting()
);
} catch { } catch {
// Test environment may already be initialized by another setup entrypoint. // Test environment may already be initialized by another setup entrypoint.
} }
@@ -42,38 +36,38 @@ describe('CartComponent', () => {
useValue: { useValue: {
cart: signal(null).asReadonly(), cart: signal(null).asReadonly(),
updateItemQuantity: vi.fn(), updateItemQuantity: vi.fn(),
removeItem removeItem,
} },
}, },
{ {
provide: ModalService, provide: ModalService,
useValue: { useValue: {
openConfirmDelete openConfirmDelete,
} },
}, },
{ {
provide: ToastService, provide: ToastService,
useValue: { useValue: {
success: vi.fn(), success: vi.fn(),
info: vi.fn(), info: vi.fn(),
danger: vi.fn() danger: vi.fn(),
} },
} },
] ],
}).compileComponents(); }).compileComponents();
const fixture = TestBed.createComponent(CartComponent); const fixture = TestBed.createComponent(CartComponent);
fixture.componentRef.setInput('items', [ fixture.componentRef.setInput('items', [
{ {
productVariantId: 10, cartItemId: 10,
imageUrl: null, imageUrl: null,
product: 'Producto de prueba', product: 'Producto de prueba',
originalPrice: null, originalPrice: null,
discountedPrice: 1000, discountedPrice: 1000,
discountPercentage: null, discountPercentage: null,
attributes: [], attributes: [],
quantity: 1 quantity: 1,
} },
]); ]);
fixture.detectChanges(); fixture.detectChanges();
@@ -81,10 +75,9 @@ describe('CartComponent', () => {
expect(openConfirmDelete).toHaveBeenCalledWith({ expect(openConfirmDelete).toHaveBeenCalledWith({
title: 'Eliminar producto', title: 'Eliminar producto',
content: content: 'Se eliminara "Producto de prueba" del carrito. Esta accion no se puede deshacer.',
'Se eliminara "Producto de prueba" del carrito. Esta accion no se puede deshacer.',
confirmLabel: 'Eliminar', confirmLabel: 'Eliminar',
cancelLabel: 'Cancelar' cancelLabel: 'Cancelar',
}); });
expect(removeItem).toHaveBeenCalledWith(10); expect(removeItem).toHaveBeenCalledWith(10);
}); });
@@ -101,38 +94,38 @@ describe('CartComponent', () => {
useValue: { useValue: {
cart: signal(null).asReadonly(), cart: signal(null).asReadonly(),
updateItemQuantity: vi.fn(), updateItemQuantity: vi.fn(),
removeItem removeItem,
} },
}, },
{ {
provide: ModalService, provide: ModalService,
useValue: { useValue: {
openConfirmDelete openConfirmDelete,
} },
}, },
{ {
provide: ToastService, provide: ToastService,
useValue: { useValue: {
success: vi.fn(), success: vi.fn(),
info: vi.fn(), info: vi.fn(),
danger: vi.fn() danger: vi.fn(),
} },
} },
] ],
}).compileComponents(); }).compileComponents();
const fixture = TestBed.createComponent(CartComponent); const fixture = TestBed.createComponent(CartComponent);
fixture.componentRef.setInput('items', [ fixture.componentRef.setInput('items', [
{ {
productVariantId: 10, cartItemId: 10,
imageUrl: null, imageUrl: null,
product: 'Producto de prueba', product: 'Producto de prueba',
originalPrice: null, originalPrice: null,
discountedPrice: 1000, discountedPrice: 1000,
discountPercentage: null, discountPercentage: null,
attributes: [], attributes: [],
quantity: 1 quantity: 1,
} },
]); ]);
fixture.detectChanges(); fixture.detectChanges();
@@ -155,36 +148,36 @@ describe('CartComponent', () => {
useValue: { useValue: {
cart: signal(null).asReadonly(), cart: signal(null).asReadonly(),
updateItemQuantity, updateItemQuantity,
removeItem: vi.fn() removeItem: vi.fn(),
} },
}, },
{ {
provide: ModalService, provide: ModalService,
useValue: {} useValue: {},
}, },
{ {
provide: ToastService, provide: ToastService,
useValue: { useValue: {
success: vi.fn(), success: vi.fn(),
info: vi.fn(), info: vi.fn(),
danger danger,
} },
} },
] ],
}).compileComponents(); }).compileComponents();
const fixture = TestBed.createComponent(CartComponent); const fixture = TestBed.createComponent(CartComponent);
const component = fixture.componentInstance; const component = fixture.componentInstance;
const item = { const item = {
productVariantId: 10, cartItemId: 10,
imageUrl: null, imageUrl: null,
product: 'Producto de prueba', product: 'Producto de prueba',
originalPrice: null, originalPrice: null,
discountedPrice: 1000, discountedPrice: 1000,
discountPercentage: null, discountPercentage: null,
attributes: [], attributes: [],
quantity: 1 quantity: 1,
}; };
fixture.componentRef.setInput('items', [item]); fixture.componentRef.setInput('items', [item]);
@@ -221,36 +214,36 @@ describe('CartComponent', () => {
useValue: { useValue: {
cart: signal(null).asReadonly(), cart: signal(null).asReadonly(),
updateItemQuantity, updateItemQuantity,
removeItem: vi.fn() removeItem: vi.fn(),
} },
}, },
{ {
provide: ModalService, provide: ModalService,
useValue: {} useValue: {},
}, },
{ {
provide: ToastService, provide: ToastService,
useValue: { useValue: {
success, success,
info: vi.fn(), info: vi.fn(),
danger: vi.fn() danger: vi.fn(),
} },
} },
] ],
}).compileComponents(); }).compileComponents();
const fixture = TestBed.createComponent(CartComponent); const fixture = TestBed.createComponent(CartComponent);
const component = fixture.componentInstance; const component = fixture.componentInstance;
const item = { const item = {
productVariantId: 10, cartItemId: 10,
imageUrl: null, imageUrl: null,
product: 'Producto de prueba', product: 'Producto de prueba',
originalPrice: null, originalPrice: null,
discountedPrice: 1000, discountedPrice: 1000,
discountPercentage: null, discountPercentage: null,
attributes: [], attributes: [],
quantity: 1 quantity: 1,
}; };
fixture.componentRef.setInput('items', [item]); 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 { HttpErrorResponse } from '@angular/common/http';
import { Subject, EMPTY } from 'rxjs'; import { Subject, EMPTY } from 'rxjs';
import { catchError, debounceTime, groupBy, mergeMap, switchMap, tap } 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'; import { ToastService } from '../../../core/services/toast.service';
export interface CartItemMock { export interface CartItemMock {
productVariantId?: number; cartItemId?: number;
imageUrl: string | null; imageUrl: string | null;
product: string; product: string;
originalPrice: number | null; originalPrice: number | null;
@@ -25,13 +33,13 @@ export interface CartItemMock {
imports: [CartItemComponent], imports: [CartItemComponent],
templateUrl: './cart.component.html', templateUrl: './cart.component.html',
styleUrl: './cart.component.scss', styleUrl: './cart.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class CartComponent { export class CartComponent {
private readonly cartService = inject(CartService); private readonly cartService = inject(CartService);
private readonly modalService = inject(ModalService); private readonly modalService = inject(ModalService);
private readonly toastService = inject(ToastService); 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 title = input<string>('CARRITO');
readonly showClose = input<boolean>(false); readonly showClose = input<boolean>(false);
@@ -46,68 +54,75 @@ export class CartComponent {
protected readonly quantityOverrides = signal<Record<number, number>>({}); protected readonly quantityOverrides = signal<Record<number, number>>({});
constructor() { constructor() {
this.quantityUpdates$.pipe( this.quantityUpdates$
groupBy(update => update.productVariantId), .pipe(
mergeMap(group$ => group$.pipe( groupBy((update) => update.cartItemId),
debounceTime(1000), mergeMap((group$) =>
switchMap(update => this.cartService.updateItemQuantity(update.productVariantId, update.quantity).pipe( group$.pipe(
tap({ debounceTime(1000),
next: (res) => { switchMap((update) =>
const msg = res.message || 'Cantidad de producto actualizada.'; this.cartService.updateItemQuantity(update.cartItemId, update.quantity).pipe(
this.toastService.success(msg); tap({
this.clearOverride(update.productVariantId); next: (res) => {
}, const msg = res.message || 'Cantidad de producto actualizada.';
error: (err: HttpErrorResponse) => { this.toastService.success(msg);
console.error('Error updating cart quantity', err); this.clearOverride(update.cartItemId);
const msg = err.error?.message || 'Error al actualizar la cantidad del producto.'; },
this.toastService.danger(msg); error: (err: HttpErrorResponse) => {
this.clearOverride(update.productVariantId); console.error('Error updating cart quantity', err);
} const msg =
}), err.error?.message || 'Error al actualizar la cantidad del producto.';
catchError(() => EMPTY) this.toastService.danger(msg);
)) this.clearOverride(update.cartItemId);
)), },
takeUntilDestroyed() }),
).subscribe(); catchError(() => EMPTY),
),
),
),
),
takeUntilDestroyed(),
)
.subscribe();
} }
protected getItemQuantity(item: CartItemMock): number { protected getItemQuantity(item: CartItemMock): number {
if (item.productVariantId !== undefined && this.quantityOverrides()[item.productVariantId] !== undefined) { if (item.cartItemId !== undefined && this.quantityOverrides()[item.cartItemId] !== undefined) {
return this.quantityOverrides()[item.productVariantId]; return this.quantityOverrides()[item.cartItemId];
} }
return item.quantity; return item.quantity;
} }
private clearOverride(productVariantId: number): void { private clearOverride(cartItemId: number): void {
this.quantityOverrides.update((overrides) => { this.quantityOverrides.update((overrides) => {
const copy = { ...overrides }; const copy = { ...overrides };
delete copy[productVariantId]; delete copy[cartItemId];
return copy; return copy;
}); });
} }
protected onItemQuantityChange(index: number, newQuantity: number): void { protected onItemQuantityChange(index: number, newQuantity: number): void {
const mockItem = this.items()[index]; const mockItem = this.items()[index];
const productVariantId = mockItem?.productVariantId; const cartItemId = mockItem?.cartItemId;
if (productVariantId) { if (cartItemId) {
this.quantityOverrides.update((overrides) => ({ this.quantityOverrides.update((overrides) => ({
...overrides, ...overrides,
[productVariantId]: newQuantity [cartItemId]: newQuantity,
})); }));
this.quantityUpdates$.next({ this.quantityUpdates$.next({
productVariantId, cartItemId,
quantity: newQuantity quantity: newQuantity,
}); });
} else { } else {
const item = this.cartService.cart()?.items[index]; const item = this.cartService.cart()?.items[index];
if (item) { if (item) {
this.quantityOverrides.update((overrides) => ({ this.quantityOverrides.update((overrides) => ({
...overrides, ...overrides,
[item.product_variant_id]: newQuantity [item.id]: newQuantity,
})); }));
this.quantityUpdates$.next({ this.quantityUpdates$.next({
productVariantId: item.product_variant_id, cartItemId: item.id,
quantity: newQuantity quantity: newQuantity,
}); });
} }
} }
@@ -120,16 +135,18 @@ export class CartComponent {
return; return;
} }
this.modalService.openConfirmDelete({ this.modalService
title: 'Eliminar producto', .openConfirmDelete({
content: `Se eliminara "${target.productName}" del carrito. Esta accion no se puede deshacer.`, title: 'Eliminar producto',
confirmLabel: 'Eliminar', content: `Se eliminara "${target.productName}" del carrito. Esta accion no se puede deshacer.`,
cancelLabel: 'Cancelar' confirmLabel: 'Eliminar',
}).subscribe((confirmed) => { cancelLabel: 'Cancelar',
if (confirmed) { })
this.removeItem(target.productVariantId); .subscribe((confirmed) => {
} if (confirmed) {
}); this.removeItem(target.cartItemId);
}
});
} }
protected readonly formattedSubtotal = computed(() => this.formatCurrency(this.subtotal())); protected readonly formattedSubtotal = computed(() => this.formatCurrency(this.subtotal()));
@@ -143,16 +160,14 @@ export class CartComponent {
return `$ ${parts.join(',')}`; return `$ ${parts.join(',')}`;
} }
private resolveRemoveTarget( private resolveRemoveTarget(index: number): { cartItemId: number; productName: string } | null {
index: number
): { productVariantId: number; productName: string } | null {
const mockItem = this.items()[index]; const mockItem = this.items()[index];
const productVariantId = mockItem?.productVariantId; const cartItemId = mockItem?.cartItemId;
if (productVariantId) { if (cartItemId) {
return { return {
productVariantId, cartItemId,
productName: mockItem.product productName: mockItem.product,
}; };
} }
@@ -163,13 +178,13 @@ export class CartComponent {
} }
return { return {
productVariantId: item.product_variant_id, cartItemId: item.id,
productName: item.product?.nombre ?? 'este producto' productName: item.product?.nombre ?? 'este producto',
}; };
} }
private removeItem(productVariantId: number): void { private removeItem(cartItemId: number): void {
this.cartService.removeItem(productVariantId).subscribe({ this.cartService.removeItem(cartItemId).subscribe({
next: (res) => { next: (res) => {
const msg = res.message || 'Producto eliminado del carrito.'; const msg = res.message || 'Producto eliminado del carrito.';
this.toastService.info(msg); this.toastService.info(msg);
@@ -178,8 +193,7 @@ export class CartComponent {
console.error('Error removing item from cart', err); console.error('Error removing item from cart', err);
const msg = err.error?.message || 'Error al eliminar el producto del carrito.'; const msg = err.error?.message || 'Error al eliminar el producto del carrito.';
this.toastService.danger(msg); this.toastService.danger(msg);
} },
}); });
} }
} }

View File

@@ -1,4 +1,8 @@
<div class="quantity-selector d-inline-flex align-items-center overflow-hidden rounded" [class.quantity-selector--small]="size() === 'small'" aria-label="Selector de cantidad"> <div
class="quantity-selector d-inline-flex align-items-center overflow-hidden rounded"
[class.quantity-selector--small]="size() === 'small'"
aria-label="Selector de cantidad"
>
<button <button
type="button" type="button"
class="quantity-selector__button border-0 p-0 fw-bold lh-1" class="quantity-selector__button border-0 p-0 fw-bold lh-1"
@@ -9,13 +13,16 @@
- -
</button> </button>
<span class="quantity-selector__value flex-grow-1 min-w-0 text-center fw-bold lh-1 user-select-none">{{ quantity() }}</span> <span
class="quantity-selector__value flex-grow-1 min-w-0 text-center fw-bold lh-1 user-select-none"
>{{ quantity() }}</span
>
<button <button
type="button" type="button"
class="quantity-selector__button border-0 p-0 fw-bold lh-1" class="quantity-selector__button border-0 p-0 fw-bold lh-1"
aria-label="Aumentar cantidad" aria-label="Aumentar cantidad"
[disabled]="quantity() >= max()" [disabled]="atMaximum()"
(click)="onIncrease()" (click)="onIncrease()"
> >
+ +

View File

@@ -0,0 +1,55 @@
import { TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import { QuantitySelectorComponent } from './quantity-selector.component';
describe('QuantitySelectorComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [QuantitySelectorComponent],
}).compileComponents();
});
it('stops increasing when it reaches a numeric maximum', () => {
const fixture = TestBed.createComponent(QuantitySelectorComponent);
fixture.componentRef.setInput('max', 2);
fixture.detectChanges();
const increaseButton = fixture.nativeElement.querySelector(
'button:last-child',
) as HTMLButtonElement;
increaseButton.click();
fixture.detectChanges();
expect(fixture.componentInstance.quantity()).toBe(2);
expect(increaseButton.disabled).toBe(true);
});
it('keeps increasing when maximum is null', () => {
const fixture = TestBed.createComponent(QuantitySelectorComponent);
fixture.componentRef.setInput('max', null);
fixture.detectChanges();
const increaseButton = fixture.nativeElement.querySelector(
'button:last-child',
) as HTMLButtonElement;
increaseButton.click();
increaseButton.click();
fixture.detectChanges();
expect(fixture.componentInstance.quantity()).toBe(3);
expect(increaseButton.disabled).toBe(false);
});
it('does not decrease below the configured minimum', () => {
const fixture = TestBed.createComponent(QuantitySelectorComponent);
fixture.detectChanges();
const decreaseButton = fixture.nativeElement.querySelector(
'button:first-child',
) as HTMLButtonElement;
expect(decreaseButton.disabled).toBe(true);
expect(fixture.componentInstance.quantity()).toBe(1);
});
});

View File

@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, input, model, output } from '@angular/core'; import { ChangeDetectionStrategy, Component, computed, input, model, output } from '@angular/core';
@Component({ @Component({
selector: 'app-quantity-selector', selector: 'app-quantity-selector',
@@ -6,14 +6,19 @@ import { ChangeDetectionStrategy, Component, input, model, output } from '@angul
imports: [], imports: [],
templateUrl: './quantity-selector.component.html', templateUrl: './quantity-selector.component.html',
styleUrl: './quantity-selector.component.scss', styleUrl: './quantity-selector.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class QuantitySelectorComponent { export class QuantitySelectorComponent {
readonly quantity = model<number>(1); readonly quantity = model<number>(1);
readonly min = input<number>(1); readonly min = input<number>(1);
readonly max = input<number>(100); readonly max = input<number | null>(100);
readonly size = input<'small' | 'medium'>('medium'); readonly size = input<'small' | 'medium'>('medium');
protected readonly atMaximum = computed(() => {
const max = this.max();
return max !== null && this.quantity() >= max;
});
readonly increase = output<void>(); readonly increase = output<void>();
readonly decrease = output<void>(); readonly decrease = output<void>();
@@ -25,7 +30,7 @@ export class QuantitySelectorComponent {
} }
protected onIncrease(): void { protected onIncrease(): void {
if (this.quantity() < this.max()) { if (!this.atMaximum()) {
this.quantity.set(this.quantity() + 1); this.quantity.set(this.quantity() + 1);
this.increase.emit(); this.increase.emit();
} }