diff --git a/src/app/core/services/catalog/catalog.interface.ts b/src/app/core/services/catalog/catalog.interface.ts index 56b9835..21097ba 100644 --- a/src/app/core/services/catalog/catalog.interface.ts +++ b/src/app/core/services/catalog/catalog.interface.ts @@ -35,6 +35,7 @@ export type InventoryPolicy = 'tracked' | 'unlimited'; export interface CatalogItemVariant { id: number; + event_date_id?: number | null; stock_tecnico: number | null; minimum_use_date?: string | null; maximum_use_date?: string | null; @@ -43,12 +44,23 @@ export interface CatalogItemVariant { values: Record; } +export interface CatalogItemEventDate { + id: number; + variant_id: number; + date: string; + starts_at: string; + ends_at: string; + label: string; + stock_tecnico: number | null; +} + export interface SelectedCatalogItemVariant extends CatalogItemVariant { images: string[]; } export interface CatalogItemDetail { id: number; + purpose?: 'product' | 'entry'; category_id: number | null; brand_id: number | null; slug: string; @@ -63,6 +75,7 @@ export interface CatalogItemDetail { maximum_use_date: string | null; attributes: ProductAttribute[]; variants: CatalogItemVariant[]; + event_dates?: CatalogItemEventDate[]; selected_variant?: SelectedCatalogItemVariant; stock_tecnico?: number | null; images?: string[]; diff --git a/src/app/core/services/tenant.interface.ts b/src/app/core/services/tenant.interface.ts index b7ef734..8a310a5 100644 --- a/src/app/core/services/tenant.interface.ts +++ b/src/app/core/services/tenant.interface.ts @@ -28,22 +28,38 @@ export interface HeroConfig { } export interface EventDate { + id?: number; date: string; start_time: string; end_time: string; } export interface EventConfig { + id?: number; title?: string | null; location?: string | null; dates_text?: string | null; dates?: EventDate[] | null; + contact?: SocialMedia[]; +} + +export interface ActiveEventDate { + id: number; + date: string; + time_start: string; + time_end: string; +} + +export interface ActiveEvent { + id: number; + name: string; + address: string; + dates: ActiveEventDate[]; } export interface WebsiteExtras { carousel?: string[]; heroConfig?: HeroConfig | null; - eventConfig?: EventConfig | null; [extraName: string]: unknown; } @@ -87,6 +103,8 @@ export interface Tenant { website_type_code?: string | null; website_type?: WebsiteType | null; extras?: WebsiteExtras; + active_event_id?: number | null; + active_event?: ActiveEvent | 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/features/store/pages/product-detail-page/product-detail-page.component.html b/src/app/features/store/pages/product-detail-page/product-detail-page.component.html index 1d18081..013ac34 100644 --- a/src/app/features/store/pages/product-detail-page/product-detail-page.component.html +++ b/src/app/features/store/pages/product-detail-page/product-detail-page.component.html @@ -35,6 +35,31 @@ + @if (hasEventDates()) { +
+ +
+ + +
+ } + @if (hasRenderableAttributes()) {
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 a969d30..f8e9fe0 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 @@ -327,6 +327,63 @@ describe('ProductDetailPageComponent', () => { expect((swatches[0] as HTMLElement).style.backgroundColor).not.toBe(''); }); + it('renders event dates and resolves the selected date through its variant', 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: {} }, + ], + event_dates: [ + { + 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, + }, + ], + selected_variant: { + id: 101, + event_date_id: 20, + stock_tecnico: 10, + images: [], + values: {}, + }, + }; + resolveProduct(detailProduct); + catalogServiceStub.getCatalogItem.mockReturnValue( + of({ ...detailProduct, selected_variant: { ...detailProduct.selected_variant!, id: 102 } }), + ); + + await configureTestingModule(); + 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'); + + select.value = '102'; + select.dispatchEvent(new Event('change')); + fixture.detectChanges(); + + expect(catalogServiceStub.getCatalogItem).toHaveBeenCalledWith(1, 102); + }); + it('hides the attributes block and its extra divider when no attributes are present', async () => { await configureTestingModule(); const fixture = TestBed.createComponent(ProductDetailPageComponent); 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 be6e219..cb188b4 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 @@ -100,6 +100,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { const variant = this.selectedVariant(); if (variant) return this.isVariantAvailable(variant, prod); + if (prod.purpose === 'entry') return false; if (prod.variants.length > 0) return false; return prod.inventory_policy === 'unlimited' || (prod.stock_tecnico ?? 0) > 0; @@ -108,8 +109,14 @@ 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()?.attributes ?? []).filter( + (attribute) => + attribute.options.length > 0 && + !(this.product()?.purpose === 'entry' && attribute.codigo === 'fecha'), + ), ); + protected readonly eventDates = computed(() => this.product()?.event_dates ?? []); + protected readonly hasEventDates = computed(() => this.eventDates().length > 0); protected readonly hasRenderableAttributes = computed( () => this.renderableAttributes().length > 0, ); @@ -159,26 +166,26 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { .withCustomLoading() .getCatalogItem(productId, variantId) .subscribe({ - next: (prod) => { - this.applyProduct(prod, false); - this.variantLoading.set(false); - }, - error: (err: HttpErrorResponse) => { - const errorMessage = err.error?.message || 'No pudimos cargar la variante seleccionada.'; - this.toastService.danger(errorMessage); - this.variantLoading.set(false); + next: (prod) => { + this.applyProduct(prod, false); + this.variantLoading.set(false); + }, + error: (err: HttpErrorResponse) => { + const errorMessage = err.error?.message || 'No pudimos cargar la variante seleccionada.'; + this.toastService.danger(errorMessage); + this.variantLoading.set(false); - this.product.update((currentProduct) => { - if (!currentProduct) return null; - return { - ...currentProduct, - variants: currentProduct.variants.filter((variant) => variant.id !== variantId), - }; - }); + this.product.update((currentProduct) => { + if (!currentProduct) return null; + return { + ...currentProduct, + variants: currentProduct.variants.filter((variant) => variant.id !== variantId), + }; + }); - this.attributeSelector()?.reset(); - }, - }); + this.attributeSelector()?.reset(); + }, + }); } private applyResolvedData(resolvedData: ProductDetailResolvedData): void { @@ -240,6 +247,12 @@ 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(); @@ -253,17 +266,17 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { .withCustomLoading() .addItem(currentProduct.id, variant?.id ?? null, this.quantity()) .subscribe({ - next: (res) => { - const msg = res.message || 'Producto agregado al carrito'; - this.toastService.success(msg); - this.addingToCart.set(false); - }, - error: (err: HttpErrorResponse) => { - const errorMessage = err.error?.message || 'No se pudo agregar el producto al carrito.'; - this.toastService.danger(errorMessage); - this.addingToCart.set(false); - }, - }); + next: (res) => { + const msg = res.message || 'Producto agregado al carrito'; + this.toastService.success(msg); + this.addingToCart.set(false); + }, + error: (err: HttpErrorResponse) => { + const errorMessage = err.error?.message || 'No se pudo agregar el producto al carrito.'; + this.toastService.danger(errorMessage); + this.addingToCart.set(false); + }, + }); } protected async buyNow(): Promise { 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 de69245..24ecd4d 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 @@ -16,6 +16,7 @@ import { Tenant } from '../../../../core/services/tenant.interface'; import { TenantService } from '../../../../core/services/tenant.service'; import { ToastService } from '../../../../core/services/toast.service'; import { ProductListComponent } from '../../../../shared/components/product-list/product-list.component'; +import { HeroBannerComponent } from '../../../../shared/components/hero-banner/hero-banner.component'; import { StoreHomePageComponent } from './store-home-page.component'; import { STORE_HOME_PRODUCTS_ERROR_MESSAGE, @@ -186,26 +187,42 @@ describe('StoreHomePageComponent', () => { expect(images?.[0].getAttribute('src')).toBe(carousel[0]); }); - it('renders OnTicket hero and event configs received in tenant extras', async () => { + it('renders the hero extra and the active tenant event', async () => { const tenant = createTenant({ heroConfig: { title_html: '

Fiesta Fútbol Infantil

', background_image_id: 'https://example.com/hero.jpg', }, - eventConfig: { - title: 'Fiesta Fútbol Infantil', - location: 'Rosario, Santa Fe', - dates_text: '5 y 6 de diciembre de 2026', - dates: [ - { - date: '2026-12-05', - start_time: '09:00', - end_time: '18:00', - }, - ], - }, }); + tenant.active_event_id = 10; + tenant.active_event = { + id: 10, + name: 'Fiesta Fútbol Infantil', + address: 'Rosario, Santa Fe', + dates: [ + { + id: 20, + date: '2026-12-05', + time_start: '09:00:00', + time_end: '18:00:00', + }, + { + id: 21, + date: '2026-12-06', + time_start: '09:00:00', + time_end: '18:00:00', + }, + ], + }; tenant.website_type_code = 'onticket'; + tenant.social_media = [ + { + code: 'whatsapp', + icon: 'fa-brands fa-whatsapp', + name: 'WhatsApp', + url: 'https://wa.me/5493410000000', + }, + ]; tenant.website_type = { codigo: 'onticket', nombre: 'OnTicket', @@ -242,7 +259,74 @@ describe('StoreHomePageComponent', () => { expect(hero.style.backgroundImage).toContain('https://example.com/hero.jpg'); expect(hero.textContent).toContain('Fiesta Fútbol Infantil'); expect(hero.textContent).toContain('Rosario, Santa Fe'); - expect(hero.textContent).toContain('5 y 6 de diciembre de 2026'); + expect(hero.textContent).toContain('2026-12-05, 2026-12-06'); + const heroComponent = fixture.debugElement.query(By.directive(HeroBannerComponent)) + .componentInstance as HeroBannerComponent; + expect(heroComponent.eventConfig?.contact).toEqual(tenant.social_media); + }); + + it('maps the active event and tenant social media to the event config', async () => { + const tenant = createTenant(); + tenant.active_event_id = 12; + tenant.active_event = { + id: 12, + name: 'Fiesta Fútbol Infantil', + address: 'Sunchales, Santa Fe', + dates: [ + { + id: 25, + date: '2026-10-09', + time_start: '09:00:00', + time_end: '18:00:00', + }, + ], + }; + tenant.social_media = [ + { + code: 'instagram', + icon: 'fa-brands fa-instagram', + name: 'Instagram', + url: 'https://instagram.com/fiesta', + }, + ]; + + await TestBed.configureTestingModule({ + imports: [StoreHomePageComponent], + providers: [ + provideActivatedRoute({ response: createCatalog(), error: null }), + { + provide: CatalogService, + useValue: { getCatalog: vi.fn(), getFeaturedGroupItems: vi.fn() }, + }, + { + provide: TenantService, + useValue: createTenantServiceStub(tenant), + }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(StoreHomePageComponent); + fixture.detectChanges(); + + const eventConfig = ( + fixture.debugElement.query(By.directive(HeroBannerComponent)) + .componentInstance as HeroBannerComponent + ).eventConfig; + + expect(eventConfig).toEqual({ + id: 12, + title: 'Fiesta Fútbol Infantil', + location: 'Sunchales, Santa Fe', + dates: [ + { + id: 25, + date: '2026-10-09', + start_time: '09:00:00', + end_time: '18:00:00', + }, + ], + contact: tenant.social_media, + }); }); it('requests another page for the selected featured group', async () => { 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 0723ab7..0c1f89d 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 @@ -62,7 +62,28 @@ export class StoreHomePageComponent implements OnInit, OnDestroy { protected readonly creatingDirectPurchase = signal(false); protected readonly mainCarouselImages = computed(() => this.tenant()?.extras?.carousel ?? []); protected readonly heroConfig = computed(() => this.tenant()?.extras?.heroConfig ?? null); - protected readonly eventConfig = computed(() => this.tenant()?.extras?.eventConfig ?? null); + protected readonly eventConfig = computed(() => { + const tenant = this.tenant(); + const contact = tenant?.social_media ?? []; + const activeEvent = tenant?.active_event; + + if (activeEvent) { + return { + id: activeEvent.id, + title: activeEvent.name, + location: activeEvent.address, + dates: activeEvent.dates.map((eventDate) => ({ + id: eventDate.id, + date: eventDate.date, + start_time: eventDate.time_start, + end_time: eventDate.time_end, + })), + contact, + }; + } + + return null; + }); protected readonly hasMainCarouselImages = computed(() => this.mainCarouselImages().length > 0); private catalogRequestSubscription: Subscription | null = null;