feat: enhance product and tenant interfaces to support event dates, update product detail page to display event date selection
This commit is contained in:
@@ -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[];
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user