Compare commits
2 Commits
25085f6f85
...
8b785fd631
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b785fd631 | |||
| e4195a8567 |
@@ -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',
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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[]>([]);
|
||||||
@@ -45,10 +45,13 @@ export class ProductAttributeSelectorComponent {
|
|||||||
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 => {
|
const isAvailable = variants.some((variant) => {
|
||||||
if (variant.cantidad_maxima <= 0) return false;
|
if (!this.isVariantAvailable(variant)) return false;
|
||||||
|
|
||||||
const variantAttrValue = this.getDefaultVariantAttributeValue(attribute, variant.attributes);
|
const variantAttrValue = this.getDefaultVariantAttributeValue(
|
||||||
|
attribute,
|
||||||
|
variant.attributes,
|
||||||
|
);
|
||||||
if (!variantAttrValue || this.normalizeText(variantAttrValue) !== optionNormalized) {
|
if (!variantAttrValue || this.normalizeText(variantAttrValue) !== optionNormalized) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -57,10 +60,15 @@ export class ProductAttributeSelectorComponent {
|
|||||||
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,
|
||||||
|
);
|
||||||
|
const vAttrValue = this.getDefaultVariantAttributeValue(
|
||||||
|
otherAttr,
|
||||||
|
variant.attributes,
|
||||||
|
);
|
||||||
if (!vAttrValue || this.normalizeText(vAttrValue) !== selectedNormalized) {
|
if (!vAttrValue || this.normalizeText(vAttrValue) !== selectedNormalized) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -102,17 +110,20 @@ export class ProductAttributeSelectorComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
@@ -238,7 +256,7 @@ export class ProductAttributeSelectorComponent {
|
|||||||
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,8 +267,8 @@ 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;
|
||||||
|
|||||||
@@ -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() {
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +197,6 @@ export class CheckoutPageComponent implements OnInit {
|
|||||||
|
|
||||||
// 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,7 +222,11 @@ 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);
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -52,17 +52,14 @@
|
|||||||
|
|
||||||
<section class="product-detail__section">
|
<section class="product-detail__section">
|
||||||
<div class="product-detail__purchase">
|
<div class="product-detail__purchase">
|
||||||
<app-quantity-selector
|
<app-quantity-selector [(quantity)]="quantity" [max]="selectedVariantMax()" />
|
||||||
[(quantity)]="quantity"
|
|
||||||
[max]="selectedVariant()?.cantidad_maxima ?? 1"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="product-detail__actions">
|
<div class="product-detail__actions">
|
||||||
<app-button
|
<app-button
|
||||||
class="product-detail__cta"
|
class="product-detail__cta"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="!selectedVariant() || variantLoading() || addingToCart()"
|
[disabled]="!selectedVariantAvailable() || variantLoading() || addingToCart()"
|
||||||
(click)="addToCart()"
|
(click)="addToCart()"
|
||||||
>
|
>
|
||||||
@if (variantLoading() || addingToCart()) {
|
@if (variantLoading() || addingToCart()) {
|
||||||
@@ -72,7 +69,11 @@
|
|||||||
}
|
}
|
||||||
</app-button>
|
</app-button>
|
||||||
|
|
||||||
<app-button class="product-detail__cta" type="button" [disabled]="!selectedVariant() || variantLoading()">
|
<app-button
|
||||||
|
class="product-detail__cta"
|
||||||
|
type="button"
|
||||||
|
[disabled]="!selectedVariantAvailable() || variantLoading()"
|
||||||
|
>
|
||||||
@if (variantLoading()) {
|
@if (variantLoading()) {
|
||||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||||
} @else {
|
} @else {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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),
|
||||||
|
mergeMap((group$) =>
|
||||||
|
group$.pipe(
|
||||||
debounceTime(1000),
|
debounceTime(1000),
|
||||||
switchMap(update => this.cartService.updateItemQuantity(update.productVariantId, update.quantity).pipe(
|
switchMap((update) =>
|
||||||
|
this.cartService.updateItemQuantity(update.cartItemId, update.quantity).pipe(
|
||||||
tap({
|
tap({
|
||||||
next: (res) => {
|
next: (res) => {
|
||||||
const msg = res.message || 'Cantidad de producto actualizada.';
|
const msg = res.message || 'Cantidad de producto actualizada.';
|
||||||
this.toastService.success(msg);
|
this.toastService.success(msg);
|
||||||
this.clearOverride(update.productVariantId);
|
this.clearOverride(update.cartItemId);
|
||||||
},
|
},
|
||||||
error: (err: HttpErrorResponse) => {
|
error: (err: HttpErrorResponse) => {
|
||||||
console.error('Error updating cart quantity', err);
|
console.error('Error updating cart quantity', err);
|
||||||
const msg = err.error?.message || 'Error al actualizar la cantidad del producto.';
|
const msg =
|
||||||
|
err.error?.message || 'Error al actualizar la cantidad del producto.';
|
||||||
this.toastService.danger(msg);
|
this.toastService.danger(msg);
|
||||||
this.clearOverride(update.productVariantId);
|
this.clearOverride(update.cartItemId);
|
||||||
}
|
},
|
||||||
}),
|
}),
|
||||||
catchError(() => EMPTY)
|
catchError(() => EMPTY),
|
||||||
))
|
),
|
||||||
)),
|
),
|
||||||
takeUntilDestroyed()
|
),
|
||||||
).subscribe();
|
),
|
||||||
|
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,14 +135,16 @@ export class CartComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.modalService.openConfirmDelete({
|
this.modalService
|
||||||
|
.openConfirmDelete({
|
||||||
title: 'Eliminar producto',
|
title: 'Eliminar producto',
|
||||||
content: `Se eliminara "${target.productName}" del carrito. Esta accion no se puede deshacer.`,
|
content: `Se eliminara "${target.productName}" del carrito. Esta accion no se puede deshacer.`,
|
||||||
confirmLabel: 'Eliminar',
|
confirmLabel: 'Eliminar',
|
||||||
cancelLabel: 'Cancelar'
|
cancelLabel: 'Cancelar',
|
||||||
}).subscribe((confirmed) => {
|
})
|
||||||
|
.subscribe((confirmed) => {
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
this.removeItem(target.productVariantId);
|
this.removeItem(target.cartItemId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()"
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user