feat: implement variant selection in cart and product components for enhanced user experience
This commit is contained in:
@@ -69,6 +69,21 @@ export class StoreLayoutComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const variants = (item.product?.variants ?? []).map((variant) => ({
|
||||||
|
value: variant.id,
|
||||||
|
values: variant.values,
|
||||||
|
}));
|
||||||
|
const selectedVariant = item.product?.variants?.find(
|
||||||
|
(variant) => variant.id === item.variant_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (selectedVariant) {
|
||||||
|
attributes = Object.entries(selectedVariant.values).map(([label, value]) => ({
|
||||||
|
label: this.formatAttributeLabel(label),
|
||||||
|
value: Array.isArray(value) ? value.join(', ') : value,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cartItemId: item.id,
|
cartItemId: item.id,
|
||||||
imageUrl: item.product?.imagen ?? null,
|
imageUrl: item.product?.imagen ?? null,
|
||||||
@@ -78,9 +93,16 @@ export class StoreLayoutComponent implements OnInit {
|
|||||||
discountPercentage: null,
|
discountPercentage: null,
|
||||||
attributes,
|
attributes,
|
||||||
quantity: item.cantidad,
|
quantity: item.cantidad,
|
||||||
|
variantId: item.variant_id,
|
||||||
|
variants,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private formatAttributeLabel(value: string): string {
|
||||||
|
const label = value.replace(/[_-]+/g, ' ');
|
||||||
|
return label.charAt(0).toUpperCase() + label.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
protected readonly currentYear = new Date().getFullYear();
|
protected readonly currentYear = new Date().getFullYear();
|
||||||
protected readonly tenant = this.tenantService.tenant;
|
protected readonly tenant = this.tenantService.tenant;
|
||||||
protected readonly user = this.authService.user;
|
protected readonly user = this.authService.user;
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
export interface CartItemProduct {
|
export interface CartItemProduct {
|
||||||
nombre: string;
|
nombre: string;
|
||||||
imagen: string | null;
|
imagen: string | null;
|
||||||
|
variants?: CartItemVariant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CartItemVariant {
|
||||||
|
id: number;
|
||||||
|
precio: string;
|
||||||
|
stock_tecnico: number | null;
|
||||||
|
values: Record<string, string | string[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CartItem {
|
export interface CartItem {
|
||||||
|
|||||||
@@ -50,9 +50,7 @@ export class CartService extends BaseApiService {
|
|||||||
): Observable<ApiResponse<Cart>> {
|
): Observable<ApiResponse<Cart>> {
|
||||||
this.isUpdatingState.set(true);
|
this.isUpdatingState.set(true);
|
||||||
return this.http
|
return this.http
|
||||||
.post<
|
.post<ApiResponse<Cart>>(
|
||||||
ApiResponse<Cart>
|
|
||||||
>(
|
|
||||||
`${this.tenantApiUrl}/cart/items`,
|
`${this.tenantApiUrl}/cart/items`,
|
||||||
{ catalog_item_id: catalogItemId, variant_id: variantId, cantidad },
|
{ catalog_item_id: catalogItemId, variant_id: variantId, cantidad },
|
||||||
{
|
{
|
||||||
@@ -71,21 +69,27 @@ export class CartService extends BaseApiService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateItemQuantity(
|
updateItemQuantity(cartItemId: number, cantidad: number): Observable<ApiResponse<Cart>> {
|
||||||
|
return this.updateItem(cartItemId, { cantidad });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateItemVariant(
|
||||||
cartItemId: number,
|
cartItemId: number,
|
||||||
cantidad: number,
|
cantidad: number,
|
||||||
|
variantId: number,
|
||||||
|
): Observable<ApiResponse<Cart>> {
|
||||||
|
return this.updateItem(cartItemId, { cantidad, variant_id: variantId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateItem(
|
||||||
|
cartItemId: number,
|
||||||
|
payload: { cantidad: number; variant_id?: number },
|
||||||
): Observable<ApiResponse<Cart>> {
|
): Observable<ApiResponse<Cart>> {
|
||||||
this.isUpdatingState.set(true);
|
this.isUpdatingState.set(true);
|
||||||
return this.http
|
return this.http
|
||||||
.patch<
|
.patch<ApiResponse<Cart>>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, payload, {
|
||||||
ApiResponse<Cart>
|
withCredentials: true,
|
||||||
>(
|
})
|
||||||
`${this.tenantApiUrl}/cart/items/${cartItemId}`,
|
|
||||||
{ cantidad },
|
|
||||||
{
|
|
||||||
withCredentials: true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.pipe(
|
.pipe(
|
||||||
tap((response) => {
|
tap((response) => {
|
||||||
this.cartState.set(response.data);
|
this.cartState.set(response.data);
|
||||||
@@ -101,9 +105,7 @@ export class CartService extends BaseApiService {
|
|||||||
removeItem(cartItemId: 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<
|
.delete<ApiResponse<Cart>>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, {
|
||||||
ApiResponse<Cart>
|
|
||||||
>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, {
|
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
<article
|
<article class="w-100 p-3 rounded-0 cart-item" [class.cart-item-with-media]="imageUrl()">
|
||||||
class="w-100 p-3 rounded-0 cart-item"
|
|
||||||
[class.cart-item-with-media]="imageUrl()"
|
|
||||||
>
|
|
||||||
@if (imageUrl()) {
|
@if (imageUrl()) {
|
||||||
<div class="position-relative overflow-hidden cart-item-media">
|
<div class="position-relative overflow-hidden cart-item-media">
|
||||||
@if (discountPercentage() && discountPercentage()! > 0) {
|
@if (discountPercentage() && discountPercentage()! > 0) {
|
||||||
<span class="position-absolute top-0 start-0 z-1 rounded-0 cart-item-discount-badge">-{{ discountPercentage() }}%</span>
|
<span class="position-absolute top-0 start-0 z-1 rounded-0 cart-item-discount-badge"
|
||||||
|
>-{{ discountPercentage() }}%</span
|
||||||
|
>
|
||||||
}
|
}
|
||||||
|
|
||||||
<img class="w-100 h-100 object-fit-cover d-block" [src]="imageUrl()" [alt]="product()" />
|
<img class="w-100 h-100 object-fit-cover d-block" [src]="imageUrl()" [alt]="product()" />
|
||||||
@@ -18,21 +17,33 @@
|
|||||||
|
|
||||||
<div class="text-nowrap cart-item-prices">
|
<div class="text-nowrap cart-item-prices">
|
||||||
@if (formattedOriginalPrice(); as originalPrice) {
|
@if (formattedOriginalPrice(); as originalPrice) {
|
||||||
<span class="text-decoration-line-through mb-1 fw-light cart-item-original-price">{{ originalPrice }}</span>
|
<span class="text-decoration-line-through mb-1 fw-light cart-item-original-price">{{
|
||||||
|
originalPrice
|
||||||
|
}}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
<span class="fw-bold cart-item-discounted-price">{{ formattedDiscountedPrice() }}</span>
|
<span class="fw-bold cart-item-discounted-price">{{ formattedDiscountedPrice() }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-grid cart-item-attributes">
|
@if (hasVariantSelectors() && !quantityDisabled()) {
|
||||||
@for (attribute of attributes(); track attribute.label + attribute.value) {
|
<app-variant-selector
|
||||||
<div class="cart-item-attribute">
|
class="cart-item-variant-selector"
|
||||||
<span class="fw-normal cart-item-attribute-label">{{ attribute.label }}: </span>
|
[variants]="variants()"
|
||||||
<span class="fw-bold">{{ attribute.value }}</span>
|
[selectedVariant]="selectedVariant()"
|
||||||
</div>
|
[compact]="true"
|
||||||
}
|
(selectedVariantChange)="onVariantChange($event)"
|
||||||
</div>
|
/>
|
||||||
|
} @else {
|
||||||
|
<div class="d-grid cart-item-attributes">
|
||||||
|
@for (attribute of attributes(); track attribute.label + attribute.value) {
|
||||||
|
<div class="cart-item-attribute">
|
||||||
|
<span class="fw-normal cart-item-attribute-label">{{ attribute.label }}: </span>
|
||||||
|
<span class="fw-bold">{{ attribute.value }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@if (!readonly()) {
|
@if (!readonly()) {
|
||||||
<div class="d-flex justify-content-end align-items-center mt-auto cart-item-actions">
|
<div class="d-flex justify-content-end align-items-center mt-auto cart-item-actions">
|
||||||
@@ -45,15 +56,9 @@
|
|||||||
(decrease)="onDecrease()"
|
(decrease)="onDecrease()"
|
||||||
/>
|
/>
|
||||||
@if (!quantityDisabled() && showRemove()) {
|
@if (!quantityDisabled() && showRemove()) {
|
||||||
<app-icon-button
|
<app-icon-button variant="trash" class="cart-item-remove-btn" (click)="onRemove()" />
|
||||||
variant="trash"
|
|
||||||
class="cart-item-remove-btn"
|
|
||||||
(click)="onRemove()"
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
:host(:first-child) .cart-item::before,
|
:host(:first-child) .cart-item::before,
|
||||||
:host .cart-item::after {
|
:host .cart-item::after {
|
||||||
content: "";
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: var(--item-divider-inset);
|
right: var(--item-divider-inset);
|
||||||
left: var(--item-divider-inset);
|
left: var(--item-divider-inset);
|
||||||
@@ -119,6 +119,11 @@
|
|||||||
gap: 0.125rem;
|
gap: 0.125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cart-item-variant-selector {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.cart-item-remove-btn {
|
.cart-item-remove-btn {
|
||||||
margin-left: 0.35rem;
|
margin-left: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
|
||||||
import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component';
|
import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component';
|
||||||
import { IconButtonComponent } from '../icon-button/icon-button.component';
|
import { IconButtonComponent } from '../icon-button/icon-button.component';
|
||||||
|
import {
|
||||||
|
VariantSelectorComponent,
|
||||||
|
VariantSelectorVariant,
|
||||||
|
} from '../variant-selector/variant-selector.component';
|
||||||
|
|
||||||
export interface CartItemAttribute {
|
export interface CartItemAttribute {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -10,10 +14,10 @@ export interface CartItemAttribute {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-cart-item',
|
selector: 'app-cart-item',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [QuantitySelectorComponent, IconButtonComponent],
|
imports: [QuantitySelectorComponent, IconButtonComponent, VariantSelectorComponent],
|
||||||
templateUrl: './cart-item.component.html',
|
templateUrl: './cart-item.component.html',
|
||||||
styleUrl: './cart-item.component.scss',
|
styleUrl: './cart-item.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class CartItemComponent {
|
export class CartItemComponent {
|
||||||
readonly imageUrl = input<string | null>(null);
|
readonly imageUrl = input<string | null>(null);
|
||||||
@@ -22,6 +26,8 @@ export class CartItemComponent {
|
|||||||
readonly discountedPrice = input<number>(0);
|
readonly discountedPrice = input<number>(0);
|
||||||
readonly discountPercentage = input<number | null>(null);
|
readonly discountPercentage = input<number | null>(null);
|
||||||
readonly attributes = input<CartItemAttribute[]>([]);
|
readonly attributes = input<CartItemAttribute[]>([]);
|
||||||
|
readonly variants = input<VariantSelectorVariant[]>([]);
|
||||||
|
readonly selectedVariant = input<unknown>(null);
|
||||||
readonly quantity = input<number>(1);
|
readonly quantity = input<number>(1);
|
||||||
readonly readonly = input<boolean>(false);
|
readonly readonly = input<boolean>(false);
|
||||||
readonly quantityDisabled = input<boolean>(false);
|
readonly quantityDisabled = input<boolean>(false);
|
||||||
@@ -31,6 +37,11 @@ export class CartItemComponent {
|
|||||||
readonly remove = output<void>();
|
readonly remove = output<void>();
|
||||||
readonly increase = output<void>();
|
readonly increase = output<void>();
|
||||||
readonly decrease = output<void>();
|
readonly decrease = output<void>();
|
||||||
|
readonly variantChange = output<number>();
|
||||||
|
|
||||||
|
protected readonly hasVariantSelectors = computed(() =>
|
||||||
|
this.variants().some((variant) => Object.keys(variant.values).length > 0),
|
||||||
|
);
|
||||||
|
|
||||||
protected onQuantityChange(newQuantity: number): void {
|
protected onQuantityChange(newQuantity: number): void {
|
||||||
if (this.quantityDisabled()) return;
|
if (this.quantityDisabled()) return;
|
||||||
@@ -49,12 +60,20 @@ export class CartItemComponent {
|
|||||||
this.decrease.emit();
|
this.decrease.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected onVariantChange(variant: unknown): void {
|
||||||
|
if (!this.quantityDisabled() && typeof variant === 'number') {
|
||||||
|
this.variantChange.emit(variant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected readonly formattedOriginalPrice = computed(() => {
|
protected readonly formattedOriginalPrice = computed(() => {
|
||||||
const price = this.originalPrice();
|
const price = this.originalPrice();
|
||||||
return price === null ? null : this.formatCurrency(price);
|
return price === null ? null : this.formatCurrency(price);
|
||||||
});
|
});
|
||||||
|
|
||||||
protected readonly formattedDiscountedPrice = computed(() => this.formatCurrency(this.discountedPrice()));
|
protected readonly formattedDiscountedPrice = computed(() =>
|
||||||
|
this.formatCurrency(this.discountedPrice()),
|
||||||
|
);
|
||||||
|
|
||||||
private formatCurrency(value: number): string {
|
private formatCurrency(value: number): string {
|
||||||
const rounded = Math.round(value);
|
const rounded = Math.round(value);
|
||||||
|
|||||||
@@ -44,11 +44,14 @@
|
|||||||
[discountedPrice]="item.discountedPrice"
|
[discountedPrice]="item.discountedPrice"
|
||||||
[discountPercentage]="item.discountPercentage"
|
[discountPercentage]="item.discountPercentage"
|
||||||
[attributes]="item.attributes"
|
[attributes]="item.attributes"
|
||||||
|
[variants]="item.variants ?? []"
|
||||||
|
[selectedVariant]="getItemVariant(item)"
|
||||||
[quantity]="getItemQuantity(item)"
|
[quantity]="getItemQuantity(item)"
|
||||||
[readonly]="readonly()"
|
[readonly]="readonly()"
|
||||||
[quantityDisabled]="editingDisabled() || (allowEditing() && !editing())"
|
[quantityDisabled]="editingDisabled() || (allowEditing() && !editing())"
|
||||||
[showRemove]="allowRemove()"
|
[showRemove]="allowRemove()"
|
||||||
(quantityChange)="onItemQuantityChange(idx, $event)"
|
(quantityChange)="onItemQuantityChange(idx, $event)"
|
||||||
|
(variantChange)="onItemVariantChange(idx, $event)"
|
||||||
(remove)="onItemRemove(idx)"
|
(remove)="onItemRemove(idx)"
|
||||||
/>
|
/>
|
||||||
} @empty {
|
} @empty {
|
||||||
|
|||||||
@@ -407,4 +407,57 @@ describe('CartComponent', () => {
|
|||||||
fixture.debugElement.query(By.css('app-cart-item')).componentInstance.quantityDisabled(),
|
fixture.debugElement.query(By.css('app-cart-item')).componentInstance.quantityDisabled(),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('persists a variant selected from a cart row', async () => {
|
||||||
|
const updateItemVariant = vi.fn().mockReturnValue(
|
||||||
|
of({
|
||||||
|
message: 'Variante actualizada.',
|
||||||
|
data: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [CartComponent],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: CartService,
|
||||||
|
useValue: {
|
||||||
|
cart: signal(null).asReadonly(),
|
||||||
|
updateItemQuantity: vi.fn(),
|
||||||
|
updateItemVariant,
|
||||||
|
removeItem: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ provide: ModalService, useValue: {} },
|
||||||
|
{
|
||||||
|
provide: ToastService,
|
||||||
|
useValue: { success: vi.fn(), info: vi.fn(), danger: vi.fn() },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(CartComponent);
|
||||||
|
fixture.componentRef.setInput('items', [
|
||||||
|
{
|
||||||
|
cartItemId: 10,
|
||||||
|
imageUrl: null,
|
||||||
|
product: 'Comida',
|
||||||
|
originalPrice: null,
|
||||||
|
discountedPrice: 1000,
|
||||||
|
discountPercentage: null,
|
||||||
|
attributes: [{ label: 'Servicio', value: 'Almuerzo' }],
|
||||||
|
quantity: 2,
|
||||||
|
variantId: 20,
|
||||||
|
variants: [
|
||||||
|
{ value: 20, values: { servicio: 'Almuerzo' } },
|
||||||
|
{ value: 21, values: { servicio: 'Cena' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
fixture.debugElement.query(By.css('app-cart-item')).triggerEventHandler('variantChange', 21);
|
||||||
|
|
||||||
|
expect(updateItemVariant).toHaveBeenCalledWith(10, 2, 21);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { CartItemAttribute, CartItemComponent } from '../cart-item/cart-item.com
|
|||||||
import { ModalService } from '../../../core/services/modal.service';
|
import { ModalService } from '../../../core/services/modal.service';
|
||||||
import { CartService } from '../../../core/services/cart/cart.service';
|
import { CartService } from '../../../core/services/cart/cart.service';
|
||||||
import { ToastService } from '../../../core/services/toast.service';
|
import { ToastService } from '../../../core/services/toast.service';
|
||||||
|
import { VariantSelectorVariant } from '../variant-selector/variant-selector.component';
|
||||||
|
|
||||||
export interface CartItemMock {
|
export interface CartItemMock {
|
||||||
cartItemId?: number;
|
cartItemId?: number;
|
||||||
@@ -26,6 +27,8 @@ export interface CartItemMock {
|
|||||||
discountPercentage: number | null;
|
discountPercentage: number | null;
|
||||||
attributes: CartItemAttribute[];
|
attributes: CartItemAttribute[];
|
||||||
quantity: number;
|
quantity: number;
|
||||||
|
variantId?: number | null;
|
||||||
|
variants?: VariantSelectorVariant[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -64,6 +67,7 @@ export class CartComponent {
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
protected readonly quantityOverrides = signal<Record<number, number>>({});
|
protected readonly quantityOverrides = signal<Record<number, number>>({});
|
||||||
|
protected readonly variantOverrides = signal<Record<number, number>>({});
|
||||||
constructor() {
|
constructor() {
|
||||||
this.quantityUpdates$
|
this.quantityUpdates$
|
||||||
.pipe(
|
.pipe(
|
||||||
@@ -104,6 +108,13 @@ export class CartComponent {
|
|||||||
return item.quantity;
|
return item.quantity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected getItemVariant(item: CartItemMock): number | null {
|
||||||
|
if (item.cartItemId !== undefined && this.variantOverrides()[item.cartItemId] !== undefined) {
|
||||||
|
return this.variantOverrides()[item.cartItemId];
|
||||||
|
}
|
||||||
|
return item.variantId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
private clearOverride(cartItemId: number): void {
|
private clearOverride(cartItemId: number): void {
|
||||||
this.quantityOverrides.update((overrides) => {
|
this.quantityOverrides.update((overrides) => {
|
||||||
const copy = { ...overrides };
|
const copy = { ...overrides };
|
||||||
@@ -153,6 +164,36 @@ export class CartComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected onItemVariantChange(index: number, variantId: number): void {
|
||||||
|
const item = this.items()[index];
|
||||||
|
const cartItemId = item?.cartItemId;
|
||||||
|
|
||||||
|
if (!item || !cartItemId || variantId === this.getItemVariant(item)) return;
|
||||||
|
|
||||||
|
this.variantOverrides.update((overrides) => ({ ...overrides, [cartItemId]: variantId }));
|
||||||
|
this.cartService
|
||||||
|
.updateItemVariant(cartItemId, this.getItemQuantity(item), variantId)
|
||||||
|
.subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.clearVariantOverride(cartItemId);
|
||||||
|
this.toastService.success(response.message || 'Variante actualizada.');
|
||||||
|
},
|
||||||
|
error: (error: HttpErrorResponse) => {
|
||||||
|
console.error('Error updating cart item variant', error);
|
||||||
|
this.clearVariantOverride(cartItemId);
|
||||||
|
this.toastService.danger(error.error?.message || 'No se pudo actualizar la variante.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearVariantOverride(cartItemId: number): void {
|
||||||
|
this.variantOverrides.update((overrides) => {
|
||||||
|
const copy = { ...overrides };
|
||||||
|
delete copy[cartItemId];
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected onItemRemove(index: number): void {
|
protected onItemRemove(index: number): void {
|
||||||
const target = this.resolveRemoveTarget(index);
|
const target = this.resolveRemoveTarget(index);
|
||||||
|
|
||||||
|
|||||||
@@ -27,20 +27,11 @@
|
|||||||
|
|
||||||
<!-- Bottom Row: Attribute selects, Quantity, and Agregar al carrito button -->
|
<!-- Bottom Row: Attribute selects, Quantity, and Agregar al carrito button -->
|
||||||
<div class="d-flex align-items-center justify-content-end gap-2">
|
<div class="d-flex align-items-center justify-content-end gap-2">
|
||||||
<div class="product-row-card__selectors">
|
<app-variant-selector
|
||||||
@for (selector of variantSelectors(); track selector.key) {
|
class="product-row-card__selectors"
|
||||||
<select
|
[variants]="variants()"
|
||||||
class="form-select product-row-card__select"
|
[(selectedVariant)]="selectedVariant"
|
||||||
[attr.aria-label]="selector.label"
|
/>
|
||||||
[ngModel]="selectedValues()[selector.key]"
|
|
||||||
(ngModelChange)="onVariantValueChange(selector.key, $event)"
|
|
||||||
>
|
|
||||||
@for (option of selector.options; track option.key) {
|
|
||||||
<option [ngValue]="option.value">{{ option.label }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<app-quantity-selector [(quantity)]="quantity"></app-quantity-selector>
|
<app-quantity-selector [(quantity)]="quantity"></app-quantity-selector>
|
||||||
|
|
||||||
|
|||||||
@@ -31,28 +31,6 @@
|
|||||||
color: var(--tenant-primary, #009933) !important; /* Green matching screenshot */
|
color: var(--tenant-primary, #009933) !important; /* Green matching screenshot */
|
||||||
}
|
}
|
||||||
|
|
||||||
&__selectors {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__select {
|
|
||||||
width: auto;
|
|
||||||
min-width: 120px;
|
|
||||||
font-size: 14px;
|
|
||||||
height: 38px;
|
|
||||||
color: #666;
|
|
||||||
border-color: #ccc;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border-color: var(--tenant-primary);
|
|
||||||
box-shadow: 0 0 0 0.25rem rgba(0, 153, 51, 0.25);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__btn-wrapper {
|
&__btn-wrapper {
|
||||||
min-width: 160px; /* To make both buttons equal width as in screenshot */
|
min-width: 160px; /* To make both buttons equal width as in screenshot */
|
||||||
|
|
||||||
|
|||||||
@@ -1,44 +1,21 @@
|
|||||||
import {
|
import { ChangeDetectionStrategy, Component, computed, input, model, output } from '@angular/core';
|
||||||
ChangeDetectionStrategy,
|
|
||||||
Component,
|
|
||||||
computed,
|
|
||||||
effect,
|
|
||||||
input,
|
|
||||||
model,
|
|
||||||
output,
|
|
||||||
signal,
|
|
||||||
untracked,
|
|
||||||
} from '@angular/core';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { ButtonComponent } from '../button/button.component';
|
import { ButtonComponent } from '../button/button.component';
|
||||||
import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component';
|
import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component';
|
||||||
|
import {
|
||||||
|
VariantSelectorComponent,
|
||||||
|
VariantSelectorVariant,
|
||||||
|
} from '../variant-selector/variant-selector.component';
|
||||||
|
|
||||||
export interface Variant {
|
export interface Variant extends VariantSelectorVariant {
|
||||||
label?: string;
|
label?: string;
|
||||||
value: unknown;
|
|
||||||
descripcion?: string | null;
|
descripcion?: string | null;
|
||||||
precio?: string | number;
|
precio?: string | number;
|
||||||
values: Record<string, string | string[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
type VariantAttributeValue = string | string[];
|
|
||||||
|
|
||||||
interface VariantSelectorOption {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
value: VariantAttributeValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VariantSelector {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
options: VariantSelectorOption[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-product-row-card',
|
selector: 'app-product-row-card',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [ButtonComponent, QuantitySelectorComponent, FormsModule],
|
imports: [ButtonComponent, QuantitySelectorComponent, VariantSelectorComponent],
|
||||||
templateUrl: './product-row-card.component.html',
|
templateUrl: './product-row-card.component.html',
|
||||||
styleUrl: './product-row-card.component.scss',
|
styleUrl: './product-row-card.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
@@ -58,51 +35,6 @@ export class ProductRowCardComponent {
|
|||||||
readonly buy = output<{ quantity: number; variant: unknown }>();
|
readonly buy = output<{ quantity: number; variant: unknown }>();
|
||||||
readonly addToCart = output<{ quantity: number; variant: unknown }>();
|
readonly addToCart = output<{ quantity: number; variant: unknown }>();
|
||||||
|
|
||||||
protected readonly selectedValues = signal<Record<string, VariantAttributeValue>>({});
|
|
||||||
protected readonly attributeKeys = computed(() =>
|
|
||||||
Array.from(new Set(this.variants().flatMap((variant) => Object.keys(variant.values)))),
|
|
||||||
);
|
|
||||||
protected readonly variantSelectors = computed<VariantSelector[]>(() => {
|
|
||||||
const variants = this.variants();
|
|
||||||
const keys = this.attributeKeys();
|
|
||||||
const selectedValues = this.selectedValues();
|
|
||||||
|
|
||||||
return keys.map((key, index) => {
|
|
||||||
const previousKeys = keys.slice(0, index);
|
|
||||||
const compatibleVariants = variants.filter((variant) =>
|
|
||||||
previousKeys.every((previousKey) =>
|
|
||||||
this.sameValue(variant.values[previousKey], selectedValues[previousKey]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
label: this.formatVariantLabel(key),
|
|
||||||
options: this.optionsFor(compatibleVariants, key),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
effect(() => {
|
|
||||||
const variants = this.variants();
|
|
||||||
const selectedVariant = this.selectedVariant();
|
|
||||||
|
|
||||||
untracked(() => {
|
|
||||||
if (variants.length === 0) {
|
|
||||||
this.selectedValues.set({});
|
|
||||||
this.selectedVariant.set(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selected =
|
|
||||||
variants.find((variant) => Object.is(variant.value, selectedVariant)) ?? variants[0];
|
|
||||||
this.selectedValues.set({ ...selected.values });
|
|
||||||
this.selectedVariant.set(selected.value);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected readonly selectedVariantData = computed(() =>
|
protected readonly selectedVariantData = computed(() =>
|
||||||
this.variants().find((variant) => Object.is(variant.value, this.selectedVariant())),
|
this.variants().find((variant) => Object.is(variant.value, this.selectedVariant())),
|
||||||
);
|
);
|
||||||
@@ -117,42 +49,6 @@ export class ProductRowCardComponent {
|
|||||||
|
|
||||||
readonly formattedPrice = computed(() => this.formatCurrency(this.effectivePrice()));
|
readonly formattedPrice = computed(() => this.formatCurrency(this.effectivePrice()));
|
||||||
|
|
||||||
protected onVariantValueChange(key: string, value: VariantAttributeValue): void {
|
|
||||||
const variants = this.variants();
|
|
||||||
const keys = this.attributeKeys();
|
|
||||||
const changedIndex = keys.indexOf(key);
|
|
||||||
const values = { ...this.selectedValues(), [key]: value };
|
|
||||||
|
|
||||||
for (let index = changedIndex + 1; index < keys.length; index++) {
|
|
||||||
const currentKey = keys[index];
|
|
||||||
const previousKeys = keys.slice(0, index);
|
|
||||||
const compatibleVariants = variants.filter((variant) =>
|
|
||||||
previousKeys.every((previousKey) =>
|
|
||||||
this.sameValue(variant.values[previousKey], values[previousKey]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const options = this.optionsFor(compatibleVariants, currentKey);
|
|
||||||
|
|
||||||
if (!options.some((option) => this.sameValue(option.value, values[currentKey]))) {
|
|
||||||
const firstOption = options[0];
|
|
||||||
if (firstOption) {
|
|
||||||
values[currentKey] = firstOption.value;
|
|
||||||
} else {
|
|
||||||
delete values[currentKey];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const matchingVariant = variants.find((variant) =>
|
|
||||||
keys.every((attributeKey) =>
|
|
||||||
this.sameValue(variant.values[attributeKey], values[attributeKey]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.selectedValues.set(values);
|
|
||||||
this.selectedVariant.set(matchingVariant?.value ?? null);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onAddToCart(): void {
|
protected onAddToCart(): void {
|
||||||
this.addToCart.emit({
|
this.addToCart.emit({
|
||||||
quantity: this.quantity(),
|
quantity: this.quantity(),
|
||||||
@@ -167,46 +63,6 @@ export class ProductRowCardComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private optionsFor(variants: Variant[], key: string): VariantSelectorOption[] {
|
|
||||||
const options = new Map<string, VariantSelectorOption>();
|
|
||||||
|
|
||||||
for (const variant of variants) {
|
|
||||||
const value = variant.values[key];
|
|
||||||
if (value === undefined || value === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const optionKey = this.valueKey(value);
|
|
||||||
if (!options.has(optionKey)) {
|
|
||||||
options.set(optionKey, {
|
|
||||||
key: optionKey,
|
|
||||||
label: Array.isArray(value) ? value.join(', ') : value,
|
|
||||||
value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(options.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
private sameValue(
|
|
||||||
left: VariantAttributeValue | undefined,
|
|
||||||
right: VariantAttributeValue | undefined,
|
|
||||||
): boolean {
|
|
||||||
return (
|
|
||||||
left !== undefined && right !== undefined && this.valueKey(left) === this.valueKey(right)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private valueKey(value: VariantAttributeValue): string {
|
|
||||||
return JSON.stringify(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatVariantLabel(key: string): string {
|
|
||||||
const label = key.replace(/[_-]+/g, ' ');
|
|
||||||
return label.charAt(0).toUpperCase() + label.slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper method to format currency numbers to Argentine Pesos style "$ XX.XXX".
|
* Helper method to format currency numbers to Argentine Pesos style "$ XX.XXX".
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
width: 78px;
|
width: 78px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
background: inherit;
|
background-color: #ffffff;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
&__button {
|
&__button {
|
||||||
@@ -15,8 +15,10 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
color: #6f6f6f;
|
color: #6f6f6f;
|
||||||
background: inherit;
|
background-color: #ffffff;
|
||||||
transition: background-color 0.2s ease, color 0.2s ease;
|
transition:
|
||||||
|
background-color 0.2s ease,
|
||||||
|
color 0.2s ease;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:not(:disabled):hover {
|
&:not(:disabled):hover {
|
||||||
@@ -25,7 +27,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
color: #A0A0A0;
|
color: #a0a0a0;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
@@ -41,12 +43,12 @@
|
|||||||
height: 21px;
|
height: 21px;
|
||||||
min-height: 21px;
|
min-height: 21px;
|
||||||
border: 1px solid #cfcfcf;
|
border: 1px solid #cfcfcf;
|
||||||
background: inherit;
|
background-color: #ffffff;
|
||||||
|
|
||||||
.quantity-selector__button {
|
.quantity-selector__button {
|
||||||
width: 15px;
|
width: 15px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
background: inherit;
|
background-color: #ffffff;
|
||||||
color: #666666;
|
color: #666666;
|
||||||
|
|
||||||
&:not(:disabled):hover {
|
&:not(:disabled):hover {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
@if (selectors().length > 0) {
|
||||||
|
<div class="variant-selector" [class.variant-selector--compact]="compact()">
|
||||||
|
@for (selector of selectors(); track selector.key) {
|
||||||
|
<select
|
||||||
|
class="form-select variant-selector__select"
|
||||||
|
[attr.aria-label]="selector.label"
|
||||||
|
[disabled]="disabled()"
|
||||||
|
[ngModel]="selectedValues()[selector.key]"
|
||||||
|
(ngModelChange)="onValueChange(selector.key, $event)"
|
||||||
|
>
|
||||||
|
@for (option of selector.options; track option.key) {
|
||||||
|
<option [ngValue]="option.value">{{ option.label }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-selector__select {
|
||||||
|
width: auto;
|
||||||
|
min-width: 120px;
|
||||||
|
height: 38px;
|
||||||
|
color: #666666;
|
||||||
|
border-color: #cccccc;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--tenant-primary);
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(0, 153, 51, 0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-selector--compact {
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.variant-selector__select {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 150px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0.2rem 1.75rem 0.2rem 0.45rem;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import '@angular/compiler';
|
||||||
|
import { TestBed, getTestBed } from '@angular/core/testing';
|
||||||
|
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
|
||||||
|
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { VariantSelectorComponent } from './variant-selector.component';
|
||||||
|
|
||||||
|
describe('VariantSelectorComponent', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
try {
|
||||||
|
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
|
||||||
|
} catch {
|
||||||
|
// Test environment may already be initialized by another setup entrypoint.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => TestBed.resetTestingModule());
|
||||||
|
|
||||||
|
it('updates following selections to the first compatible variant', async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [VariantSelectorComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
const fixture = TestBed.createComponent(VariantSelectorComponent);
|
||||||
|
fixture.componentRef.setInput('variants', [
|
||||||
|
{ value: 1, values: { alojamiento: 'Carpa', servicio: 'Almuerzo' } },
|
||||||
|
{ value: 2, values: { alojamiento: 'Carpa', servicio: 'Cena' } },
|
||||||
|
{ value: 3, values: { alojamiento: 'Hotel', servicio: 'Cena' } },
|
||||||
|
]);
|
||||||
|
fixture.componentRef.setInput('selectedVariant', 1);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
(fixture.componentInstance as any).onValueChange('alojamiento', 'Hotel');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.selectedVariant()).toBe(3);
|
||||||
|
expect((fixture.componentInstance as any).selectedValues()).toEqual({
|
||||||
|
alojamiento: 'Hotel',
|
||||||
|
servicio: 'Cena',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
|
input,
|
||||||
|
model,
|
||||||
|
signal,
|
||||||
|
untracked,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
|
||||||
|
export type VariantAttributeValue = string | string[];
|
||||||
|
|
||||||
|
export interface VariantSelectorVariant {
|
||||||
|
value: unknown;
|
||||||
|
values: Record<string, VariantAttributeValue>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VariantSelectorOption {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
value: VariantAttributeValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VariantSelectorGroup {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
options: VariantSelectorOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-variant-selector',
|
||||||
|
standalone: true,
|
||||||
|
imports: [FormsModule],
|
||||||
|
templateUrl: './variant-selector.component.html',
|
||||||
|
styleUrl: './variant-selector.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class VariantSelectorComponent {
|
||||||
|
readonly variants = input<VariantSelectorVariant[]>([]);
|
||||||
|
readonly selectedVariant = model<unknown>(null);
|
||||||
|
readonly disabled = input(false);
|
||||||
|
readonly compact = input(false);
|
||||||
|
|
||||||
|
protected readonly selectedValues = signal<Record<string, VariantAttributeValue>>({});
|
||||||
|
protected readonly attributeKeys = computed(() =>
|
||||||
|
Array.from(new Set(this.variants().flatMap((variant) => Object.keys(variant.values)))),
|
||||||
|
);
|
||||||
|
protected readonly selectors = computed<VariantSelectorGroup[]>(() => {
|
||||||
|
const variants = this.variants();
|
||||||
|
const keys = this.attributeKeys();
|
||||||
|
const selectedValues = this.selectedValues();
|
||||||
|
|
||||||
|
return keys.map((key, index) => {
|
||||||
|
const previousKeys = keys.slice(0, index);
|
||||||
|
const compatibleVariants = variants.filter((variant) =>
|
||||||
|
previousKeys.every((previousKey) =>
|
||||||
|
this.sameValue(variant.values[previousKey], selectedValues[previousKey]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label: this.formatVariantLabel(key),
|
||||||
|
options: this.optionsFor(compatibleVariants, key),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const variants = this.variants();
|
||||||
|
const selectedVariant = this.selectedVariant();
|
||||||
|
|
||||||
|
untracked(() => {
|
||||||
|
if (variants.length === 0) {
|
||||||
|
this.selectedValues.set({});
|
||||||
|
if (selectedVariant !== null) this.selectedVariant.set(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected =
|
||||||
|
variants.find((variant) => Object.is(variant.value, selectedVariant)) ?? variants[0];
|
||||||
|
this.selectedValues.set({ ...selected.values });
|
||||||
|
if (!Object.is(selected.value, selectedVariant)) this.selectedVariant.set(selected.value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onValueChange(key: string, value: VariantAttributeValue): void {
|
||||||
|
const variants = this.variants();
|
||||||
|
const keys = this.attributeKeys();
|
||||||
|
const changedIndex = keys.indexOf(key);
|
||||||
|
const values = { ...this.selectedValues(), [key]: value };
|
||||||
|
|
||||||
|
for (let index = changedIndex + 1; index < keys.length; index++) {
|
||||||
|
const currentKey = keys[index];
|
||||||
|
const previousKeys = keys.slice(0, index);
|
||||||
|
const compatibleVariants = variants.filter((variant) =>
|
||||||
|
previousKeys.every((previousKey) =>
|
||||||
|
this.sameValue(variant.values[previousKey], values[previousKey]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const options = this.optionsFor(compatibleVariants, currentKey);
|
||||||
|
|
||||||
|
if (!options.some((option) => this.sameValue(option.value, values[currentKey]))) {
|
||||||
|
const firstOption = options[0];
|
||||||
|
if (firstOption) values[currentKey] = firstOption.value;
|
||||||
|
else delete values[currentKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingVariant = variants.find((variant) =>
|
||||||
|
keys.every((attributeKey) =>
|
||||||
|
this.sameValue(variant.values[attributeKey], values[attributeKey]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.selectedValues.set(values);
|
||||||
|
this.selectedVariant.set(matchingVariant?.value ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private optionsFor(variants: VariantSelectorVariant[], key: string): VariantSelectorOption[] {
|
||||||
|
const options = new Map<string, VariantSelectorOption>();
|
||||||
|
|
||||||
|
for (const variant of variants) {
|
||||||
|
const value = variant.values[key];
|
||||||
|
if (value === undefined || value === '') continue;
|
||||||
|
|
||||||
|
const optionKey = this.valueKey(value);
|
||||||
|
if (!options.has(optionKey)) {
|
||||||
|
options.set(optionKey, {
|
||||||
|
key: optionKey,
|
||||||
|
label: Array.isArray(value) ? value.join(', ') : value,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(options.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
private sameValue(
|
||||||
|
left: VariantAttributeValue | undefined,
|
||||||
|
right: VariantAttributeValue | undefined,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
left !== undefined && right !== undefined && this.valueKey(left) === this.valueKey(right)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private valueKey(value: VariantAttributeValue): string {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatVariantLabel(key: string): string {
|
||||||
|
const label = key.replace(/[_-]+/g, ' ');
|
||||||
|
return label.charAt(0).toUpperCase() + label.slice(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user