Compare commits

..

2 Commits

10 changed files with 98 additions and 174 deletions

View File

@@ -6,13 +6,12 @@ import { StoreFooterComponent, StoreFooterSection } from './store-footer/store-f
import { StoreHeaderComponent } from './store-header/store-header.component';
import { CartComponent, CartItemMock } from '../../../shared/components/cart/cart.component';
import { ButtonComponent } from '../../../shared/components/button/button.component';
import { CartItem } from '../../services/cart/cart.interface';
import { CartItem, CartItemVariantValue } from '../../services/cart/cart.interface';
import { AuthService } from '../../services/auth/auth.service';
import { findMenu } from '../../services/menu.utils';
import { CheckoutService } from '../../services/checkout.service';
import { ToastService } from '../../services/toast.service';
import { Category } from '../../services/tenant.interface';
import { VariantAttributeValue } from '../../services/catalog/catalog.interface';
@Component({
selector: 'app-store-layout',
@@ -81,7 +80,7 @@ export class StoreLayoutComponent implements OnInit {
if (selectedVariant) {
attributes = Object.entries(selectedVariant.values).map(([label, value]) => ({
label: this.formatAttributeLabel(label),
value: this.variantAttributeLabel(value),
value: this.formatAttributeValue(value),
}));
}
@@ -104,12 +103,9 @@ export class StoreLayoutComponent implements OnInit {
return label.charAt(0).toUpperCase() + label.slice(1);
}
private variantAttributeLabel(value: VariantAttributeValue): string {
if (typeof value === 'string') return value;
if (Array.isArray(value)) {
return value.map((item) => (typeof item === 'string' ? item : item.label)).join(', ');
}
return value.label;
private formatAttributeValue(value: CartItemVariantValue): string {
const values = Array.isArray(value) ? value : [value];
return values.map((item) => (typeof item === 'string' ? item : item.label)).join(', ');
}
protected readonly currentYear = new Date().getFullYear();

View File

@@ -1,16 +1,24 @@
import { VariantAttributeValue } from '../catalog/catalog.interface';
export interface CartItemProduct {
nombre: string;
imagen: string | null;
variants?: CartItemVariant[];
}
export interface CartItemVariantOption {
value: string;
label: string;
}
export type CartItemVariantValue =
| string
| CartItemVariantOption
| Array<string | CartItemVariantOption>;
export interface CartItemVariant {
id: number;
precio: string;
stock_tecnico: number | null;
values: Record<string, VariantAttributeValue>;
values: Record<string, CartItemVariantValue>;
}
export interface CartItem {

View File

@@ -47,17 +47,6 @@ export interface ProductAttribute {
export type InventoryPolicy = 'tracked' | 'unlimited';
export interface VariantAttributeOptionValue {
value: string;
label: string;
}
export type VariantAttributeValue =
| VariantAttributeOptionValue
| VariantAttributeOptionValue[]
| string
| string[];
export interface CatalogItemVariant {
id: number;
descripcion?: string | null;
@@ -70,7 +59,7 @@ export interface CatalogItemVariant {
maximum_use_date?: string | null;
effective_minimum_use_date?: string | null;
effective_maximum_use_date?: string | null;
values: Record<string, VariantAttributeValue>;
values: Record<string, string | string[]>;
}
export interface SelectedCatalogItemVariant extends CatalogItemVariant {
@@ -109,7 +98,7 @@ export interface CatalogFeaturedItemVariant {
descripcion?: string | null;
precio?: string;
stock_tecnico: number | null;
values: Record<string, VariantAttributeValue>;
values: Record<string, string | string[]>;
}
export interface CatalogFeaturedItem {

View File

@@ -14,7 +14,6 @@ import {
InventoryPolicy,
ProductAttribute,
ProductAttributeOption,
VariantAttributeValue,
} from '../../../../core/services/catalog/catalog.interface';
@Component({
@@ -199,7 +198,7 @@ export class ProductAttributeSelectorComponent {
private getVariantAttributeValues(
attribute: ProductAttribute,
variantAttributes: Record<string, VariantAttributeValue>,
variantAttributes: Record<string, string | string[]>,
): string[] {
const normalizedCodigo = this.normalizeText(attribute.codigo);
const normalizedNombre = this.normalizeText(attribute.nombre);
@@ -208,9 +207,7 @@ export class ProductAttributeSelectorComponent {
const normalizedKey = this.normalizeText(key);
if (normalizedKey === normalizedCodigo || normalizedKey === normalizedNombre) {
return (Array.isArray(value) ? value : [value]).map((item) =>
this.normalizeText(typeof item === 'string' ? item : item.value),
);
return (Array.isArray(value) ? value : [value]).map((item) => this.normalizeText(item));
}
}

View File

@@ -28,8 +28,8 @@
[ngModel]="selectedValues()[selector.key]"
(ngModelChange)="onVariantValueChange(selector.key, $event)"
>
@for (option of selector.options; track option.key) {
<option [ngValue]="option.value">{{ option.label }}</option>
@for (option of selector.options; track option) {
<option [ngValue]="option">{{ option }}</option>
}
</select>
}

View File

@@ -136,24 +136,6 @@ describe('ProductVerticalWithCartCardComponent', () => {
expect(selectors?.querySelector('app-quantity-selector')).toBeNull();
});
it('uses the variant value for selection and renders its label', async () => {
const fixture = await createComponent();
fixture.componentRef.setInput('variants', [
{
id: 10,
values: { event_date: { value: '2', label: '10/10/2026' } },
},
]);
fixture.detectChanges();
const option = fixture.nativeElement.querySelector(
'.product-vertical-with-cart-card__variant-select option',
) as HTMLOptionElement;
expect(option.textContent?.trim()).toBe('10/10/2026');
expect((fixture.componentInstance as any).selectedValues()).toEqual({ event_date: '2' });
});
it('places all variant selectors together below price and quantity', async () => {
const fixture = await createComponent();
fixture.componentRef.setInput('variants', [

View File

@@ -13,27 +13,18 @@ import { FormsModule } from '@angular/forms';
import { ButtonComponent } from '../button/button.component';
import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component';
import { VariantAttributeValue } from '../../../core/services/catalog/catalog.interface';
type VariantSelectionValue = string | string[];
export interface VerticalCartVariant {
id: number;
descripcion?: string | null;
precio?: string | number;
values: Record<string, VariantAttributeValue>;
}
interface VariantSelectorOption {
key: string;
value: VariantSelectionValue;
label: string;
values: Record<string, string>;
}
interface VariantSelector {
key: string;
label: string;
options: VariantSelectorOption[];
options: string[];
}
@Component({
@@ -55,7 +46,7 @@ export class ProductVerticalWithCartCardComponent {
readonly buy = output<{ quantity: number; variant: number | null }>();
readonly addToCart = output<{ quantity: number; variant: number | null }>();
protected readonly selectedValues = signal<Record<string, VariantSelectionValue>>({});
protected readonly selectedValues = signal<Record<string, string>>({});
protected readonly selectedVariantData = computed(() =>
this.variants().find((variant) => variant.id === this.selectedVariant()),
@@ -76,7 +67,13 @@ export class ProductVerticalWithCartCardComponent {
return keys.map((key) => ({
key,
label: this.formatVariantLabel(key),
options: this.optionsFor(variants, key),
options: Array.from(
new Set(
variants
.map((variant) => variant.values[key])
.filter((value): value is string => Boolean(value)),
),
),
}));
});
@@ -95,21 +92,17 @@ export class ProductVerticalWithCartCardComponent {
const selected =
variants.find((variant) => variant.id === this.selectedVariant()) ?? variants[0];
this.selectedValues.set(this.selectionValues(selected.values));
this.selectedValues.set({ ...selected.values });
this.selectedVariant.set(selected.id);
});
});
}
protected onVariantValueChange(key: string, value: VariantSelectionValue): void {
protected onVariantValueChange(key: string, value: string): void {
const values = { ...this.selectedValues(), [key]: value };
const selectors = this.variantSelectors();
const matchingVariant = this.variants().find((variant) =>
selectors.every(
(selector) =>
this.valueKey(this.selectionValue(variant.values[selector.key])) ===
this.valueKey(values[selector.key]),
),
selectors.every((selector) => variant.values[selector.key] === values[selector.key]),
);
this.selectedValues.set(values);
@@ -124,55 +117,6 @@ export class ProductVerticalWithCartCardComponent {
this.buy.emit({ quantity: this.quantity(), variant: this.selectedVariant() });
}
private optionsFor(variants: VerticalCartVariant[], key: string): VariantSelectorOption[] {
const options = new Map<string, VariantSelectorOption>();
for (const variant of variants) {
const attributeValue = variant.values[key];
if (attributeValue === undefined) continue;
const value = this.selectionValue(attributeValue);
const optionKey = this.valueKey(value);
if (!options.has(optionKey)) {
options.set(optionKey, {
key: optionKey,
value,
label: this.selectionLabel(attributeValue),
});
}
}
return Array.from(options.values());
}
private selectionValues(
values: Record<string, VariantAttributeValue>,
): Record<string, VariantSelectionValue> {
return Object.fromEntries(
Object.entries(values).map(([key, value]) => [key, this.selectionValue(value)]),
);
}
private selectionValue(value: VariantAttributeValue): VariantSelectionValue {
if (typeof value === 'string') return value;
if (Array.isArray(value)) {
return value.map((item) => (typeof item === 'string' ? item : item.value));
}
return value.value;
}
private selectionLabel(value: VariantAttributeValue): string {
if (typeof value === 'string') return value;
if (Array.isArray(value)) {
return value.map((item) => (typeof item === 'string' ? item : item.label)).join(', ');
}
return value.label;
}
private valueKey(value: VariantSelectionValue | undefined): string {
return JSON.stringify(value);
}
private formatVariantLabel(key: string): string {
const label = key.replace(/[_-]+/g, ' ');
return label.charAt(0).toUpperCase() + label.slice(1);

View File

@@ -5,6 +5,7 @@
class="form-select variant-selector__select"
[attr.aria-label]="selector.label"
[disabled]="disabled()"
[compareWith]="compareValues"
[ngModel]="selectedValues()[selector.key]"
(ngModelChange)="onValueChange(selector.key, $event)"
>

View File

@@ -39,7 +39,7 @@ describe('VariantSelectorComponent', () => {
});
});
it('renders labels while matching variants by value', async () => {
it('renders backend option labels and compares equivalent option objects by value', async () => {
await TestBed.configureTestingModule({
imports: [VariantSelectorComponent],
}).compileComponents();
@@ -47,13 +47,36 @@ describe('VariantSelectorComponent', () => {
fixture.componentRef.setInput('variants', [
{
value: 1,
values: { event_date: { value: '2', label: '10/10/2026' } },
values: {
color: { value: 'red', label: 'Rojo' },
talle: { value: 's', label: 'Small' },
},
},
{
value: 2,
values: {
color: { value: 'red', label: 'Rojo' },
talle: { value: 'm', label: 'Medium' },
},
},
]);
fixture.componentRef.setInput('selectedVariant', 2);
fixture.detectChanges();
const option = fixture.nativeElement.querySelector('option') as HTMLOptionElement;
expect(option.textContent?.trim()).toBe('10/10/2026');
expect((fixture.componentInstance as any).selectedValues()).toEqual({ event_date: '2' });
const selects = Array.from(
fixture.nativeElement.querySelectorAll('select'),
) as HTMLSelectElement[];
expect(selects).toHaveLength(2);
expect(selects[0].selectedOptions[0]?.textContent).toBe('Rojo');
expect(selects[1].selectedOptions[0]?.textContent).toBe('Medium');
expect(fixture.nativeElement.textContent).not.toContain('[object Object]');
(fixture.componentInstance as any).onValueChange('talle', {
value: 's',
label: 'Small',
});
expect(fixture.componentInstance.selectedVariant()).toBe(1);
});
});

View File

@@ -9,9 +9,14 @@ import {
untracked,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { VariantAttributeValue } from '../../../core/services/catalog/catalog.interface';
type VariantSelectionValue = string | string[];
export interface VariantAttributeOption {
value: string;
label: string;
}
export type VariantAttributeScalar = string | VariantAttributeOption;
export type VariantAttributeValue = VariantAttributeScalar | VariantAttributeScalar[];
export interface VariantSelectorVariant {
value: unknown;
@@ -21,7 +26,7 @@ export interface VariantSelectorVariant {
interface VariantSelectorOption {
key: string;
label: string;
value: VariantSelectionValue;
value: VariantAttributeValue;
}
interface VariantSelectorGroup {
@@ -44,7 +49,11 @@ export class VariantSelectorComponent {
readonly disabled = input(false);
readonly compact = input(false);
protected readonly selectedValues = signal<Record<string, VariantSelectionValue>>({});
protected readonly selectedValues = signal<Record<string, VariantAttributeValue>>({});
protected readonly compareValues = (
left: VariantAttributeValue | null,
right: VariantAttributeValue | null,
): boolean => left !== null && right !== null && this.sameValue(left, right);
protected readonly attributeKeys = computed(() =>
Array.from(new Set(this.variants().flatMap((variant) => Object.keys(variant.values)))),
);
@@ -57,10 +66,7 @@ export class VariantSelectorComponent {
const previousKeys = keys.slice(0, index);
const compatibleVariants = variants.filter((variant) =>
previousKeys.every((previousKey) =>
this.sameValue(
this.selectionValue(variant.values[previousKey]),
selectedValues[previousKey],
),
this.sameValue(variant.values[previousKey], selectedValues[previousKey]),
),
);
@@ -86,13 +92,13 @@ export class VariantSelectorComponent {
const selected =
variants.find((variant) => Object.is(variant.value, selectedVariant)) ?? variants[0];
this.selectedValues.set(this.selectionValues(selected.values));
this.selectedValues.set({ ...selected.values });
if (!Object.is(selected.value, selectedVariant)) this.selectedVariant.set(selected.value);
});
});
}
protected onValueChange(key: string, value: VariantSelectionValue): void {
protected onValueChange(key: string, value: VariantAttributeValue): void {
const variants = this.variants();
const keys = this.attributeKeys();
const changedIndex = keys.indexOf(key);
@@ -103,7 +109,7 @@ export class VariantSelectorComponent {
const previousKeys = keys.slice(0, index);
const compatibleVariants = variants.filter((variant) =>
previousKeys.every((previousKey) =>
this.sameValue(this.selectionValue(variant.values[previousKey]), values[previousKey]),
this.sameValue(variant.values[previousKey], values[previousKey]),
),
);
const options = this.optionsFor(compatibleVariants, currentKey);
@@ -117,7 +123,7 @@ export class VariantSelectorComponent {
const matchingVariant = variants.find((variant) =>
keys.every((attributeKey) =>
this.sameValue(this.selectionValue(variant.values[attributeKey]), values[attributeKey]),
this.sameValue(variant.values[attributeKey], values[attributeKey]),
),
);
@@ -129,19 +135,14 @@ export class VariantSelectorComponent {
const options = new Map<string, VariantSelectorOption>();
for (const variant of variants) {
const attributeValue = variant.values[key];
if (attributeValue === undefined) continue;
const value = this.selectionValue(attributeValue);
if (value === undefined || value === '' || (Array.isArray(value) && value.length === 0)) {
continue;
}
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: this.selectionLabel(attributeValue),
label: this.valueLabel(value),
value,
});
}
@@ -151,46 +152,29 @@ export class VariantSelectorComponent {
}
private sameValue(
left: VariantSelectionValue | undefined,
right: VariantSelectionValue | undefined,
left: VariantAttributeValue | undefined,
right: VariantAttributeValue | undefined,
): boolean {
return (
left !== undefined && right !== undefined && this.valueKey(left) === this.valueKey(right)
);
}
private valueKey(value: VariantSelectionValue): string {
return JSON.stringify(value);
private valueKey(value: VariantAttributeValue): string {
const comparableValue = Array.isArray(value)
? value.map((item) => this.scalarValue(item))
: this.scalarValue(value);
return JSON.stringify(comparableValue);
}
private selectionValues(
values: Record<string, VariantAttributeValue>,
): Record<string, VariantSelectionValue> {
return Object.fromEntries(
Object.entries(values).flatMap(([key, value]) => {
const selectionValue = this.selectionValue(value);
return selectionValue === undefined ? [] : [[key, selectionValue]];
}),
);
private valueLabel(value: VariantAttributeValue): string {
const values = Array.isArray(value) ? value : [value];
return values.map((item) => (typeof item === 'string' ? item : item.label)).join(', ');
}
private selectionValue(
value: VariantAttributeValue | undefined,
): VariantSelectionValue | undefined {
if (value === undefined) return undefined;
if (typeof value === 'string') return value;
if (Array.isArray(value)) {
return value.map((item) => (typeof item === 'string' ? item : item.value));
}
return value.value;
}
private selectionLabel(value: VariantAttributeValue): string {
if (typeof value === 'string') return value;
if (Array.isArray(value)) {
return value.map((item) => (typeof item === 'string' ? item : item.label)).join(', ');
}
return value.label;
private scalarValue(value: VariantAttributeScalar): string {
return typeof value === 'string' ? value : value.value;
}
private formatVariantLabel(key: string): string {