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 {
|
||||
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<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 {
|
||||
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[];
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -35,6 +35,31 @@
|
||||
</div>
|
||||
</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()) {
|
||||
<div class="product-detail__divider"></div>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<void> {
|
||||
|
||||
@@ -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: '<h1>Fiesta Fútbol Infantil</h1>',
|
||||
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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user