diff --git a/src/app/core/services/catalog/catalog.interface.ts b/src/app/core/services/catalog/catalog.interface.ts index d2c93b7..913546c 100644 --- a/src/app/core/services/catalog/catalog.interface.ts +++ b/src/app/core/services/catalog/catalog.interface.ts @@ -24,13 +24,24 @@ export interface ProductAttributeOption { metadata: Record | null; } +export type ProductAttributeType = + | 'string' + | 'number' + | 'boolean' + | 'select' + | 'multiselect' + | 'color' + | 'image' + | 'event_date'; + export interface ProductAttribute { id: number; codigo: string; nombre: string; is_required: boolean; + allow_multi_select?: boolean; metadata_schema: Record | null; - type: string; + type: ProductAttributeType; options: ProductAttributeOption[]; } @@ -38,23 +49,17 @@ export type InventoryPolicy = 'tracked' | 'unlimited'; export interface CatalogItemVariant { id: number; + descripcion?: string | null; + precio?: string; event_date_id?: number | null; + event_date_ids?: number[]; + event_dates?: string[]; stock_tecnico: number | null; minimum_use_date?: string | null; maximum_use_date?: string | null; effective_minimum_use_date?: string | null; effective_maximum_use_date?: string | null; - values: Record; -} - -export interface CatalogItemEventDate { - id: number; - variant_id: number; - date: string; - starts_at: string; - ends_at: string; - label: string; - stock_tecnico: number | null; + values: Record; } export interface SelectedCatalogItemVariant extends CatalogItemVariant { @@ -74,12 +79,12 @@ export interface CatalogItemDetail { category: string | null; brand: string | null; inventory_policy: InventoryPolicy; + max_units_per_user?: number | null; has_tickets: boolean; minimum_use_date: string | null; maximum_use_date: string | null; attributes: ProductAttribute[]; variants: CatalogItemVariant[]; - event_dates?: CatalogItemEventDate[]; selected_variant?: SelectedCatalogItemVariant; stock_tecnico?: number | null; images?: string[]; @@ -90,8 +95,10 @@ export type CatalogGroupLayout = 'paginated' | 'simple' | 'simple_vertical' | 'c export interface CatalogFeaturedItemVariant { id: number; + descripcion?: string | null; + precio?: string; stock_tecnico: number | null; - values: Record; + values: Record; } export interface CatalogFeaturedItem { diff --git a/src/app/core/services/checkout.service.ts b/src/app/core/services/checkout.service.ts index 1666bd7..9d7d996 100644 --- a/src/app/core/services/checkout.service.ts +++ b/src/app/core/services/checkout.service.ts @@ -29,7 +29,6 @@ export interface PurchaseStatusResponse { export interface PurchaseSummaryResponse extends PurchaseStatusResponse { id: number; - event_id: number | null; created_at: string | null; total: string; } @@ -59,7 +58,6 @@ export interface PurchaseDetailResponse extends PurchaseStatusResponse { id: number; cart_id: number | null; tenant_codigo: string; - event_id: number | null; user_id: number; created_at: string | null; payment_method: string | null; diff --git a/src/app/core/services/tenant.interface.ts b/src/app/core/services/tenant.interface.ts index a27bf1c..e1a1b7a 100644 --- a/src/app/core/services/tenant.interface.ts +++ b/src/app/core/services/tenant.interface.ts @@ -54,10 +54,9 @@ export interface ActiveEventDate { time_end: string; } -export interface ActiveEvent { - id: number; - name: string; - address: string; +export interface TenantEvent { + title: string; + location: string; dates: ActiveEventDate[]; } @@ -108,8 +107,7 @@ export interface Tenant { website_type_code?: string | null; website_type?: WebsiteType | null; extras?: WebsiteExtras; - active_event_id?: number | null; - active_event?: ActiveEvent | null; + event?: TenantEvent | null; selected_bank_account_id?: number | null; selected_bank_account?: BankAccount | null; search_product_layout?: 'row' | 'column_with_image' | 'column_with_cart'; diff --git a/src/app/core/services/validity-time.interface.ts b/src/app/core/services/validity-time.interface.ts new file mode 100644 index 0000000..37203ee --- /dev/null +++ b/src/app/core/services/validity-time.interface.ts @@ -0,0 +1,17 @@ +export interface TimeWindowValidityTime { + id: number; + type: 'time_window'; + is_valid: boolean; + start_time?: string; + end_time?: string; +} + +export interface FixedWindowValidityTime { + id: number; + type: 'fixed_window'; + is_valid: boolean; + fixed_starts_at?: string; + fixed_expires_at?: string; +} + +export type ValidityTime = TimeWindowValidityTime | FixedWindowValidityTime; diff --git a/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.html b/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.html index ac7b1b3..5701661 100644 --- a/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.html +++ b/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.html @@ -1,5 +1,5 @@
- @for (attribute of attributes(); track attribute.id) { + @for (attribute of attributes(); track attribute.codigo) {
{{ attribute.nombre }}: @@ -10,12 +10,14 @@ type="button" class="attribute-selector__swatch" [class.attribute-selector__swatch--selected]="hasSelectedOption(attribute, option)" - [class.attribute-selector__swatch--disabled]="!availableOptions()[attribute.id][option.id]" + [class.attribute-selector__swatch--disabled]=" + !availableOptions()[attribute.codigo][option.id] + " [style.background-color]="getOptionSwatchColor(option)" [attr.aria-label]="option.label" [attr.aria-pressed]="hasSelectedOption(attribute, option)" [title]="option.label" - [disabled]="!availableOptions()[attribute.id][option.id]" + [disabled]="!availableOptions()[attribute.codigo][option.id]" (click)="selectAttributeOption(attribute, option)" > {{ option.label }} @@ -24,10 +26,14 @@
@if (showDescriptionToggle()) { diff --git a/src/app/features/store/pages/product-detail-page/product-detail-page.component.spec.ts b/src/app/features/store/pages/product-detail-page/product-detail-page.component.spec.ts index f8e9fe0..928d8d4 100644 --- a/src/app/features/store/pages/product-detail-page/product-detail-page.component.spec.ts +++ b/src/app/features/store/pages/product-detail-page/product-detail-page.component.spec.ts @@ -22,6 +22,7 @@ import { describe('ProductDetailPageComponent', () => { const mockProduct: CatalogItemDetail = { id: 1, + type: 'product', category_id: 10, brand_id: null, slug: 'auriculares-bluetooth', @@ -327,33 +328,39 @@ describe('ProductDetailPageComponent', () => { expect((swatches[0] as HTMLElement).style.backgroundColor).not.toBe(''); }); - it('renders event dates and resolves the selected date through its variant', async () => { + it('renders event dates as a dynamic attribute and resolves their variants', async () => { const detailProduct: CatalogItemDetail = { ...mockProduct, purpose: 'entry', has_tickets: true, variants: [ - { id: 101, event_date_id: 20, stock_tecnico: 10, values: {} }, - { id: 102, event_date_id: 21, stock_tecnico: 10, values: {} }, + { id: 101, event_date_id: 20, stock_tecnico: 10, values: { event_date: '20' } }, + { id: 102, event_date_id: 21, stock_tecnico: 10, values: { event_date: '21' } }, ], - event_dates: [ + attributes: [ { - id: 20, - variant_id: 101, - date: '2026-10-09', - starts_at: '2026-10-09T10:00:00.000000Z', - ends_at: '2026-10-09T20:00:00.000000Z', - label: 'Viernes 9 de octubre - 10:00 a 20:00', - stock_tecnico: 10, - }, - { - id: 21, - variant_id: 102, - date: '2026-10-10', - starts_at: '2026-10-10T10:00:00.000000Z', - ends_at: '2026-10-10T20:00:00.000000Z', - label: 'Sábado 10 de octubre - 10:00 a 20:00', - stock_tecnico: 10, + id: 99, + codigo: 'event_date', + nombre: 'Fecha', + is_required: true, + metadata_schema: null, + type: 'event_date', + options: [ + { + id: 20, + value: '20', + label: '09/10/2026 · 10:00 a 20:00', + sort_order: 0, + metadata: null, + }, + { + id: 21, + value: '21', + label: '10/10/2026 · 10:00 a 20:00', + sort_order: 1, + metadata: null, + }, + ], }, ], selected_variant: { @@ -373,12 +380,13 @@ describe('ProductDetailPageComponent', () => { const fixture = TestBed.createComponent(ProductDetailPageComponent); fixture.detectChanges(); - const select = fixture.nativeElement.querySelector('#event-date-selector') as HTMLSelectElement; - expect(select.options).toHaveLength(2); - expect(select.value).toBe('101'); + const options = fixture.nativeElement.querySelectorAll( + '.attribute-selector__text-option', + ) as NodeListOf; + expect(options).toHaveLength(2); + expect(options[0].classList.contains('attribute-selector__text-option--selected')).toBe(true); - select.value = '102'; - select.dispatchEvent(new Event('change')); + options[1].click(); fixture.detectChanges(); expect(catalogServiceStub.getCatalogItem).toHaveBeenCalledWith(1, 102); @@ -621,6 +629,39 @@ describe('ProductDetailPageComponent', () => { expect(fixture.componentInstance['quantity']()).toBe(2); }); + it('caps an unlimited variant at the per-user purchase limit', async () => { + const unlimitedVariant = { + id: 322, + stock_tecnico: null, + values: {}, + }; + resolveProduct({ + ...mockProduct, + inventory_policy: 'unlimited', + max_units_per_user: 2, + selected_variant: { + id: 322, + stock_tecnico: null, + images: [], + values: { event_date: '20' }, + }, + variants: [unlimitedVariant], + }); + + await configureTestingModule(); + const fixture = TestBed.createComponent(ProductDetailPageComponent); + fixture.detectChanges(); + + const increaseButton = fixture.nativeElement.querySelector( + 'app-quantity-selector button:last-child', + ) as HTMLButtonElement; + increaseButton.click(); + fixture.detectChanges(); + + expect(fixture.componentInstance['quantity']()).toBe(2); + expect(increaseButton.disabled).toBe(true); + }); + it('disables purchase actions for tracked variants without stock', async () => { const trackedVariant = { id: 654, diff --git a/src/app/features/store/pages/product-detail-page/product-detail-page.component.ts b/src/app/features/store/pages/product-detail-page/product-detail-page.component.ts index cb188b4..1e73d8a 100644 --- a/src/app/features/store/pages/product-detail-page/product-detail-page.component.ts +++ b/src/app/features/store/pages/product-detail-page/product-detail-page.component.ts @@ -86,13 +86,28 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { protected readonly creatingDirectPurchase = signal(false); protected readonly error = signal(null); protected readonly selectedVariant = signal(null); + protected readonly effectiveDescription = computed( + () => this.selectedVariant()?.descripcion ?? this.product()?.descripcion ?? '', + ); + protected readonly effectivePrice = computed( + () => this.selectedVariant()?.precio ?? this.product()?.precio, + ); protected readonly quantity = signal(1); protected readonly selectedVariantMax = computed(() => { const prod = this.product(); const variant = this.selectedVariant(); - if (variant) return variant.stock_tecnico; - if (prod && prod.variants.length === 0) return prod.stock_tecnico ?? null; - return 0; + if (!prod) return 0; + + const stockLimit = variant + ? variant.stock_tecnico + : prod.variants.length === 0 + ? (prod.stock_tecnico ?? null) + : 0; + const userLimit = prod.max_units_per_user ?? null; + + if (stockLimit === null) return userLimit; + if (userLimit === null) return stockLimit; + return Math.min(stockLimit, userLimit); }); protected readonly selectedVariantAvailable = computed(() => { const prod = this.product(); @@ -109,14 +124,8 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { protected readonly descriptionMaxHeight = signal(0); protected readonly descriptionHasOverflow = signal(false); protected readonly renderableAttributes = computed(() => - (this.product()?.attributes ?? []).filter( - (attribute) => - attribute.options.length > 0 && - !(this.product()?.purpose === 'entry' && attribute.codigo === 'fecha'), - ), + (this.product()?.attributes ?? []).filter((attribute) => attribute.options.length > 0), ); - protected readonly eventDates = computed(() => this.product()?.event_dates ?? []); - protected readonly hasEventDates = computed(() => this.eventDates().length > 0); protected readonly hasRenderableAttributes = computed( () => this.renderableAttributes().length > 0, ); @@ -130,6 +139,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { this.carouselHost(); this.descriptionBody(); this.product(); + this.selectedVariant(); this.descriptionExpanded(); if (this.isBrowser) { @@ -237,8 +247,11 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { } this.selectedVariant.set(variant); - if (variant && variant.stock_tecnico !== null && this.quantity() > variant.stock_tecnico) { - this.quantity.set(Math.max(1, variant.stock_tecnico)); + this.descriptionExpanded.set(false); + this.descriptionHasOverflow.set(false); + const maximum = this.selectedVariantMax(); + if (maximum !== null && this.quantity() > maximum) { + this.quantity.set(Math.max(1, maximum)); } const currentProduct = this.product(); @@ -247,12 +260,6 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { } } - protected onEventDateChange(event: Event): void { - const variantId = Number((event.target as HTMLSelectElement).value); - const variant = this.product()?.variants.find((item) => item.id === variantId) ?? null; - this.onVariantChange(variant); - } - protected addToCart(): void { const currentProduct = this.product(); const variant = this.selectedVariant(); diff --git a/src/app/features/store/pages/store-home-page/store-home-page.component.spec.ts b/src/app/features/store/pages/store-home-page/store-home-page.component.spec.ts index f0ba32f..1e62757 100644 --- a/src/app/features/store/pages/store-home-page/store-home-page.component.spec.ts +++ b/src/app/features/store/pages/store-home-page/store-home-page.component.spec.ts @@ -195,11 +195,9 @@ describe('StoreHomePageComponent', () => { background_image_id: 'https://example.com/hero.jpg', }, }); - tenant.active_event_id = 10; - tenant.active_event = { - id: 10, - name: 'Fiesta Fútbol Infantil', - address: 'Rosario, Santa Fe', + tenant.event = { + title: 'Fiesta Fútbol Infantil', + location: 'Rosario, Santa Fe', dates: [ { id: 20, @@ -331,11 +329,9 @@ describe('StoreHomePageComponent', () => { it('renders only the active event data when the tenant does not have the hero extra', async () => { const tenant = createTenant(); - tenant.active_event_id = 12; - tenant.active_event = { - id: 12, - name: 'Fiesta Fútbol Infantil', - address: 'Sunchales, Santa Fe', + tenant.event = { + title: 'Fiesta Fútbol Infantil', + location: 'Sunchales, Santa Fe', dates: [ { id: 25, diff --git a/src/app/features/store/pages/store-home-page/store-home-page.component.ts b/src/app/features/store/pages/store-home-page/store-home-page.component.ts index ac6b27a..8d4397a 100644 --- a/src/app/features/store/pages/store-home-page/store-home-page.component.ts +++ b/src/app/features/store/pages/store-home-page/store-home-page.component.ts @@ -68,14 +68,14 @@ export class StoreHomePageComponent implements OnInit, OnDestroy { protected readonly eventConfig = computed(() => { const tenant = this.tenant(); const contact = tenant?.social_media ?? []; - const activeEvent = tenant?.active_event; + const event = tenant?.event; - if (activeEvent) { + if (event) { return { - id: activeEvent.id, - title: activeEvent.name, - location: activeEvent.address, - dates: activeEvent.dates.map((eventDate) => ({ + id: tenant.id, + title: event.title, + location: event.location, + dates: event.dates.map((eventDate) => ({ id: eventDate.id, date: eventDate.date, start_time: eventDate.time_start, 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 f4e6681..ac12391 100644 --- a/src/app/shared/components/product-list/product-list.component.ts +++ b/src/app/shared/components/product-list/product-list.component.ts @@ -109,6 +109,8 @@ export class ProductListComponent { return (item.variants ?? []).map((variant) => ({ value: variant.id, label: Object.values(variant.values).join(' / ') || `Variante ${variant.id}`, + descripcion: variant.descripcion, + precio: variant.precio, })); } diff --git a/src/app/shared/components/product-row-card/product-row-card.component.html b/src/app/shared/components/product-row-card/product-row-card.component.html index 9e73b5e..53d8664 100644 --- a/src/app/shared/components/product-row-card/product-row-card.component.html +++ b/src/app/shared/components/product-row-card/product-row-card.component.html @@ -6,9 +6,9 @@

{{ title() }}

- @if (description()) { + @if (effectiveDescription()) {

- {{ description() }} + {{ effectiveDescription() }}

}
diff --git a/src/app/shared/components/product-row-card/product-row-card.component.ts b/src/app/shared/components/product-row-card/product-row-card.component.ts index e1c87ad..be4eba3 100644 --- a/src/app/shared/components/product-row-card/product-row-card.component.ts +++ b/src/app/shared/components/product-row-card/product-row-card.component.ts @@ -14,6 +14,8 @@ import { QuantitySelectorComponent } from '../quantity-selector/quantity-selecto export interface Variant { label: string; value: any; + descripcion?: string | null; + precio?: string | number; } @Component({ @@ -53,11 +55,20 @@ export class ProductRowCardComponent { }); } - // Formatted string for price: "$ 10.000" - readonly formattedPrice = computed(() => { - return this.formatCurrency(this.price()); + protected readonly selectedVariantData = computed(() => + this.variants().find((variant) => Object.is(variant.value, this.selectedVariant())), + ); + protected readonly effectiveDescription = computed( + () => this.selectedVariantData()?.descripcion ?? this.description(), + ); + protected readonly effectivePrice = computed(() => { + const variantPrice = Number(this.selectedVariantData()?.precio); + + return Number.isFinite(variantPrice) ? variantPrice : this.price(); }); + readonly formattedPrice = computed(() => this.formatCurrency(this.effectivePrice())); + protected onAddToCart(): void { this.addToCart.emit({ quantity: this.quantity(), 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 d2c396f..2d03f1b 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 @@ -2,8 +2,8 @@

{{ title() }}

- @if (description()) { -

{{ description() }}

+ @if (effectiveDescription()) { +

{{ effectiveDescription() }}

}
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 fc2e1b2..c6ff6f6 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 @@ -177,4 +177,45 @@ describe('ProductVerticalWithCartCardComponent', () => { expect(buySpy).toHaveBeenCalledWith({ quantity: 1, variant: 31 }); }); + + it('prioritizes the selected variant description and price', async () => { + const fixture = await createComponent('Descripción general'); + fixture.componentRef.setInput('variants', [ + { + id: 40, + descripcion: 'Almuerzo en comedor', + precio: '10000.00', + values: { servicio: 'Comedor' }, + }, + { + id: 41, + descripcion: 'Almuerzo en vianda', + precio: '8000.00', + values: { servicio: 'Vianda' }, + }, + ]); + fixture.detectChanges(); + + const element = fixture.nativeElement as HTMLElement; + expect( + element.querySelector('.product-vertical-with-cart-card__description')?.textContent, + ).toContain('Almuerzo en comedor'); + expect( + element.querySelector('.product-vertical-with-cart-card__price')?.textContent?.trim(), + ).toBe('$ 10.000'); + + const select = element.querySelector( + '.product-vertical-with-cart-card__variant-select', + ) as HTMLSelectElement; + select.value = select.options[1].value; + select.dispatchEvent(new Event('change')); + fixture.detectChanges(); + + expect( + element.querySelector('.product-vertical-with-cart-card__description')?.textContent, + ).toContain('Almuerzo en vianda'); + expect( + element.querySelector('.product-vertical-with-cart-card__price')?.textContent?.trim(), + ).toBe('$ 8.000'); + }); }); 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 876a7ac..38f1719 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 @@ -16,6 +16,8 @@ import { QuantitySelectorComponent } from '../quantity-selector/quantity-selecto export interface VerticalCartVariant { id: number; + descripcion?: string | null; + precio?: string | number; values: Record; } @@ -46,7 +48,18 @@ export class ProductVerticalWithCartCardComponent { protected readonly selectedValues = signal>({}); - protected readonly formattedPrice = computed(() => this.formatCurrency(this.price())); + protected readonly selectedVariantData = computed(() => + this.variants().find((variant) => variant.id === this.selectedVariant()), + ); + protected readonly effectiveDescription = computed( + () => this.selectedVariantData()?.descripcion ?? this.description(), + ); + protected readonly effectivePrice = computed(() => { + const variantPrice = Number(this.selectedVariantData()?.precio); + + return Number.isFinite(variantPrice) ? variantPrice : this.price(); + }); + protected readonly formattedPrice = computed(() => this.formatCurrency(this.effectivePrice())); protected readonly variantSelectors = computed(() => { const variants = this.variants(); const keys = Array.from(new Set(variants.flatMap((variant) => Object.keys(variant.values))));