feat: enhance product and tenant interfaces to support event dates, update product detail page to display event date selection

This commit is contained in:
2026-08-03 14:24:42 -03:00
parent ec18aaf0c5
commit ffb586c348
7 changed files with 277 additions and 46 deletions

View File

@@ -35,6 +35,7 @@ export type InventoryPolicy = 'tracked' | 'unlimited';
export interface CatalogItemVariant { export interface CatalogItemVariant {
id: number; id: number;
event_date_id?: number | null;
stock_tecnico: number | null; stock_tecnico: number | null;
minimum_use_date?: string | null; minimum_use_date?: string | null;
maximum_use_date?: string | null; maximum_use_date?: string | null;
@@ -43,12 +44,23 @@ export interface CatalogItemVariant {
values: Record<string, string>; values: Record<string, string>;
} }
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 { export interface SelectedCatalogItemVariant extends CatalogItemVariant {
images: string[]; images: string[];
} }
export interface CatalogItemDetail { export interface CatalogItemDetail {
id: number; id: number;
purpose?: 'product' | 'entry';
category_id: number | null; category_id: number | null;
brand_id: number | null; brand_id: number | null;
slug: string; slug: string;
@@ -63,6 +75,7 @@ export interface CatalogItemDetail {
maximum_use_date: string | null; maximum_use_date: string | null;
attributes: ProductAttribute[]; attributes: ProductAttribute[];
variants: CatalogItemVariant[]; variants: CatalogItemVariant[];
event_dates?: CatalogItemEventDate[];
selected_variant?: SelectedCatalogItemVariant; selected_variant?: SelectedCatalogItemVariant;
stock_tecnico?: number | null; stock_tecnico?: number | null;
images?: string[]; images?: string[];

View File

@@ -28,22 +28,38 @@ export interface HeroConfig {
} }
export interface EventDate { export interface EventDate {
id?: number;
date: string; date: string;
start_time: string; start_time: string;
end_time: string; end_time: string;
} }
export interface EventConfig { export interface EventConfig {
id?: number;
title?: string | null; title?: string | null;
location?: string | null; location?: string | null;
dates_text?: string | null; dates_text?: string | null;
dates?: EventDate[] | 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 { export interface WebsiteExtras {
carousel?: string[]; carousel?: string[];
heroConfig?: HeroConfig | null; heroConfig?: HeroConfig | null;
eventConfig?: EventConfig | null;
[extraName: string]: unknown; [extraName: string]: unknown;
} }
@@ -87,6 +103,8 @@ export interface Tenant {
website_type_code?: string | null; website_type_code?: string | null;
website_type?: WebsiteType | null; website_type?: WebsiteType | null;
extras?: WebsiteExtras; extras?: WebsiteExtras;
active_event_id?: number | null;
active_event?: ActiveEvent | null;
selected_bank_account_id?: number | null; selected_bank_account_id?: number | null;
selected_bank_account?: BankAccount | null; selected_bank_account?: BankAccount | null;
search_product_layout?: 'row' | 'column_with_image' | 'column_with_cart'; search_product_layout?: 'row' | 'column_with_image' | 'column_with_cart';

View File

@@ -35,6 +35,31 @@
</div> </div>
</section> </section>
@if (hasEventDates()) {
<div class="product-detail__divider"></div>
<section class="product-detail__section product-detail__section--event-date">
<label class="form-label" for="event-date-selector">Fecha</label>
<select
id="event-date-selector"
class="form-select"
[value]="selectedVariant()?.id ?? ''"
(change)="onEventDateChange($event)"
>
@for (eventDate of eventDates(); track eventDate.id) {
<option
[value]="eventDate.variant_id"
[disabled]="
prod.inventory_policy !== 'unlimited' && (eventDate.stock_tecnico ?? 0) <= 0
"
>
{{ eventDate.label }}
</option>
}
</select>
</section>
}
@if (hasRenderableAttributes()) { @if (hasRenderableAttributes()) {
<div class="product-detail__divider"></div> <div class="product-detail__divider"></div>

View File

@@ -327,6 +327,63 @@ describe('ProductDetailPageComponent', () => {
expect((swatches[0] as HTMLElement).style.backgroundColor).not.toBe(''); 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 () => { it('hides the attributes block and its extra divider when no attributes are present', async () => {
await configureTestingModule(); await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent); const fixture = TestBed.createComponent(ProductDetailPageComponent);

View File

@@ -100,6 +100,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
const variant = this.selectedVariant(); const variant = this.selectedVariant();
if (variant) return this.isVariantAvailable(variant, prod); if (variant) return this.isVariantAvailable(variant, prod);
if (prod.purpose === 'entry') return false;
if (prod.variants.length > 0) return false; if (prod.variants.length > 0) return false;
return prod.inventory_policy === 'unlimited' || (prod.stock_tecnico ?? 0) > 0; 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 descriptionMaxHeight = signal(0);
protected readonly descriptionHasOverflow = signal(false); protected readonly descriptionHasOverflow = signal(false);
protected readonly renderableAttributes = computed(() => 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( protected readonly hasRenderableAttributes = computed(
() => this.renderableAttributes().length > 0, () => this.renderableAttributes().length > 0,
); );
@@ -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 { protected addToCart(): void {
const currentProduct = this.product(); const currentProduct = this.product();
const variant = this.selectedVariant(); const variant = this.selectedVariant();

View File

@@ -16,6 +16,7 @@ import { Tenant } from '../../../../core/services/tenant.interface';
import { TenantService } from '../../../../core/services/tenant.service'; import { TenantService } from '../../../../core/services/tenant.service';
import { ToastService } from '../../../../core/services/toast.service'; import { ToastService } from '../../../../core/services/toast.service';
import { ProductListComponent } from '../../../../shared/components/product-list/product-list.component'; 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 { StoreHomePageComponent } from './store-home-page.component';
import { import {
STORE_HOME_PRODUCTS_ERROR_MESSAGE, STORE_HOME_PRODUCTS_ERROR_MESSAGE,
@@ -186,26 +187,42 @@ describe('StoreHomePageComponent', () => {
expect(images?.[0].getAttribute('src')).toBe(carousel[0]); 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({ const tenant = createTenant({
heroConfig: { heroConfig: {
title_html: '<h1>Fiesta Fútbol Infantil</h1>', title_html: '<h1>Fiesta Fútbol Infantil</h1>',
background_image_id: 'https://example.com/hero.jpg', background_image_id: 'https://example.com/hero.jpg',
}, },
eventConfig: { });
title: 'Fiesta Fútbol Infantil', tenant.active_event_id = 10;
location: 'Rosario, Santa Fe', tenant.active_event = {
dates_text: '5 y 6 de diciembre de 2026', id: 10,
name: 'Fiesta Fútbol Infantil',
address: 'Rosario, Santa Fe',
dates: [ dates: [
{ {
id: 20,
date: '2026-12-05', date: '2026-12-05',
start_time: '09:00', time_start: '09:00:00',
end_time: '18: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.website_type_code = 'onticket';
tenant.social_media = [
{
code: 'whatsapp',
icon: 'fa-brands fa-whatsapp',
name: 'WhatsApp',
url: 'https://wa.me/5493410000000',
},
];
tenant.website_type = { tenant.website_type = {
codigo: 'onticket', codigo: 'onticket',
nombre: 'OnTicket', nombre: 'OnTicket',
@@ -242,7 +259,74 @@ describe('StoreHomePageComponent', () => {
expect(hero.style.backgroundImage).toContain('https://example.com/hero.jpg'); expect(hero.style.backgroundImage).toContain('https://example.com/hero.jpg');
expect(hero.textContent).toContain('Fiesta Fútbol Infantil'); expect(hero.textContent).toContain('Fiesta Fútbol Infantil');
expect(hero.textContent).toContain('Rosario, Santa Fe'); 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 () => { it('requests another page for the selected featured group', async () => {

View File

@@ -62,7 +62,28 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
protected readonly creatingDirectPurchase = signal(false); protected readonly creatingDirectPurchase = signal(false);
protected readonly mainCarouselImages = computed(() => this.tenant()?.extras?.carousel ?? []); protected readonly mainCarouselImages = computed(() => this.tenant()?.extras?.carousel ?? []);
protected readonly heroConfig = computed(() => this.tenant()?.extras?.heroConfig ?? null); 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); protected readonly hasMainCarouselImages = computed(() => this.mainCarouselImages().length > 0);
private catalogRequestSubscription: Subscription | null = null; private catalogRequestSubscription: Subscription | null = null;