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 {
|
||||
cartItemId: item.id,
|
||||
imageUrl: item.product?.imagen ?? null,
|
||||
@@ -78,9 +93,16 @@ export class StoreLayoutComponent implements OnInit {
|
||||
discountPercentage: null,
|
||||
attributes,
|
||||
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 tenant = this.tenantService.tenant;
|
||||
protected readonly user = this.authService.user;
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
export interface CartItemProduct {
|
||||
nombre: string;
|
||||
imagen: string | null;
|
||||
variants?: CartItemVariant[];
|
||||
}
|
||||
|
||||
export interface CartItemVariant {
|
||||
id: number;
|
||||
precio: string;
|
||||
stock_tecnico: number | null;
|
||||
values: Record<string, string | string[]>;
|
||||
}
|
||||
|
||||
export interface CartItem {
|
||||
|
||||
@@ -50,9 +50,7 @@ export class CartService extends BaseApiService {
|
||||
): Observable<ApiResponse<Cart>> {
|
||||
this.isUpdatingState.set(true);
|
||||
return this.http
|
||||
.post<
|
||||
ApiResponse<Cart>
|
||||
>(
|
||||
.post<ApiResponse<Cart>>(
|
||||
`${this.tenantApiUrl}/cart/items`,
|
||||
{ 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,
|
||||
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>> {
|
||||
this.isUpdatingState.set(true);
|
||||
return this.http
|
||||
.patch<
|
||||
ApiResponse<Cart>
|
||||
>(
|
||||
`${this.tenantApiUrl}/cart/items/${cartItemId}`,
|
||||
{ cantidad },
|
||||
{
|
||||
withCredentials: true,
|
||||
},
|
||||
)
|
||||
.patch<ApiResponse<Cart>>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, payload, {
|
||||
withCredentials: true,
|
||||
})
|
||||
.pipe(
|
||||
tap((response) => {
|
||||
this.cartState.set(response.data);
|
||||
@@ -101,9 +105,7 @@ export class CartService extends BaseApiService {
|
||||
removeItem(cartItemId: number): Observable<ApiResponse<Cart>> {
|
||||
this.isUpdatingState.set(true);
|
||||
return this.http
|
||||
.delete<
|
||||
ApiResponse<Cart>
|
||||
>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, {
|
||||
.delete<ApiResponse<Cart>>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, {
|
||||
withCredentials: true,
|
||||
})
|
||||
.pipe(
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<article
|
||||
class="w-100 p-3 rounded-0 cart-item"
|
||||
[class.cart-item-with-media]="imageUrl()"
|
||||
>
|
||||
<article class="w-100 p-3 rounded-0 cart-item" [class.cart-item-with-media]="imageUrl()">
|
||||
@if (imageUrl()) {
|
||||
<div class="position-relative overflow-hidden cart-item-media">
|
||||
@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()" />
|
||||
@@ -18,21 +17,33 @@
|
||||
|
||||
<div class="text-nowrap cart-item-prices">
|
||||
@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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 (hasVariantSelectors() && !quantityDisabled()) {
|
||||
<app-variant-selector
|
||||
class="cart-item-variant-selector"
|
||||
[variants]="variants()"
|
||||
[selectedVariant]="selectedVariant()"
|
||||
[compact]="true"
|
||||
(selectedVariantChange)="onVariantChange($event)"
|
||||
/>
|
||||
} @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()) {
|
||||
<div class="d-flex justify-content-end align-items-center mt-auto cart-item-actions">
|
||||
@@ -45,15 +56,9 @@
|
||||
(decrease)="onDecrease()"
|
||||
/>
|
||||
@if (!quantityDisabled() && showRemove()) {
|
||||
<app-icon-button
|
||||
variant="trash"
|
||||
class="cart-item-remove-btn"
|
||||
(click)="onRemove()"
|
||||
/>
|
||||
<app-icon-button variant="trash" class="cart-item-remove-btn" (click)="onRemove()" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
:host(:first-child) .cart-item::before,
|
||||
:host .cart-item::after {
|
||||
content: "";
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: var(--item-divider-inset);
|
||||
left: var(--item-divider-inset);
|
||||
@@ -119,6 +119,11 @@
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.cart-item-variant-selector {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.cart-item-remove-btn {
|
||||
margin-left: 0.35rem;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
|
||||
import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component';
|
||||
import { IconButtonComponent } from '../icon-button/icon-button.component';
|
||||
import {
|
||||
VariantSelectorComponent,
|
||||
VariantSelectorVariant,
|
||||
} from '../variant-selector/variant-selector.component';
|
||||
|
||||
export interface CartItemAttribute {
|
||||
label: string;
|
||||
@@ -10,10 +14,10 @@ export interface CartItemAttribute {
|
||||
@Component({
|
||||
selector: 'app-cart-item',
|
||||
standalone: true,
|
||||
imports: [QuantitySelectorComponent, IconButtonComponent],
|
||||
imports: [QuantitySelectorComponent, IconButtonComponent, VariantSelectorComponent],
|
||||
templateUrl: './cart-item.component.html',
|
||||
styleUrl: './cart-item.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class CartItemComponent {
|
||||
readonly imageUrl = input<string | null>(null);
|
||||
@@ -22,6 +26,8 @@ export class CartItemComponent {
|
||||
readonly discountedPrice = input<number>(0);
|
||||
readonly discountPercentage = input<number | null>(null);
|
||||
readonly attributes = input<CartItemAttribute[]>([]);
|
||||
readonly variants = input<VariantSelectorVariant[]>([]);
|
||||
readonly selectedVariant = input<unknown>(null);
|
||||
readonly quantity = input<number>(1);
|
||||
readonly readonly = input<boolean>(false);
|
||||
readonly quantityDisabled = input<boolean>(false);
|
||||
@@ -31,6 +37,11 @@ export class CartItemComponent {
|
||||
readonly remove = output<void>();
|
||||
readonly increase = 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 {
|
||||
if (this.quantityDisabled()) return;
|
||||
@@ -49,12 +60,20 @@ export class CartItemComponent {
|
||||
this.decrease.emit();
|
||||
}
|
||||
|
||||
protected onVariantChange(variant: unknown): void {
|
||||
if (!this.quantityDisabled() && typeof variant === 'number') {
|
||||
this.variantChange.emit(variant);
|
||||
}
|
||||
}
|
||||
|
||||
protected readonly formattedOriginalPrice = computed(() => {
|
||||
const price = this.originalPrice();
|
||||
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 {
|
||||
const rounded = Math.round(value);
|
||||
|
||||
@@ -44,11 +44,14 @@
|
||||
[discountedPrice]="item.discountedPrice"
|
||||
[discountPercentage]="item.discountPercentage"
|
||||
[attributes]="item.attributes"
|
||||
[variants]="item.variants ?? []"
|
||||
[selectedVariant]="getItemVariant(item)"
|
||||
[quantity]="getItemQuantity(item)"
|
||||
[readonly]="readonly()"
|
||||
[quantityDisabled]="editingDisabled() || (allowEditing() && !editing())"
|
||||
[showRemove]="allowRemove()"
|
||||
(quantityChange)="onItemQuantityChange(idx, $event)"
|
||||
(variantChange)="onItemVariantChange(idx, $event)"
|
||||
(remove)="onItemRemove(idx)"
|
||||
/>
|
||||
} @empty {
|
||||
|
||||
@@ -407,4 +407,57 @@ describe('CartComponent', () => {
|
||||
fixture.debugElement.query(By.css('app-cart-item')).componentInstance.quantityDisabled(),
|
||||
).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 { CartService } from '../../../core/services/cart/cart.service';
|
||||
import { ToastService } from '../../../core/services/toast.service';
|
||||
import { VariantSelectorVariant } from '../variant-selector/variant-selector.component';
|
||||
|
||||
export interface CartItemMock {
|
||||
cartItemId?: number;
|
||||
@@ -26,6 +27,8 @@ export interface CartItemMock {
|
||||
discountPercentage: number | null;
|
||||
attributes: CartItemAttribute[];
|
||||
quantity: number;
|
||||
variantId?: number | null;
|
||||
variants?: VariantSelectorVariant[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -64,6 +67,7 @@ export class CartComponent {
|
||||
}>();
|
||||
|
||||
protected readonly quantityOverrides = signal<Record<number, number>>({});
|
||||
protected readonly variantOverrides = signal<Record<number, number>>({});
|
||||
constructor() {
|
||||
this.quantityUpdates$
|
||||
.pipe(
|
||||
@@ -104,6 +108,13 @@ export class CartComponent {
|
||||
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 {
|
||||
this.quantityOverrides.update((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 {
|
||||
const target = this.resolveRemoveTarget(index);
|
||||
|
||||
|
||||
@@ -27,20 +27,11 @@
|
||||
|
||||
<!-- Bottom Row: Attribute selects, Quantity, and Agregar al carrito button -->
|
||||
<div class="d-flex align-items-center justify-content-end gap-2">
|
||||
<div class="product-row-card__selectors">
|
||||
@for (selector of variantSelectors(); track selector.key) {
|
||||
<select
|
||||
class="form-select product-row-card__select"
|
||||
[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-variant-selector
|
||||
class="product-row-card__selectors"
|
||||
[variants]="variants()"
|
||||
[(selectedVariant)]="selectedVariant"
|
||||
/>
|
||||
|
||||
<app-quantity-selector [(quantity)]="quantity"></app-quantity-selector>
|
||||
|
||||
|
||||
@@ -31,28 +31,6 @@
|
||||
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 {
|
||||
min-width: 160px; /* To make both buttons equal width as in screenshot */
|
||||
|
||||
|
||||
@@ -1,44 +1,21 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
input,
|
||||
model,
|
||||
output,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ChangeDetectionStrategy, Component, computed, input, model, output } from '@angular/core';
|
||||
import { ButtonComponent } from '../button/button.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;
|
||||
value: unknown;
|
||||
descripcion?: string | null;
|
||||
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({
|
||||
selector: 'app-product-row-card',
|
||||
standalone: true,
|
||||
imports: [ButtonComponent, QuantitySelectorComponent, FormsModule],
|
||||
imports: [ButtonComponent, QuantitySelectorComponent, VariantSelectorComponent],
|
||||
templateUrl: './product-row-card.component.html',
|
||||
styleUrl: './product-row-card.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@@ -58,51 +35,6 @@ export class ProductRowCardComponent {
|
||||
readonly buy = 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(() =>
|
||||
this.variants().find((variant) => Object.is(variant.value, this.selectedVariant())),
|
||||
);
|
||||
@@ -117,42 +49,6 @@ export class ProductRowCardComponent {
|
||||
|
||||
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 {
|
||||
this.addToCart.emit({
|
||||
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".
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
width: 78px;
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
background: inherit;
|
||||
background-color: #ffffff;
|
||||
box-sizing: border-box;
|
||||
|
||||
&__button {
|
||||
@@ -15,8 +15,10 @@
|
||||
height: 100%;
|
||||
font-size: 20px;
|
||||
color: #6f6f6f;
|
||||
background: inherit;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
background-color: #ffffff;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
color 0.2s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:not(:disabled):hover {
|
||||
@@ -25,7 +27,7 @@
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: #A0A0A0;
|
||||
color: #a0a0a0;
|
||||
opacity: 1;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -41,12 +43,12 @@
|
||||
height: 21px;
|
||||
min-height: 21px;
|
||||
border: 1px solid #cfcfcf;
|
||||
background: inherit;
|
||||
background-color: #ffffff;
|
||||
|
||||
.quantity-selector__button {
|
||||
width: 15px;
|
||||
font-size: 10px;
|
||||
background: inherit;
|
||||
background-color: #ffffff;
|
||||
color: #666666;
|
||||
|
||||
&: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