+
@if (ticketsMenu(); as menu) {
diff --git a/src/app/shared/components/product-list/product-list.component.spec.ts b/src/app/shared/components/product-list/product-list.component.spec.ts
index d70622e..30504d0 100644
--- a/src/app/shared/components/product-list/product-list.component.spec.ts
+++ b/src/app/shared/components/product-list/product-list.component.spec.ts
@@ -160,6 +160,31 @@ describe('ProductListComponent', () => {
});
});
+ it('passes column variants through to the purchase event', async () => {
+ const itemWithVariants: ProductListItem = {
+ ...items[0],
+ variants: [
+ { id: 91, stock_tecnico: 3, values: { fecha: '10 de octubre' } },
+ { id: 92, stock_tecnico: 4, values: { fecha: '11 de octubre' } },
+ ],
+ };
+ const fixture = await render('column_with_cart', [itemWithVariants]);
+ const buySpy = vi.fn();
+ fixture.componentInstance.buy.subscribe(buySpy);
+
+ const card = fixture.nativeElement.querySelector(
+ 'app-product-vertical-with-cart-card',
+ ) as HTMLElement;
+ (card.querySelector('.btn-primary') as HTMLButtonElement).click();
+
+ expect(buySpy).toHaveBeenCalledWith({
+ product: itemWithVariants,
+ quantity: 1,
+ variant: 91,
+ directPurchase: true,
+ });
+ });
+
it('renders pagination and emits the requested page', async () => {
const fixture = await render('row');
const pageChangeSpy = vi.fn();
diff --git a/src/app/shared/components/product-list/product-list.component.ts b/src/app/shared/components/product-list/product-list.component.ts
index 34382a4..f4e6681 100644
--- a/src/app/shared/components/product-list/product-list.component.ts
+++ b/src/app/shared/components/product-list/product-list.component.ts
@@ -123,8 +123,11 @@ export class ProductListComponent {
});
}
- protected emitColumnCart(product: ProductListItem, event: { quantity: number }): void {
- this.addToCart.emit({ product, quantity: event.quantity });
+ protected emitColumnCart(
+ product: ProductListItem,
+ event: { quantity: number; variant: number | null },
+ ): void {
+ this.addToCart.emit({ product, quantity: event.quantity, variant: event.variant });
}
protected emitRowBuy(
@@ -139,8 +142,16 @@ export class ProductListComponent {
});
}
- protected emitColumnBuy(product: ProductListItem, event: { quantity: number }): void {
- this.buy.emit({ product, quantity: event.quantity, variant: null, directPurchase: true });
+ protected emitColumnBuy(
+ product: ProductListItem,
+ event: { quantity: number; variant: number | null },
+ ): void {
+ this.buy.emit({
+ product,
+ quantity: event.quantity,
+ variant: event.variant,
+ directPurchase: true,
+ });
}
protected emitProductDetailBuy(product: ProductListItem): void {
diff --git a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.html b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.html
index d6000cb..d2c396f 100644
--- a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.html
+++ b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.html
@@ -8,14 +8,71 @@
-
-
{{ formattedPrice() }}
-
-
+ @if (!hasVariants()) {
+
+
{{ formattedPrice() }}
+
+
+ } @else if (!hasMultipleVariantSelectors()) {
+
+
{{ formattedPrice() }}
+
+
+ @for (selector of variantSelectors(); track selector.key) {
+
+ }
+
+
+
+
+ } @else {
+
+
+
{{ formattedPrice() }}
+
+
+
+
+ @for (selector of variantSelectors(); track selector.key) {
+
+ }
+
+
+ }
-
Comprar
-
Agregar al carrito
+
+ Comprar
+
+
+ Agregar al carrito
+
diff --git a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.scss b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.scss
index 23c4b01..64b0143 100644
--- a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.scss
+++ b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.scss
@@ -40,7 +40,7 @@
}
&__description {
- margin: 23px 0 ;
+ margin: 23px 0;
color: #6f6f6f;
font-size: 11px;
font-weight: 300;
@@ -60,6 +60,60 @@
padding-inline: 12px;
}
+ &__single-variant {
+ display: grid;
+ justify-items: center;
+ gap: 16px;
+ }
+
+ &__single-variant-row {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ gap: 8px;
+ }
+
+ &__multiple-variants {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ align-items: center;
+ gap: 16px;
+ }
+
+ &__summary-column,
+ &__variant-selectors {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+ }
+
+ &__variant-selectors {
+ align-items: stretch;
+ }
+
+ &__variant-select {
+ min-width: 0;
+ height: 40px;
+ border-color: #d8d8d8;
+ color: #6f6f6f;
+ font-size: 12px;
+
+ &:focus {
+ border-color: var(--tenant-primary, #009933);
+ box-shadow: 0 0 0 0.2rem color-mix(in srgb, transparent 75%, var(--tenant-primary, #009933));
+ }
+ }
+
+ &__single-variant-row &__variant-select {
+ flex: 1 1 auto;
+ }
+
+ &__single-variant-row app-quantity-selector {
+ flex: 0 0 auto;
+ }
+
&__price {
color: var(--tenant-primary, #009933);
font-size: 22px;
@@ -87,6 +141,12 @@
}
}
+@media (max-width: 767.98px) {
+ .product-vertical-with-cart-card {
+ border-color: var(--color-primary, var(--tenant-primary, #009933));
+ }
+}
+
@media (max-width: 420px) {
.product-vertical-with-cart-card {
padding: 42px 20px 21px;
@@ -98,6 +158,5 @@
&__summary {
padding-inline: 12px;
}
-
}
}
diff --git a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.spec.ts b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.spec.ts
index ce0c4ba..fc2e1b2 100644
--- a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.spec.ts
+++ b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.spec.ts
@@ -83,7 +83,7 @@ describe('ProductVerticalWithCartCardComponent', () => {
) as HTMLButtonElement;
button.click();
- expect(buySpy).toHaveBeenCalledWith({ quantity: 1 });
+ expect(buySpy).toHaveBeenCalledWith({ quantity: 1, variant: null });
});
it('emits addToCart with the current quantity', async () => {
@@ -98,7 +98,7 @@ describe('ProductVerticalWithCartCardComponent', () => {
) as HTMLButtonElement;
button.click();
- expect(addToCartSpy).toHaveBeenCalledWith({ quantity: 3 });
+ expect(addToCartSpy).toHaveBeenCalledWith({ quantity: 3, variant: null });
});
it('uses the shared primary and secondary buttons', async () => {
@@ -115,4 +115,66 @@ describe('ProductVerticalWithCartCardComponent', () => {
element.querySelector('.product-vertical-with-cart-card__actions .btn-outline-primary'),
).not.toBeNull();
});
+
+ it('places a single variant selector in the same row as the quantity', async () => {
+ const fixture = await createComponent();
+ fixture.componentRef.setInput('variants', [
+ { id: 10, values: { fecha: '10 de octubre' } },
+ { id: 11, values: { fecha: '11 de octubre' } },
+ ]);
+ fixture.detectChanges();
+
+ const element = fixture.nativeElement as HTMLElement;
+ const row = element.querySelector('.product-vertical-with-cart-card__single-variant-row');
+
+ expect(row?.querySelectorAll('.product-vertical-with-cart-card__variant-select')).toHaveLength(
+ 1,
+ );
+ expect(row?.querySelector('app-quantity-selector')).not.toBeNull();
+ expect(element.querySelector('.product-vertical-with-cart-card__multiple-variants')).toBeNull();
+ });
+
+ it('uses separate summary and selector columns for multiple variant attributes', async () => {
+ const fixture = await createComponent();
+ fixture.componentRef.setInput('variants', [
+ { id: 20, values: { fecha: '10 de octubre', turno: 'Mañana' } },
+ { id: 21, values: { fecha: '10 de octubre', turno: 'Tarde' } },
+ ]);
+ fixture.detectChanges();
+
+ const element = fixture.nativeElement as HTMLElement;
+ const layout = element.querySelector('.product-vertical-with-cart-card__multiple-variants');
+
+ expect(
+ layout?.querySelector('.product-vertical-with-cart-card__summary-column'),
+ ).not.toBeNull();
+ expect(
+ layout?.querySelectorAll('.product-vertical-with-cart-card__variant-select'),
+ ).toHaveLength(2);
+ });
+
+ it('emits the selected variant with the purchase action', async () => {
+ const fixture = await createComponent();
+ fixture.componentRef.setInput('variants', [
+ { id: 30, values: { fecha: '10 de octubre' } },
+ { id: 31, values: { fecha: '11 de octubre' } },
+ ]);
+ fixture.detectChanges();
+ const buySpy = vi.fn();
+ fixture.componentInstance.buy.subscribe(buySpy);
+
+ const select = fixture.nativeElement.querySelector(
+ '.product-vertical-with-cart-card__variant-select',
+ ) as HTMLSelectElement;
+ select.value = select.options[1].value;
+ select.dispatchEvent(new Event('change'));
+ fixture.detectChanges();
+
+ const button = fixture.nativeElement.querySelector(
+ '.product-vertical-with-cart-card__actions .btn-primary',
+ ) as HTMLButtonElement;
+ button.click();
+
+ expect(buySpy).toHaveBeenCalledWith({ quantity: 1, variant: 31 });
+ });
});
diff --git a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.ts b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.ts
index 7ef0c11..876a7ac 100644
--- a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.ts
+++ b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.ts
@@ -1,11 +1,33 @@
-import { ChangeDetectionStrategy, Component, computed, input, model, output } from '@angular/core';
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ effect,
+ input,
+ model,
+ output,
+ signal,
+ untracked,
+} from '@angular/core';
+import { FormsModule } from '@angular/forms';
import { ButtonComponent } from '../button/button.component';
import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component';
+export interface VerticalCartVariant {
+ id: number;
+ values: Record
;
+}
+
+interface VariantSelector {
+ key: string;
+ label: string;
+ options: string[];
+}
+
@Component({
selector: 'app-product-vertical-with-cart-card',
- imports: [ButtonComponent, QuantitySelectorComponent],
+ imports: [ButtonComponent, FormsModule, QuantitySelectorComponent],
templateUrl: './product-vertical-with-cart-card.component.html',
styleUrl: './product-vertical-with-cart-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -14,20 +36,80 @@ export class ProductVerticalWithCartCardComponent {
readonly title = input('');
readonly description = input('');
readonly price = input(0);
+ readonly variants = input([]);
readonly quantity = model(1);
+ readonly selectedVariant = model(null);
- readonly buy = output<{ quantity: number }>();
- readonly addToCart = output<{ quantity: number }>();
+ readonly buy = output<{ quantity: number; variant: number | null }>();
+ readonly addToCart = output<{ quantity: number; variant: number | null }>();
+
+ protected readonly selectedValues = signal>({});
protected readonly formattedPrice = computed(() => this.formatCurrency(this.price()));
+ protected readonly variantSelectors = computed(() => {
+ const variants = this.variants();
+ const keys = Array.from(new Set(variants.flatMap((variant) => Object.keys(variant.values))));
+
+ return keys.map((key) => ({
+ key,
+ label: this.formatVariantLabel(key),
+ options: Array.from(
+ new Set(
+ variants
+ .map((variant) => variant.values[key])
+ .filter((value): value is string => Boolean(value)),
+ ),
+ ),
+ }));
+ });
+
+ protected readonly hasVariants = computed(() => this.variantSelectors().length > 0);
+ protected readonly hasMultipleVariantSelectors = computed(
+ () => this.variantSelectors().length > 1,
+ );
+
+ constructor() {
+ effect(() => {
+ const variants = this.variants();
+
+ untracked(() => {
+ if (variants.length === 0) {
+ this.selectedValues.set({});
+ this.selectedVariant.set(null);
+ return;
+ }
+
+ const selected =
+ variants.find((variant) => variant.id === this.selectedVariant()) ?? variants[0];
+ this.selectedValues.set({ ...selected.values });
+ this.selectedVariant.set(selected.id);
+ });
+ });
+ }
+
+ 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) => variant.values[selector.key] === values[selector.key]),
+ );
+
+ this.selectedValues.set(values);
+ this.selectedVariant.set(matchingVariant?.id ?? null);
+ }
protected onAddToCart(): void {
- this.addToCart.emit({ quantity: this.quantity() });
+ this.addToCart.emit({ quantity: this.quantity(), variant: this.selectedVariant() });
}
protected onBuy(): void {
- this.buy.emit({ quantity: this.quantity() });
+ this.buy.emit({ quantity: this.quantity(), variant: this.selectedVariant() });
+ }
+
+ private formatVariantLabel(key: string): string {
+ const label = key.replace(/[_-]+/g, ' ');
+ return label.charAt(0).toUpperCase() + label.slice(1);
}
private formatCurrency(value: number): string {
diff --git a/src/app/shared/components/store-section/store-section.component.scss b/src/app/shared/components/store-section/store-section.component.scss
index e957c83..80f23e7 100644
--- a/src/app/shared/components/store-section/store-section.component.scss
+++ b/src/app/shared/components/store-section/store-section.component.scss
@@ -8,5 +8,5 @@
font-size: 15px;
font-weight: 700;
letter-spacing: 0.08em;
- color: #000000;
+ color: var(--color-primary);
}