From 934bd07105511b3ff7c31394cc6eb8874dab6741 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 29 Jul 2026 15:46:02 -0300 Subject: [PATCH 01/26] feat: enhance tenant interface with website type and extras, update store home page to utilize new data structure --- src/app/core/services/tenant.interface.ts | 20 ++++-- .../store-home-page.component.html | 13 ++-- .../store-home-page.component.spec.ts | 66 +++++++++++-------- .../store-home-page.component.ts | 51 +++++++------- .../hero-banner/hero-banner.component.html | 26 +++++--- .../hero-banner/hero-banner.component.ts | 14 ++-- 6 files changed, 110 insertions(+), 80 deletions(-) diff --git a/src/app/core/services/tenant.interface.ts b/src/app/core/services/tenant.interface.ts index 51a87cf..0207fe4 100644 --- a/src/app/core/services/tenant.interface.ts +++ b/src/app/core/services/tenant.interface.ts @@ -9,8 +9,13 @@ export interface BankAccount { cvu: string; } +export interface WebsiteType { + codigo: string; + nombre: string; +} + export interface HeroConfig { - background_image?: string | null; + background_image_id?: string | null; title_html?: string | null; description_html?: string | null; button_text?: string | null; @@ -23,6 +28,13 @@ export interface EventConfig { dates?: string[] | null; } +export interface WebsiteExtras { + carousel?: string[]; + heroConfig?: HeroConfig | null; + eventConfig?: EventConfig | null; + [extraName: string]: unknown; +} + export interface Menu { id: number; code: string; @@ -60,14 +72,14 @@ export interface Tenant { footer_bg_color: string; header_logo: string; footer_logo: string; + website_type_code?: string | null; + website_type?: WebsiteType | null; + extras?: WebsiteExtras; selected_bank_account_id?: number | null; selected_bank_account?: BankAccount | null; - hero_config?: HeroConfig | null; - event_config?: EventConfig | null; search_product_layout?: 'row' | 'column_with_image' | 'column_with_cart'; search_group_layout?: 'paginated' | 'simple' | 'simple_vertical' | 'carousel'; search_items_per_page?: number; - main_carousel_images?: string[]; social_media?: SocialMedia[]; menues?: Menu[]; categories: Category[]; diff --git a/src/app/features/store/pages/store-home-page/store-home-page.component.html b/src/app/features/store/pages/store-home-page/store-home-page.component.html index d62e95a..514396a 100644 --- a/src/app/features/store/pages/store-home-page/store-home-page.component.html +++ b/src/app/features/store/pages/store-home-page/store-home-page.component.html @@ -1,19 +1,16 @@ -@if (tenant()?.main_carousel_images; as mainCarouselImages) { - @if (mainCarouselImages.length) { +@if (mainCarouselImages(); as images) { + @if (images.length) { } } -@if (tenant()?.hero_config || tenant()?.event_config) { - +@if (heroConfig() || eventConfig()) { + } @if (error()) { 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 982483f..3c1e1c8 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 @@ -88,7 +88,7 @@ const pageOneItems: CatalogFeaturedItem[] = [ }, ]; -function createTenant(mainCarouselImages?: string[]): Tenant { +function createTenant(extras: Tenant['extras'] = {}): Tenant { return { id: 1, codigo: 'acme', @@ -102,8 +102,10 @@ function createTenant(mainCarouselImages?: string[]): Tenant { footer_bg_color: '#ffffff', header_logo: '/header.png', footer_logo: '/footer.png', + website_type_code: 'shopit', + website_type: { codigo: 'shopit', nombre: 'ShopIt' }, + extras, categories: [], - main_carousel_images: mainCarouselImages, }; } @@ -146,11 +148,8 @@ describe('StoreHomePageComponent', () => { ); }); - it('renders the tenant main carousel images at the beginning of the home page', async () => { - const mainCarouselImages = [ - 'https://example.com/carousel-1.webp', - 'https://example.com/carousel-2.webp', - ]; + it('renders the carousel URLs received in tenant extras', async () => { + const carousel = ['https://example.com/carousel-1.webp', 'https://example.com/carousel-2.webp']; await TestBed.configureTestingModule({ imports: [StoreHomePageComponent], @@ -162,7 +161,7 @@ describe('StoreHomePageComponent', () => { }, { provide: TenantService, - useValue: createTenantServiceStub(createTenant(mainCarouselImages)), + useValue: createTenantServiceStub(createTenant({ carousel })), }, ], }).compileComponents(); @@ -171,25 +170,29 @@ describe('StoreHomePageComponent', () => { fixture.detectChanges(); const element = fixture.nativeElement as HTMLElement; - const carousel = element.querySelector('app-main-carousel'); - const images = carousel?.querySelectorAll('.main-carousel__image'); + const carouselElement = element.querySelector('app-main-carousel'); + const images = carouselElement?.querySelectorAll('.main-carousel__image'); - expect(element.firstElementChild).toBe(carousel); + expect(element.firstElementChild).toBe(carouselElement); expect(images).toHaveLength(2); - expect(images?.[0].getAttribute('src')).toBe(mainCarouselImages[0]); - expect(images?.[1].getAttribute('src')).toBeNull(); - expect(element.querySelectorAll('app-product-column-with-image img')).toHaveLength(0); - - images?.[0].dispatchEvent(new Event('load')); - fixture.detectChanges(); - - expect(images?.[1].getAttribute('src')).toBe(mainCarouselImages[1]); - expect(element.querySelectorAll('app-product-column-with-image img')).toHaveLength(1); - - fixture.destroy(); + expect(images?.[0].getAttribute('src')).toBe(carousel[0]); }); - it('does not render the main carousel when the tenant has no images', async () => { + it('renders OnTicket hero and event configs received in tenant extras', 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: ['2026-12-05'], + }, + }); + tenant.website_type_code = 'onticket'; + tenant.website_type = { codigo: 'onticket', nombre: 'OnTicket' }; + await TestBed.configureTestingModule({ imports: [StoreHomePageComponent], providers: [ @@ -200,7 +203,7 @@ describe('StoreHomePageComponent', () => { }, { provide: TenantService, - useValue: createTenantServiceStub(createTenant([])), + useValue: createTenantServiceStub(tenant), }, ], }).compileComponents(); @@ -208,7 +211,14 @@ describe('StoreHomePageComponent', () => { const fixture = TestBed.createComponent(StoreHomePageComponent); fixture.detectChanges(); - expect((fixture.nativeElement as HTMLElement).querySelector('app-main-carousel')).toBeNull(); + const hero = (fixture.nativeElement as HTMLElement).querySelector( + 'app-hero-banner .hero-banner', + ) as HTMLElement; + + expect(hero).not.toBeNull(); + 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'); }); it('requests another page for the selected featured group', async () => { @@ -323,9 +333,9 @@ describe('StoreHomePageComponent', () => { it('adds a product-list cart event to the cart', async () => { const catalogServiceStub = { getCatalog: vi.fn(), getFeaturedGroupItems: vi.fn() }; const cartServiceStub = { - addItem: vi.fn().mockReturnValue( - of({ data: {}, message: 'Producto agregado correctamente' }), - ), + addItem: vi + .fn() + .mockReturnValue(of({ data: {}, message: 'Producto agregado correctamente' })), }; const toastServiceStub = { success: vi.fn(), danger: vi.fn() }; 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 bac1577..0723ab7 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 @@ -60,9 +60,10 @@ export class StoreHomePageComponent implements OnInit, OnDestroy { protected readonly error = signal(null); protected readonly mainCarouselReady = signal(false); protected readonly creatingDirectPurchase = signal(false); - protected readonly hasMainCarouselImages = computed( - () => (this.tenant()?.main_carousel_images?.length ?? 0) > 0, - ); + 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 hasMainCarouselImages = computed(() => this.mainCarouselImages().length > 0); private catalogRequestSubscription: Subscription | null = null; private readonly groupRequestSubscriptions = new Map(); @@ -109,20 +110,20 @@ export class StoreHomePageComponent implements OnInit, OnDestroy { .withCustomLoading() .getFeaturedGroupItems(groupId, { page }) .subscribe({ - next: (items) => { - this.catalog.update((groups) => - groups.map((candidate) => - candidate.id === groupId ? { ...candidate, items } : candidate, - ), - ); - this.error.set(null); - }, - error: () => { - this.error.set(STORE_HOME_PRODUCTS_ERROR_MESSAGE); - this.setGroupLoading(groupId, false); - }, - complete: () => this.setGroupLoading(groupId, false), - }); + next: (items) => { + this.catalog.update((groups) => + groups.map((candidate) => + candidate.id === groupId ? { ...candidate, items } : candidate, + ), + ); + this.error.set(null); + }, + error: () => { + this.error.set(STORE_HOME_PRODUCTS_ERROR_MESSAGE); + this.setGroupLoading(groupId, false); + }, + complete: () => this.setGroupLoading(groupId, false), + }); this.groupRequestSubscriptions.set(groupId, subscription); } @@ -189,14 +190,14 @@ export class StoreHomePageComponent implements OnInit, OnDestroy { .withCustomLoading() .getCatalog() .subscribe({ - next: (catalog) => this.catalog.set(catalog), - error: () => { - this.catalog.set([]); - this.loading.set(false); - this.error.set(STORE_HOME_PRODUCTS_ERROR_MESSAGE); - }, - complete: () => this.loading.set(false), - }); + next: (catalog) => this.catalog.set(catalog), + error: () => { + this.catalog.set([]); + this.loading.set(false); + this.error.set(STORE_HOME_PRODUCTS_ERROR_MESSAGE); + }, + complete: () => this.loading.set(false), + }); } private applyResolvedData(resolvedData: StoreHomeCatalogResolvedData): void { diff --git a/src/app/shared/components/hero-banner/hero-banner.component.html b/src/app/shared/components/hero-banner/hero-banner.component.html index e544c02..5ca582b 100644 --- a/src/app/shared/components/hero-banner/hero-banner.component.html +++ b/src/app/shared/components/hero-banner/hero-banner.component.html @@ -1,11 +1,16 @@
-
- @if (heroConfig) { -
+
@if (heroConfig.title_html) {
@@ -14,7 +19,10 @@
} @if (heroConfig.button_text) { - + {{ heroConfig.button_text }} @@ -30,8 +38,10 @@ @if (eventConfig.title) {

{{ eventConfig.title }}

} - -
+ +
@if (eventConfig.dates && eventConfig.dates.length > 0) {
diff --git a/src/app/shared/components/hero-banner/hero-banner.component.ts b/src/app/shared/components/hero-banner/hero-banner.component.ts index 54a75cd..c540993 100644 --- a/src/app/shared/components/hero-banner/hero-banner.component.ts +++ b/src/app/shared/components/hero-banner/hero-banner.component.ts @@ -1,7 +1,7 @@ import { Component, ChangeDetectionStrategy, Input } from '@angular/core'; import { CommonModule } from '@angular/common'; import { RouterModule } from '@angular/router'; -import { HeroConfig, EventConfig } from '../../../core/services/tenant.interface'; +import { EventConfig, HeroConfig } from '../../../core/services/tenant.interface'; import { ButtonComponent } from '../button/button.component'; @Component({ @@ -20,20 +20,20 @@ export class HeroBannerComponent { if (!this.eventConfig?.dates || this.eventConfig.dates.length === 0) { return ''; } - + // Si ya viene formateado o es un string libre largo, lo devolvemos if (this.eventConfig.dates.length === 1 && this.eventConfig.dates[0].length > 10) { return this.eventConfig.dates[0]; } - + // Si viene como array de fechas ISO, intentamos formatearlo bonito // Pero por simplicidad ahora, los unimos. // Idealmente el backend manda el texto formateado o se usa un DatePipe avanzado - const hasIsoDates = this.eventConfig.dates.some(d => d.includes('-')); - + const hasIsoDates = this.eventConfig.dates.some((d) => d.includes('-')); + if (hasIsoDates) { - // Un simple join por si acaso, aunque lo ideal es que el admin ponga el texto tal cual - return '9, 10, 11 y 12 de Octubre 2026'; // Placeholder basado en los requerimientos, si no se envía como string formateado + // Un simple join por si acaso, aunque lo ideal es que el admin ponga el texto tal cual + return '9, 10, 11 y 12 de Octubre 2026'; // Placeholder basado en los requerimientos, si no se envía como string formateado } return this.eventConfig.dates.join(', '); From 941cb5c73cb893ba24661ed5c7bf8cc4ec16b05b Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 30 Jul 2026 16:32:37 -0300 Subject: [PATCH 02/26] feat: add dates_text to EventConfig and update formattedDates method in HeroBannerComponent --- src/app/core/services/tenant.interface.ts | 1 + .../store-home-page.component.spec.ts | 2 ++ .../hero-banner/hero-banner.component.ts | 19 ++++--------------- 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/src/app/core/services/tenant.interface.ts b/src/app/core/services/tenant.interface.ts index 0207fe4..267ca26 100644 --- a/src/app/core/services/tenant.interface.ts +++ b/src/app/core/services/tenant.interface.ts @@ -25,6 +25,7 @@ export interface HeroConfig { export interface EventConfig { title?: string | null; location?: string | null; + dates_text?: string | null; dates?: string[] | null; } 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 3c1e1c8..334cac9 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 @@ -187,6 +187,7 @@ describe('StoreHomePageComponent', () => { eventConfig: { title: 'Fiesta Fútbol Infantil', location: 'Rosario, Santa Fe', + dates_text: '5 y 6 de diciembre de 2026', dates: ['2026-12-05'], }, }); @@ -219,6 +220,7 @@ 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'); }); it('requests another page for the selected featured group', async () => { diff --git a/src/app/shared/components/hero-banner/hero-banner.component.ts b/src/app/shared/components/hero-banner/hero-banner.component.ts index c540993..5662c57 100644 --- a/src/app/shared/components/hero-banner/hero-banner.component.ts +++ b/src/app/shared/components/hero-banner/hero-banner.component.ts @@ -17,25 +17,14 @@ export class HeroBannerComponent { @Input() eventConfig?: EventConfig | null; get formattedDates(): string { + if (this.eventConfig?.dates_text) { + return this.eventConfig.dates_text; + } + if (!this.eventConfig?.dates || this.eventConfig.dates.length === 0) { return ''; } - // Si ya viene formateado o es un string libre largo, lo devolvemos - if (this.eventConfig.dates.length === 1 && this.eventConfig.dates[0].length > 10) { - return this.eventConfig.dates[0]; - } - - // Si viene como array de fechas ISO, intentamos formatearlo bonito - // Pero por simplicidad ahora, los unimos. - // Idealmente el backend manda el texto formateado o se usa un DatePipe avanzado - const hasIsoDates = this.eventConfig.dates.some((d) => d.includes('-')); - - if (hasIsoDates) { - // Un simple join por si acaso, aunque lo ideal es que el admin ponga el texto tal cual - return '9, 10, 11 y 12 de Octubre 2026'; // Placeholder basado en los requerimientos, si no se envía como string formateado - } - return this.eventConfig.dates.join(', '); } } From ec18aaf0c57ef07d4b9de1f2235e392e63b3eb82 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 3 Aug 2026 09:09:58 -0300 Subject: [PATCH 03/26] feat: update WebsiteType and EventConfig interfaces with new properties, enhance tenant creation in tests --- src/app/core/services/tenant.interface.ts | 13 ++++++++- .../store-home-page.component.spec.ts | 28 +++++++++++++++++-- .../hero-banner/hero-banner.component.ts | 2 +- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/app/core/services/tenant.interface.ts b/src/app/core/services/tenant.interface.ts index 267ca26..b7ef734 100644 --- a/src/app/core/services/tenant.interface.ts +++ b/src/app/core/services/tenant.interface.ts @@ -12,6 +12,11 @@ export interface BankAccount { export interface WebsiteType { codigo: string; nombre: string; + primary_color: string | null; + secondary_color: string | null; + danger_color: string | null; + success_color: string | null; + site_logo: string | null; } export interface HeroConfig { @@ -22,11 +27,17 @@ export interface HeroConfig { button_href?: string | null; } +export interface EventDate { + date: string; + start_time: string; + end_time: string; +} + export interface EventConfig { title?: string | null; location?: string | null; dates_text?: string | null; - dates?: string[] | null; + dates?: EventDate[] | null; } export interface WebsiteExtras { 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 334cac9..de69245 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 @@ -103,7 +103,15 @@ function createTenant(extras: Tenant['extras'] = {}): Tenant { header_logo: '/header.png', footer_logo: '/footer.png', website_type_code: 'shopit', - website_type: { codigo: 'shopit', nombre: 'ShopIt' }, + website_type: { + codigo: 'shopit', + nombre: 'ShopIt', + primary_color: null, + secondary_color: null, + danger_color: null, + success_color: null, + site_logo: null, + }, extras, categories: [], }; @@ -188,11 +196,25 @@ describe('StoreHomePageComponent', () => { title: 'Fiesta Fútbol Infantil', location: 'Rosario, Santa Fe', dates_text: '5 y 6 de diciembre de 2026', - dates: ['2026-12-05'], + dates: [ + { + date: '2026-12-05', + start_time: '09:00', + end_time: '18:00', + }, + ], }, }); tenant.website_type_code = 'onticket'; - tenant.website_type = { codigo: 'onticket', nombre: 'OnTicket' }; + tenant.website_type = { + codigo: 'onticket', + nombre: 'OnTicket', + primary_color: null, + secondary_color: null, + danger_color: null, + success_color: null, + site_logo: null, + }; await TestBed.configureTestingModule({ imports: [StoreHomePageComponent], diff --git a/src/app/shared/components/hero-banner/hero-banner.component.ts b/src/app/shared/components/hero-banner/hero-banner.component.ts index 5662c57..2a32e6b 100644 --- a/src/app/shared/components/hero-banner/hero-banner.component.ts +++ b/src/app/shared/components/hero-banner/hero-banner.component.ts @@ -25,6 +25,6 @@ export class HeroBannerComponent { return ''; } - return this.eventConfig.dates.join(', '); + return this.eventConfig.dates.map(({ date }) => date).join(', '); } } From ffb586c348a98919e924a687f3da09f0dac40628 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 3 Aug 2026 14:24:42 -0300 Subject: [PATCH 04/26] feat: enhance product and tenant interfaces to support event dates, update product detail page to display event date selection --- .../services/catalog/catalog.interface.ts | 13 ++ src/app/core/services/tenant.interface.ts | 20 +++- .../product-detail-page.component.html | 25 ++++ .../product-detail-page.component.spec.ts | 57 +++++++++ .../product-detail-page.component.ts | 73 +++++++----- .../store-home-page.component.spec.ts | 112 +++++++++++++++--- .../store-home-page.component.ts | 23 +++- 7 files changed, 277 insertions(+), 46 deletions(-) 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; From cc884e3cc0bd8582009a9424442cbefdd282b35f Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 3 Aug 2026 15:08:13 -0300 Subject: [PATCH 05/26] feat: add description_html to tenant heroConfig and update tests for hero banner rendering --- .../store-home-page.component.spec.ts | 42 +++++++------------ .../hero-banner/hero-banner.component.html | 11 +++-- 2 files changed, 22 insertions(+), 31 deletions(-) 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 24ecd4d..b780e7e 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 @@ -191,6 +191,7 @@ describe('StoreHomePageComponent', () => { const tenant = createTenant({ heroConfig: { title_html: '

Fiesta Fútbol Infantil

', + description_html: '

Viví una jornada inolvidable de fútbol infantil.

', background_image_id: 'https://example.com/hero.jpg', }, }); @@ -258,6 +259,7 @@ describe('StoreHomePageComponent', () => { expect(hero).not.toBeNull(); expect(hero.style.backgroundImage).toContain('https://example.com/hero.jpg'); expect(hero.textContent).toContain('Fiesta Fútbol Infantil'); + expect(hero.textContent).toContain('Viví una jornada inolvidable de fútbol infantil.'); expect(hero.textContent).toContain('Rosario, Santa Fe'); expect(hero.textContent).toContain('2026-12-05, 2026-12-06'); const heroComponent = fixture.debugElement.query(By.directive(HeroBannerComponent)) @@ -265,7 +267,7 @@ describe('StoreHomePageComponent', () => { expect(heroComponent.eventConfig?.contact).toEqual(tenant.social_media); }); - it('maps the active event and tenant social media to the event config', async () => { + 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 = { @@ -281,15 +283,6 @@ describe('StoreHomePageComponent', () => { }, ], }; - tenant.social_media = [ - { - code: 'instagram', - icon: 'fa-brands fa-instagram', - name: 'Instagram', - url: 'https://instagram.com/fiesta', - }, - ]; - await TestBed.configureTestingModule({ imports: [StoreHomePageComponent], providers: [ @@ -308,25 +301,18 @@ describe('StoreHomePageComponent', () => { const fixture = TestBed.createComponent(StoreHomePageComponent); fixture.detectChanges(); - const eventConfig = ( - fixture.debugElement.query(By.directive(HeroBannerComponent)) - .componentInstance as HeroBannerComponent - ).eventConfig; + const element = fixture.nativeElement as HTMLElement; + const heroComponent = fixture.debugElement.query(By.directive(HeroBannerComponent)) + .componentInstance as HeroBannerComponent; - 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, - }); + expect(heroComponent.heroConfig).toBeNull(); + expect(element.querySelector('.hero-banner')).toBeNull(); + expect(element.querySelector('.hero-banner-container')).toBeNull(); + expect(element.querySelector('.hero-content')).toBeNull(); + expect(element.querySelector('.event-card')).not.toBeNull(); + expect(element.textContent).toContain('Fiesta Fútbol Infantil'); + expect(element.textContent).toContain('Sunchales, Santa Fe'); + expect(element.textContent).toContain('2026-10-09'); }); it('requests another page for the selected featured group', async () => { diff --git a/src/app/shared/components/hero-banner/hero-banner.component.html b/src/app/shared/components/hero-banner/hero-banner.component.html index 5ca582b..15c7b16 100644 --- a/src/app/shared/components/hero-banner/hero-banner.component.html +++ b/src/app/shared/components/hero-banner/hero-banner.component.html @@ -1,6 +1,8 @@ -
+
+
@if (eventConfig.title) {

{{ eventConfig.title }}

From 2487546a759fffdb64041f0a54e67ea5c88b95d2 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 3 Aug 2026 15:08:33 -0300 Subject: [PATCH 06/26] feat: add additionalInfoConfig to tenant interface and implement rendering in store home page --- src/app/core/services/tenant.interface.ts | 5 ++ .../store-home-page.component.html | 6 ++ .../store-home-page.component.scss | 19 ++++++ .../store-home-page.component.spec.ts | 62 +++++++++++++++++++ .../store-home-page.component.ts | 3 + 5 files changed, 95 insertions(+) diff --git a/src/app/core/services/tenant.interface.ts b/src/app/core/services/tenant.interface.ts index 8a310a5..a27bf1c 100644 --- a/src/app/core/services/tenant.interface.ts +++ b/src/app/core/services/tenant.interface.ts @@ -27,6 +27,10 @@ export interface HeroConfig { button_href?: string | null; } +export interface AdditionalInfoConfig { + description?: string | null; +} + export interface EventDate { id?: number; date: string; @@ -60,6 +64,7 @@ export interface ActiveEvent { export interface WebsiteExtras { carousel?: string[]; heroConfig?: HeroConfig | null; + additionalInfoConfig?: AdditionalInfoConfig | null; [extraName: string]: unknown; } diff --git a/src/app/features/store/pages/store-home-page/store-home-page.component.html b/src/app/features/store/pages/store-home-page/store-home-page.component.html index 514396a..619ba61 100644 --- a/src/app/features/store/pages/store-home-page/store-home-page.component.html +++ b/src/app/features/store/pages/store-home-page/store-home-page.component.html @@ -37,3 +37,9 @@ } } + +@if (additionalInfo(); as content) { + +
+
+} diff --git a/src/app/features/store/pages/store-home-page/store-home-page.component.scss b/src/app/features/store/pages/store-home-page/store-home-page.component.scss index 080b2cb..3ee8682 100644 --- a/src/app/features/store/pages/store-home-page/store-home-page.component.scss +++ b/src/app/features/store/pages/store-home-page/store-home-page.component.scss @@ -11,6 +11,25 @@ app-store-section + app-store-section { margin-top: clamp(3rem, 6vw, 5rem); } +:host > .store-home__additional-info:not(:first-child) { + margin-top: clamp(3rem, 6vw, 5rem); +} + +.store-home__additional-info-content { + width: 100%; + text-align: center; +} + +::ng-deep .store-home__additional-info-content img { + display: block; + margin-right: auto; + margin-left: auto; +} + +::ng-deep .store-home__additional-info-content > :last-child { + margin-bottom: 0; +} + .store-home__alert-error { margin: 0; border-color: rgba(var(--tenant-danger-rgb, 220, 53, 69), 0.18); 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 b780e7e..f0ba32f 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 @@ -267,6 +267,68 @@ describe('StoreHomePageComponent', () => { expect(heroComponent.eventConfig?.contact).toEqual(tenant.social_media); }); + it('renders additional information as an HTML section', async () => { + const description = + '

Consultá los términos del evento.

  • Ingreso con DNI
'; + + await TestBed.configureTestingModule({ + imports: [StoreHomePageComponent], + providers: [ + provideActivatedRoute({ response: createCatalog(), error: null }), + { + provide: CatalogService, + useValue: { getCatalog: vi.fn(), getFeaturedGroupItems: vi.fn() }, + }, + { + provide: TenantService, + useValue: createTenantServiceStub( + createTenant({ additionalInfoConfig: { description } }), + ), + }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(StoreHomePageComponent); + fixture.detectChanges(); + + const element = fixture.nativeElement as HTMLElement; + const section = element.querySelector('.store-home__additional-info'); + const sections = element.querySelectorAll(':scope > app-store-section'); + + expect(sections[sections.length - 1]).toBe(section); + expect(section?.querySelector('.store-section__title')?.textContent?.trim()).toBe( + 'Información adicional', + ); + expect(section?.querySelector('strong')?.textContent).toBe('términos del evento'); + expect(section?.querySelector('li')?.textContent).toBe('Ingreso con DNI'); + }); + + it('does not render the additional information section without content', async () => { + await TestBed.configureTestingModule({ + imports: [StoreHomePageComponent], + providers: [ + provideActivatedRoute({ response: createCatalog(), error: null }), + { + provide: CatalogService, + useValue: { getCatalog: vi.fn(), getFeaturedGroupItems: vi.fn() }, + }, + { + provide: TenantService, + useValue: createTenantServiceStub( + createTenant({ additionalInfoConfig: { description: ' ' } }), + ), + }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(StoreHomePageComponent); + fixture.detectChanges(); + + expect( + (fixture.nativeElement as HTMLElement).querySelector('.store-home__additional-info'), + ).toBeNull(); + }); + 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; 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 0c1f89d..ac6b27a 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,6 +62,9 @@ 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 additionalInfo = computed( + () => this.tenant()?.extras?.additionalInfoConfig?.description?.trim() || null, + ); protected readonly eventConfig = computed(() => { const tenant = this.tenant(); const contact = tenant?.social_media ?? []; From f289e9e2e49961edb7b82b5eeb55773df4707431 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 3 Aug 2026 17:04:07 -0300 Subject: [PATCH 07/26] feat: add expires_at to PurchaseDetailResponse and update checkout logic for pending payments --- src/app/core/services/checkout.service.ts | 1 + .../checkout-page.component.spec.ts | 31 +++++++++++++++---- .../checkout-page/checkout-page.component.ts | 8 +++-- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/app/core/services/checkout.service.ts b/src/app/core/services/checkout.service.ts index 81d4cfc..9d7d996 100644 --- a/src/app/core/services/checkout.service.ts +++ b/src/app/core/services/checkout.service.ts @@ -61,6 +61,7 @@ export interface PurchaseDetailResponse extends PurchaseStatusResponse { user_id: number; created_at: string | null; payment_method: string | null; + expires_at: string | null; dni: string | null; transfer_payer_dni: string | null; telefono: string | null; diff --git a/src/app/features/store/pages/checkout-page/checkout-page.component.spec.ts b/src/app/features/store/pages/checkout-page/checkout-page.component.spec.ts index 3f24fcf..c3f71b7 100644 --- a/src/app/features/store/pages/checkout-page/checkout-page.component.spec.ts +++ b/src/app/features/store/pages/checkout-page/checkout-page.component.spec.ts @@ -60,7 +60,7 @@ describe('CheckoutPageComponent payment validation', () => { qr_data: { qr_code: 'qr-value' }, }), getPurchase: vi.fn().mockResolvedValue({ status: 'pending_payment' }), - submitPurchaseForReview: vi.fn().mockResolvedValue({ status: 'in_review' }), + submitPurchaseForReview: vi.fn().mockResolvedValue({ status: 'pending_payment' }), withCustomLoading: vi.fn(), }; checkoutServiceStub.withCustomLoading.mockReturnValue(checkoutServiceStub); @@ -199,20 +199,20 @@ describe('CheckoutPageComponent payment validation', () => { component.selectedPaymentMethod.set('transfer'); await component.onComplete(); - expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(1); + expect(checkoutServiceStub.submitPurchaseForReview).toHaveBeenCalledTimes(1); expect(component.transferValidationStatus()).toBe('pending'); expect(cartServiceStub.clearCart).not.toHaveBeenCalled(); expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]); await vi.advanceTimersByTimeAsync(30_000); - expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(1); + expect(checkoutServiceStub.submitPurchaseForReview).toHaveBeenCalledTimes(1); await component.onComplete(); - expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(1); + expect(checkoutServiceStub.submitPurchaseForReview).toHaveBeenCalledTimes(1); }); it('navigates after a transfer is confirmed as paid', async () => { - checkoutServiceStub.getPurchase.mockResolvedValue({ status: 'paid' }); + checkoutServiceStub.submitPurchaseForReview.mockResolvedValue({ status: 'paid' }); const { component } = createComponent(); component.selectedPaymentMethod.set('transfer'); @@ -375,7 +375,7 @@ describe('CheckoutPageComponent payment validation', () => { expect(component.qrPaymentStatus()).toBe('waiting'); }); - it.each(['in_review', 'paid', 'cancelled', 'rejected', 'expired'])( + it.each(['paid', 'cancelled', 'rejected', 'expired'])( 'redirects a %s purchase to its status page', async (status) => { routeQueryParamMap = convertToParamMap({ purchase: 25 }); @@ -395,6 +395,25 @@ describe('CheckoutPageComponent payment validation', () => { }, ); + it('redirects a submitted pending payment purchase to its status page', async () => { + routeQueryParamMap = convertToParamMap({ purchase: 25 }); + checkoutServiceStub.getPurchase.mockResolvedValue({ + id: 25, + status: 'pending_payment', + payment_method: 'transfer', + expires_at: null, + transfer_payer_dni: null, + items: [], + subtotal: '100.00', + total: '100.00', + }); + + createComponent(); + await Promise.resolve(); + + expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]); + }); + it('updates a purchase item while editing and refreshes checkout totals', async () => { const updatedPurchase = { id: 25, diff --git a/src/app/features/store/pages/checkout-page/checkout-page.component.ts b/src/app/features/store/pages/checkout-page/checkout-page.component.ts index 9d023c6..6db657f 100644 --- a/src/app/features/store/pages/checkout-page/checkout-page.component.ts +++ b/src/app/features/store/pages/checkout-page/checkout-page.component.ts @@ -437,7 +437,11 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { .withCustomLoading() .submitPurchaseForReview(tenant.codigo, purchaseId); - if (purchase.status === 'paid') { + if (purchase.status === 'paid' || purchase.status === 'pending_payment') { + if (purchase.status === 'pending_payment') { + this.transferValidationStatus.set('pending'); + } + this.navigateToPurchaseStatus(purchaseId); return; } @@ -576,7 +580,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { .getPurchase(tenant.codigo, purchaseId); if ( - purchase.status === 'in_review' || + (purchase.status === 'pending_payment' && purchase.expires_at === null) || purchase.status === 'paid' || purchase.status === 'cancelled' || purchase.status === 'rejected' || From 78970573a84656eb523a9e13706c53fcbd582636 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 5 Aug 2026 08:49:52 -0300 Subject: [PATCH 08/26] feat: add type property to Product and CatalogItemDetail interfaces; include event_id in PurchaseSummaryResponse and PurchaseDetailResponse --- src/app/core/services/catalog/catalog.interface.ts | 5 +++++ src/app/core/services/checkout.service.ts | 2 ++ 2 files changed, 7 insertions(+) diff --git a/src/app/core/services/catalog/catalog.interface.ts b/src/app/core/services/catalog/catalog.interface.ts index 21097ba..d2c93b7 100644 --- a/src/app/core/services/catalog/catalog.interface.ts +++ b/src/app/core/services/catalog/catalog.interface.ts @@ -1,7 +1,10 @@ import { ApiPaginatedResponse } from '../api-paginated-response.interface'; +export type CatalogItemType = 'product' | 'bundle'; + export interface Product { id: number; + type: CatalogItemType; category_id: number; brand_id: number | null; slug: string; @@ -60,6 +63,7 @@ export interface SelectedCatalogItemVariant extends CatalogItemVariant { export interface CatalogItemDetail { id: number; + type: CatalogItemType; purpose?: 'product' | 'entry'; category_id: number | null; brand_id: number | null; @@ -92,6 +96,7 @@ export interface CatalogFeaturedItemVariant { export interface CatalogFeaturedItem { id: number; + type: CatalogItemType; nombre: string; descripcion?: string | null; precio: number | string; diff --git a/src/app/core/services/checkout.service.ts b/src/app/core/services/checkout.service.ts index 9d7d996..1666bd7 100644 --- a/src/app/core/services/checkout.service.ts +++ b/src/app/core/services/checkout.service.ts @@ -29,6 +29,7 @@ export interface PurchaseStatusResponse { export interface PurchaseSummaryResponse extends PurchaseStatusResponse { id: number; + event_id: number | null; created_at: string | null; total: string; } @@ -58,6 +59,7 @@ 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; From 1977df6765b63d9fd0a49b73f3fcbd462a549162 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 7 Aug 2026 14:16:02 -0300 Subject: [PATCH 09/26] feat: enhance product attributes and event date handling for improved user experience and data management --- .../services/catalog/catalog.interface.ts | 35 ++-- src/app/core/services/checkout.service.ts | 2 - src/app/core/services/tenant.interface.ts | 10 +- .../core/services/validity-time.interface.ts | 17 ++ .../product-attribute-selector.component.html | 18 +- ...oduct-attribute-selector.component.spec.ts | 42 +++++ .../product-attribute-selector.component.ts | 157 ++++++++++-------- .../product-detail-page.component.html | 29 +--- .../product-detail-page.component.spec.ts | 91 +++++++--- .../product-detail-page.component.ts | 43 +++-- .../store-home-page.component.spec.ts | 16 +- .../store-home-page.component.ts | 12 +- .../product-list/product-list.component.ts | 2 + .../product-row-card.component.html | 4 +- .../product-row-card.component.ts | 17 +- ...uct-vertical-with-cart-card.component.html | 4 +- ...-vertical-with-cart-card.component.spec.ts | 41 +++++ ...oduct-vertical-with-cart-card.component.ts | 15 +- 18 files changed, 368 insertions(+), 187 deletions(-) create mode 100644 src/app/core/services/validity-time.interface.ts 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)))); From 7c2a098696df63654fafe40635cff7f245f02575 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 10 Aug 2026 09:58:51 -0300 Subject: [PATCH 10/26] feat: enhance product variant handling and improve layout for better user interaction --- .../reutilizables-test-page.component.ts | 5 +- .../hero-banner/hero-banner.component.html | 2 +- .../product-list/product-list.component.ts | 2 +- .../product-row-card.component.html | 23 ++- .../product-row-card.component.scss | 11 +- .../product-row-card.component.ts | 148 ++++++++++++++++-- ...uct-vertical-with-cart-card.component.html | 25 +-- ...uct-vertical-with-cart-card.component.scss | 35 +---- ...-vertical-with-cart-card.component.spec.ts | 28 ++-- ...oduct-vertical-with-cart-card.component.ts | 3 - 10 files changed, 185 insertions(+), 97 deletions(-) diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts index 09e3915..f133a64 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts @@ -204,8 +204,9 @@ export class ReutilizablesTestPageComponent { 'Acceso total al predio. No incluye acceso a estacionamiento. Niños menores de 5 años ingresan gratis.', price: 10000, variants: [ - { label: '10 de Octubre', value: '10-oct' }, - { label: '11 de Octubre', value: '11-oct' }, + { value: '10-oct-manana', values: { fecha: '10 de Octubre', turno: 'Mañana' } }, + { value: '10-oct-tarde', values: { fecha: '10 de Octubre', turno: 'Tarde' } }, + { value: '11-oct-tarde', values: { fecha: '11 de Octubre', turno: 'Tarde' } }, ], }; diff --git a/src/app/shared/components/hero-banner/hero-banner.component.html b/src/app/shared/components/hero-banner/hero-banner.component.html index 483de3d..79aea97 100644 --- a/src/app/shared/components/hero-banner/hero-banner.component.html +++ b/src/app/shared/components/hero-banner/hero-banner.component.html @@ -5,7 +5,7 @@ @if (heroConfig) { 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 ac12391..be3b3a3 100644 --- a/src/app/shared/components/product-list/product-list.component.ts +++ b/src/app/shared/components/product-list/product-list.component.ts @@ -108,9 +108,9 @@ export class ProductListComponent { protected variantsFor(item: ProductListItem): RowVariant[] { return (item.variants ?? []).map((variant) => ({ value: variant.id, - label: Object.values(variant.values).join(' / ') || `Variante ${variant.id}`, descripcion: variant.descripcion, precio: variant.precio, + values: variant.values, })); } 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 53d8664..9e5f9c9 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 @@ -25,15 +25,22 @@
- +
- @if (variants().length > 0) { - - } +
+ @for (selector of variantSelectors(); track selector.key) { + + } +
diff --git a/src/app/shared/components/product-row-card/product-row-card.component.scss b/src/app/shared/components/product-row-card/product-row-card.component.scss index a5ee11a..af30676 100644 --- a/src/app/shared/components/product-row-card/product-row-card.component.scss +++ b/src/app/shared/components/product-row-card/product-row-card.component.scss @@ -20,7 +20,7 @@ color: #777; /* lighter grey for text */ line-height: 1.4; max-width: 600px; - + /* Simulate the bold text for 'Niños menores...' if it was HTML. Since it's passed as string we just let it be, unless we parse it. */ } @@ -31,9 +31,16 @@ color: var(--tenant-primary, #009933) !important; /* Green matching screenshot */ } + &__selectors { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + } + &__select { width: auto; - min-width: 150px; + min-width: 120px; font-size: 14px; height: 38px; color: #666; 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 be4eba3..4dc23b5 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 @@ -4,18 +4,35 @@ import { computed, effect, input, - output, model, + output, + signal, + untracked, } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ButtonComponent } from '../button/button.component'; import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component'; export interface Variant { - label: string; - value: any; + label?: string; + value: unknown; descripcion?: string | null; precio?: string | number; + values: Record; +} + +type VariantAttributeValue = string | string[]; + +interface VariantSelectorOption { + key: string; + label: string; + value: VariantAttributeValue; +} + +interface VariantSelector { + key: string; + label: string; + options: VariantSelectorOption[]; } @Component({ @@ -35,23 +52,54 @@ export class ProductRowCardComponent { // Internal state models readonly quantity = model(1); - readonly selectedVariant = model(null); + readonly selectedVariant = model(null); // Interactive events - readonly buy = output<{ quantity: number; variant: any }>(); - readonly addToCart = output<{ quantity: number; variant: any }>(); + readonly buy = output<{ quantity: number; variant: unknown }>(); + readonly addToCart = output<{ quantity: number; variant: unknown }>(); + + protected readonly selectedValues = signal>({}); + protected readonly attributeKeys = computed(() => + Array.from(new Set(this.variants().flatMap((variant) => Object.keys(variant.values)))), + ); + protected readonly variantSelectors = computed(() => { + const variants = this.variants(); + const keys = this.attributeKeys(); + const selectedValues = this.selectedValues(); + + return keys.map((key, index) => { + const previousKeys = keys.slice(0, index); + const compatibleVariants = variants.filter((variant) => + previousKeys.every((previousKey) => + this.sameValue(variant.values[previousKey], selectedValues[previousKey]), + ), + ); + + return { + key, + label: this.formatVariantLabel(key), + options: this.optionsFor(compatibleVariants, key), + }; + }); + }); constructor() { effect(() => { const variants = this.variants(); const selectedVariant = this.selectedVariant(); - if ( - variants.length > 0 && - !variants.some((variant) => Object.is(variant.value, selectedVariant)) - ) { - this.selectedVariant.set(variants[0].value); - } + untracked(() => { + if (variants.length === 0) { + this.selectedValues.set({}); + this.selectedVariant.set(null); + return; + } + + const selected = + variants.find((variant) => Object.is(variant.value, selectedVariant)) ?? variants[0]; + this.selectedValues.set({ ...selected.values }); + this.selectedVariant.set(selected.value); + }); }); } @@ -69,6 +117,42 @@ export class ProductRowCardComponent { readonly formattedPrice = computed(() => this.formatCurrency(this.effectivePrice())); + protected onVariantValueChange(key: string, value: VariantAttributeValue): void { + const variants = this.variants(); + const keys = this.attributeKeys(); + const changedIndex = keys.indexOf(key); + const values = { ...this.selectedValues(), [key]: value }; + + for (let index = changedIndex + 1; index < keys.length; index++) { + const currentKey = keys[index]; + const previousKeys = keys.slice(0, index); + const compatibleVariants = variants.filter((variant) => + previousKeys.every((previousKey) => + this.sameValue(variant.values[previousKey], values[previousKey]), + ), + ); + const options = this.optionsFor(compatibleVariants, currentKey); + + if (!options.some((option) => this.sameValue(option.value, values[currentKey]))) { + const firstOption = options[0]; + if (firstOption) { + values[currentKey] = firstOption.value; + } else { + delete values[currentKey]; + } + } + } + + const matchingVariant = variants.find((variant) => + keys.every((attributeKey) => + this.sameValue(variant.values[attributeKey], values[attributeKey]), + ), + ); + + this.selectedValues.set(values); + this.selectedVariant.set(matchingVariant?.value ?? null); + } + protected onAddToCart(): void { this.addToCart.emit({ quantity: this.quantity(), @@ -83,6 +167,46 @@ export class ProductRowCardComponent { }); } + private optionsFor(variants: Variant[], key: string): VariantSelectorOption[] { + const options = new Map(); + + for (const variant of variants) { + const value = variant.values[key]; + if (value === undefined || value === '') { + continue; + } + + const optionKey = this.valueKey(value); + if (!options.has(optionKey)) { + options.set(optionKey, { + key: optionKey, + label: Array.isArray(value) ? value.join(', ') : value, + value, + }); + } + } + + return Array.from(options.values()); + } + + private sameValue( + left: VariantAttributeValue | undefined, + right: VariantAttributeValue | undefined, + ): boolean { + return ( + left !== undefined && right !== undefined && this.valueKey(left) === this.valueKey(right) + ); + } + + private valueKey(value: VariantAttributeValue): string { + return JSON.stringify(value); + } + + private formatVariantLabel(key: string): string { + const label = key.replace(/[_-]+/g, ' '); + return label.charAt(0).toUpperCase() + label.slice(1); + } + /** * Helper method to format currency numbers to Argentine Pesos style "$ XX.XXX". */ 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 2d03f1b..267dd4b 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 @@ -13,30 +13,9 @@ {{ formattedPrice() }}
- } @else if (!hasMultipleVariantSelectors()) { -
- {{ formattedPrice() }} - -
- @for (selector of variantSelectors(); track selector.key) { - - } - - -
-
} @else { -
-
+
+
{{ formattedPrice() }}
diff --git a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.scss b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.scss index e30af70..00ad1f3 100644 --- a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.scss +++ b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.scss @@ -60,41 +60,20 @@ padding-inline: 12px; } - &__single-variant { + &__variants { display: grid; - justify-items: center; gap: 16px; } - &__single-variant-row { + &__variant-selectors { display: flex; align-items: center; width: 100%; gap: 8px; } - &__multiple-variants { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - align-items: center; - gap: 16px; - } - - &__summary-column, - &__variant-selectors { - display: flex; - flex-direction: column; - - align-items: center; - gap: 10px; - min-width: 0; - } - - &__variant-selectors { - align-items: stretch; - } - &__variant-select { + flex: 1 1 0; min-width: 0; height: 40px; border-color: #d8d8d8; @@ -107,14 +86,6 @@ } } - &__single-variant-row &__variant-select { - flex: 1 1 auto; - } - - &__single-variant-row app-quantity-selector { - flex: 0 0 auto; - } - &__price { color: var(--tenant-primary, #009933); font-size: 22px; 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 c6ff6f6..ef6fdca 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 @@ -116,7 +116,7 @@ describe('ProductVerticalWithCartCardComponent', () => { ).not.toBeNull(); }); - it('places a single variant selector in the same row as the quantity', async () => { + it('places price and quantity together above a single variant selector', async () => { const fixture = await createComponent(); fixture.componentRef.setInput('variants', [ { id: 10, values: { fecha: '10 de octubre' } }, @@ -125,16 +125,18 @@ describe('ProductVerticalWithCartCardComponent', () => { fixture.detectChanges(); const element = fixture.nativeElement as HTMLElement; - const row = element.querySelector('.product-vertical-with-cart-card__single-variant-row'); + const summary = element.querySelector('.product-vertical-with-cart-card__summary'); + const selectors = element.querySelector('.product-vertical-with-cart-card__variant-selectors'); - expect(row?.querySelectorAll('.product-vertical-with-cart-card__variant-select')).toHaveLength( - 1, - ); - expect(row?.querySelector('app-quantity-selector')).not.toBeNull(); - expect(element.querySelector('.product-vertical-with-cart-card__multiple-variants')).toBeNull(); + expect(summary?.querySelector('.product-vertical-with-cart-card__price')).not.toBeNull(); + expect(summary?.querySelector('app-quantity-selector')).not.toBeNull(); + expect( + selectors?.querySelectorAll('.product-vertical-with-cart-card__variant-select'), + ).toHaveLength(1); + expect(selectors?.querySelector('app-quantity-selector')).toBeNull(); }); - it('uses separate summary and selector columns for multiple variant attributes', async () => { + it('places all variant selectors together below price and quantity', async () => { const fixture = await createComponent(); fixture.componentRef.setInput('variants', [ { id: 20, values: { fecha: '10 de octubre', turno: 'Mañana' } }, @@ -143,13 +145,13 @@ describe('ProductVerticalWithCartCardComponent', () => { fixture.detectChanges(); const element = fixture.nativeElement as HTMLElement; - const layout = element.querySelector('.product-vertical-with-cart-card__multiple-variants'); + const layout = element.querySelector('.product-vertical-with-cart-card__variants'); + expect(layout?.querySelector('.product-vertical-with-cart-card__summary')).not.toBeNull(); expect( - layout?.querySelector('.product-vertical-with-cart-card__summary-column'), - ).not.toBeNull(); - expect( - layout?.querySelectorAll('.product-vertical-with-cart-card__variant-select'), + layout?.querySelectorAll( + '.product-vertical-with-cart-card__variant-selectors .product-vertical-with-cart-card__variant-select', + ), ).toHaveLength(2); }); 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 38f1719..c4a06e7 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 @@ -78,9 +78,6 @@ export class ProductVerticalWithCartCardComponent { }); protected readonly hasVariants = computed(() => this.variantSelectors().length > 0); - protected readonly hasMultipleVariantSelectors = computed( - () => this.variantSelectors().length > 1, - ); constructor() { effect(() => { From dd828d9a4e8d36c9e2c8edb91dab2dce8a8f782c Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 10 Aug 2026 10:42:15 -0300 Subject: [PATCH 11/26] feat: add event_date_text to Tenant interface and update StoreHomePageComponent to display formatted event dates --- src/app/core/services/tenant.interface.ts | 1 + .../pages/store-home-page/store-home-page.component.spec.ts | 3 ++- .../store/pages/store-home-page/store-home-page.component.ts | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/core/services/tenant.interface.ts b/src/app/core/services/tenant.interface.ts index e1a1b7a..5efbfda 100644 --- a/src/app/core/services/tenant.interface.ts +++ b/src/app/core/services/tenant.interface.ts @@ -106,6 +106,7 @@ export interface Tenant { footer_logo: string; website_type_code?: string | null; website_type?: WebsiteType | null; + event_date_text?: string | null; extras?: WebsiteExtras; event?: TenantEvent | null; selected_bank_account_id?: number | null; 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 1e62757..4f1cad2 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 @@ -213,6 +213,7 @@ describe('StoreHomePageComponent', () => { }, ], }; + tenant.event_date_text = '5 y 6 de Diciembre 2026'; tenant.website_type_code = 'onticket'; tenant.social_media = [ { @@ -259,7 +260,7 @@ describe('StoreHomePageComponent', () => { expect(hero.textContent).toContain('Fiesta Fútbol Infantil'); expect(hero.textContent).toContain('Viví una jornada inolvidable de fútbol infantil.'); expect(hero.textContent).toContain('Rosario, Santa Fe'); - expect(hero.textContent).toContain('2026-12-05, 2026-12-06'); + expect(hero.textContent).toContain('5 y 6 de Diciembre 2026'); const heroComponent = fixture.debugElement.query(By.directive(HeroBannerComponent)) .componentInstance as HeroBannerComponent; expect(heroComponent.eventConfig?.contact).toEqual(tenant.social_media); 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 8d4397a..6e019f9 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 @@ -75,6 +75,7 @@ export class StoreHomePageComponent implements OnInit, OnDestroy { id: tenant.id, title: event.title, location: event.location, + dates_text: tenant.event_date_text, dates: event.dates.map((eventDate) => ({ id: eventDate.id, date: eventDate.date, From d6bab24e1d5cfa30d66cea968736ec1221ba07c0 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 10 Aug 2026 12:08:41 -0300 Subject: [PATCH 12/26] feat: implement variant selection in cart and product components for enhanced user experience --- .../store-layout/store-layout.component.ts | 22 +++ src/app/core/services/cart/cart.interface.ts | 8 + src/app/core/services/cart/cart.service.ts | 34 ++-- .../cart-item/cart-item.component.html | 47 ++--- .../cart-item/cart-item.component.scss | 7 +- .../cart-item/cart-item.component.ts | 25 ++- .../components/cart/cart.component.html | 3 + .../components/cart/cart.component.spec.ts | 53 ++++++ .../shared/components/cart/cart.component.ts | 41 +++++ .../product-row-card.component.html | 19 +-- .../product-row-card.component.scss | 22 --- .../product-row-card.component.ts | 158 +---------------- .../quantity-selector.component.scss | 14 +- .../variant-selector.component.html | 17 ++ .../variant-selector.component.scss | 40 +++++ .../variant-selector.component.spec.ts | 41 +++++ .../variant-selector.component.ts | 161 ++++++++++++++++++ 17 files changed, 478 insertions(+), 234 deletions(-) create mode 100644 src/app/shared/components/variant-selector/variant-selector.component.html create mode 100644 src/app/shared/components/variant-selector/variant-selector.component.scss create mode 100644 src/app/shared/components/variant-selector/variant-selector.component.spec.ts create mode 100644 src/app/shared/components/variant-selector/variant-selector.component.ts diff --git a/src/app/core/layout/store-layout/store-layout.component.ts b/src/app/core/layout/store-layout/store-layout.component.ts index 8336100..98810f6 100644 --- a/src/app/core/layout/store-layout/store-layout.component.ts +++ b/src/app/core/layout/store-layout/store-layout.component.ts @@ -69,6 +69,21 @@ export class StoreLayoutComponent implements OnInit { }); } + const variants = (item.product?.variants ?? []).map((variant) => ({ + value: variant.id, + values: variant.values, + })); + const selectedVariant = item.product?.variants?.find( + (variant) => variant.id === item.variant_id, + ); + + if (selectedVariant) { + attributes = Object.entries(selectedVariant.values).map(([label, value]) => ({ + label: this.formatAttributeLabel(label), + value: Array.isArray(value) ? value.join(', ') : value, + })); + } + return { cartItemId: item.id, imageUrl: item.product?.imagen ?? null, @@ -78,9 +93,16 @@ export class StoreLayoutComponent implements OnInit { discountPercentage: null, attributes, quantity: item.cantidad, + variantId: item.variant_id, + variants, }; } + private formatAttributeLabel(value: string): string { + const label = value.replace(/[_-]+/g, ' '); + return label.charAt(0).toUpperCase() + label.slice(1); + } + protected readonly currentYear = new Date().getFullYear(); protected readonly tenant = this.tenantService.tenant; protected readonly user = this.authService.user; diff --git a/src/app/core/services/cart/cart.interface.ts b/src/app/core/services/cart/cart.interface.ts index c774367..3145a6c 100644 --- a/src/app/core/services/cart/cart.interface.ts +++ b/src/app/core/services/cart/cart.interface.ts @@ -1,6 +1,14 @@ export interface CartItemProduct { nombre: string; imagen: string | null; + variants?: CartItemVariant[]; +} + +export interface CartItemVariant { + id: number; + precio: string; + stock_tecnico: number | null; + values: Record; } export interface CartItem { diff --git a/src/app/core/services/cart/cart.service.ts b/src/app/core/services/cart/cart.service.ts index 4a74bf2..750105f 100644 --- a/src/app/core/services/cart/cart.service.ts +++ b/src/app/core/services/cart/cart.service.ts @@ -50,9 +50,7 @@ export class CartService extends BaseApiService { ): Observable> { this.isUpdatingState.set(true); return this.http - .post< - ApiResponse - >( + .post>( `${this.tenantApiUrl}/cart/items`, { catalog_item_id: catalogItemId, variant_id: variantId, cantidad }, { @@ -71,21 +69,27 @@ export class CartService extends BaseApiService { ); } - updateItemQuantity( + updateItemQuantity(cartItemId: number, cantidad: number): Observable> { + return this.updateItem(cartItemId, { cantidad }); + } + + updateItemVariant( cartItemId: number, cantidad: number, + variantId: number, + ): Observable> { + return this.updateItem(cartItemId, { cantidad, variant_id: variantId }); + } + + private updateItem( + cartItemId: number, + payload: { cantidad: number; variant_id?: number }, ): Observable> { this.isUpdatingState.set(true); return this.http - .patch< - ApiResponse - >( - `${this.tenantApiUrl}/cart/items/${cartItemId}`, - { cantidad }, - { - withCredentials: true, - }, - ) + .patch>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, payload, { + withCredentials: true, + }) .pipe( tap((response) => { this.cartState.set(response.data); @@ -101,9 +105,7 @@ export class CartService extends BaseApiService { removeItem(cartItemId: number): Observable> { this.isUpdatingState.set(true); return this.http - .delete< - ApiResponse - >(`${this.tenantApiUrl}/cart/items/${cartItemId}`, { + .delete>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, { withCredentials: true, }) .pipe( diff --git a/src/app/shared/components/cart-item/cart-item.component.html b/src/app/shared/components/cart-item/cart-item.component.html index d425ffe..e934db3 100644 --- a/src/app/shared/components/cart-item/cart-item.component.html +++ b/src/app/shared/components/cart-item/cart-item.component.html @@ -1,11 +1,10 @@ -
+
@if (imageUrl()) {
@if (discountPercentage() && discountPercentage()! > 0) { - -{{ discountPercentage() }}% + -{{ discountPercentage() }}% } @@ -18,21 +17,33 @@
@if (formattedOriginalPrice(); as originalPrice) { - {{ originalPrice }} + {{ + originalPrice + }} } {{ formattedDiscountedPrice() }}
-
- @for (attribute of attributes(); track attribute.label + attribute.value) { -
- {{ attribute.label }}: - {{ attribute.value }} -
- } -
+ @if (hasVariantSelectors() && !quantityDisabled()) { + + } @else { +
+ @for (attribute of attributes(); track attribute.label + attribute.value) { +
+ {{ attribute.label }}: + {{ attribute.value }} +
+ } +
+ } @if (!readonly()) {
@@ -45,15 +56,9 @@ (decrease)="onDecrease()" /> @if (!quantityDisabled() && showRemove()) { - + }
}
- - diff --git a/src/app/shared/components/cart-item/cart-item.component.scss b/src/app/shared/components/cart-item/cart-item.component.scss index 2a73769..0d0d6c3 100644 --- a/src/app/shared/components/cart-item/cart-item.component.scss +++ b/src/app/shared/components/cart-item/cart-item.component.scss @@ -11,7 +11,7 @@ :host(:first-child) .cart-item::before, :host .cart-item::after { - content: ""; + content: ''; position: absolute; right: var(--item-divider-inset); left: var(--item-divider-inset); @@ -119,6 +119,11 @@ gap: 0.125rem; } +.cart-item-variant-selector { + display: flex; + justify-content: flex-end; +} + .cart-item-remove-btn { margin-left: 0.35rem; } diff --git a/src/app/shared/components/cart-item/cart-item.component.ts b/src/app/shared/components/cart-item/cart-item.component.ts index 7c752fe..72b2449 100644 --- a/src/app/shared/components/cart-item/cart-item.component.ts +++ b/src/app/shared/components/cart-item/cart-item.component.ts @@ -1,6 +1,10 @@ import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component'; import { IconButtonComponent } from '../icon-button/icon-button.component'; +import { + VariantSelectorComponent, + VariantSelectorVariant, +} from '../variant-selector/variant-selector.component'; export interface CartItemAttribute { label: string; @@ -10,10 +14,10 @@ export interface CartItemAttribute { @Component({ selector: 'app-cart-item', standalone: true, - imports: [QuantitySelectorComponent, IconButtonComponent], + imports: [QuantitySelectorComponent, IconButtonComponent, VariantSelectorComponent], templateUrl: './cart-item.component.html', styleUrl: './cart-item.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, }) export class CartItemComponent { readonly imageUrl = input(null); @@ -22,6 +26,8 @@ export class CartItemComponent { readonly discountedPrice = input(0); readonly discountPercentage = input(null); readonly attributes = input([]); + readonly variants = input([]); + readonly selectedVariant = input(null); readonly quantity = input(1); readonly readonly = input(false); readonly quantityDisabled = input(false); @@ -31,6 +37,11 @@ export class CartItemComponent { readonly remove = output(); readonly increase = output(); readonly decrease = output(); + readonly variantChange = output(); + + protected readonly hasVariantSelectors = computed(() => + this.variants().some((variant) => Object.keys(variant.values).length > 0), + ); protected onQuantityChange(newQuantity: number): void { if (this.quantityDisabled()) return; @@ -49,12 +60,20 @@ export class CartItemComponent { this.decrease.emit(); } + protected onVariantChange(variant: unknown): void { + if (!this.quantityDisabled() && typeof variant === 'number') { + this.variantChange.emit(variant); + } + } + protected readonly formattedOriginalPrice = computed(() => { const price = this.originalPrice(); return price === null ? null : this.formatCurrency(price); }); - protected readonly formattedDiscountedPrice = computed(() => this.formatCurrency(this.discountedPrice())); + protected readonly formattedDiscountedPrice = computed(() => + this.formatCurrency(this.discountedPrice()), + ); private formatCurrency(value: number): string { const rounded = Math.round(value); diff --git a/src/app/shared/components/cart/cart.component.html b/src/app/shared/components/cart/cart.component.html index a04b6d1..469dd86 100644 --- a/src/app/shared/components/cart/cart.component.html +++ b/src/app/shared/components/cart/cart.component.html @@ -44,11 +44,14 @@ [discountedPrice]="item.discountedPrice" [discountPercentage]="item.discountPercentage" [attributes]="item.attributes" + [variants]="item.variants ?? []" + [selectedVariant]="getItemVariant(item)" [quantity]="getItemQuantity(item)" [readonly]="readonly()" [quantityDisabled]="editingDisabled() || (allowEditing() && !editing())" [showRemove]="allowRemove()" (quantityChange)="onItemQuantityChange(idx, $event)" + (variantChange)="onItemVariantChange(idx, $event)" (remove)="onItemRemove(idx)" /> } @empty { diff --git a/src/app/shared/components/cart/cart.component.spec.ts b/src/app/shared/components/cart/cart.component.spec.ts index d7a1d49..bdf87d1 100644 --- a/src/app/shared/components/cart/cart.component.spec.ts +++ b/src/app/shared/components/cart/cart.component.spec.ts @@ -407,4 +407,57 @@ describe('CartComponent', () => { fixture.debugElement.query(By.css('app-cart-item')).componentInstance.quantityDisabled(), ).toBe(false); }); + + it('persists a variant selected from a cart row', async () => { + const updateItemVariant = vi.fn().mockReturnValue( + of({ + message: 'Variante actualizada.', + data: {}, + }), + ); + + await TestBed.configureTestingModule({ + imports: [CartComponent], + providers: [ + { + provide: CartService, + useValue: { + cart: signal(null).asReadonly(), + updateItemQuantity: vi.fn(), + updateItemVariant, + removeItem: vi.fn(), + }, + }, + { provide: ModalService, useValue: {} }, + { + provide: ToastService, + useValue: { success: vi.fn(), info: vi.fn(), danger: vi.fn() }, + }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(CartComponent); + fixture.componentRef.setInput('items', [ + { + cartItemId: 10, + imageUrl: null, + product: 'Comida', + originalPrice: null, + discountedPrice: 1000, + discountPercentage: null, + attributes: [{ label: 'Servicio', value: 'Almuerzo' }], + quantity: 2, + variantId: 20, + variants: [ + { value: 20, values: { servicio: 'Almuerzo' } }, + { value: 21, values: { servicio: 'Cena' } }, + ], + }, + ]); + fixture.detectChanges(); + + fixture.debugElement.query(By.css('app-cart-item')).triggerEventHandler('variantChange', 21); + + expect(updateItemVariant).toHaveBeenCalledWith(10, 2, 21); + }); }); diff --git a/src/app/shared/components/cart/cart.component.ts b/src/app/shared/components/cart/cart.component.ts index 7a78e16..c1f852c 100644 --- a/src/app/shared/components/cart/cart.component.ts +++ b/src/app/shared/components/cart/cart.component.ts @@ -16,6 +16,7 @@ import { CartItemAttribute, CartItemComponent } from '../cart-item/cart-item.com import { ModalService } from '../../../core/services/modal.service'; import { CartService } from '../../../core/services/cart/cart.service'; import { ToastService } from '../../../core/services/toast.service'; +import { VariantSelectorVariant } from '../variant-selector/variant-selector.component'; export interface CartItemMock { cartItemId?: number; @@ -26,6 +27,8 @@ export interface CartItemMock { discountPercentage: number | null; attributes: CartItemAttribute[]; quantity: number; + variantId?: number | null; + variants?: VariantSelectorVariant[]; } @Component({ @@ -64,6 +67,7 @@ export class CartComponent { }>(); protected readonly quantityOverrides = signal>({}); + protected readonly variantOverrides = signal>({}); constructor() { this.quantityUpdates$ .pipe( @@ -104,6 +108,13 @@ export class CartComponent { return item.quantity; } + protected getItemVariant(item: CartItemMock): number | null { + if (item.cartItemId !== undefined && this.variantOverrides()[item.cartItemId] !== undefined) { + return this.variantOverrides()[item.cartItemId]; + } + return item.variantId ?? null; + } + private clearOverride(cartItemId: number): void { this.quantityOverrides.update((overrides) => { const copy = { ...overrides }; @@ -153,6 +164,36 @@ export class CartComponent { } } + protected onItemVariantChange(index: number, variantId: number): void { + const item = this.items()[index]; + const cartItemId = item?.cartItemId; + + if (!item || !cartItemId || variantId === this.getItemVariant(item)) return; + + this.variantOverrides.update((overrides) => ({ ...overrides, [cartItemId]: variantId })); + this.cartService + .updateItemVariant(cartItemId, this.getItemQuantity(item), variantId) + .subscribe({ + next: (response) => { + this.clearVariantOverride(cartItemId); + this.toastService.success(response.message || 'Variante actualizada.'); + }, + error: (error: HttpErrorResponse) => { + console.error('Error updating cart item variant', error); + this.clearVariantOverride(cartItemId); + this.toastService.danger(error.error?.message || 'No se pudo actualizar la variante.'); + }, + }); + } + + private clearVariantOverride(cartItemId: number): void { + this.variantOverrides.update((overrides) => { + const copy = { ...overrides }; + delete copy[cartItemId]; + return copy; + }); + } + protected onItemRemove(index: number): void { const target = this.resolveRemoveTarget(index); 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 9e5f9c9..526b733 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 @@ -27,20 +27,11 @@
-
- @for (selector of variantSelectors(); track selector.key) { - - } -
+ diff --git a/src/app/shared/components/product-row-card/product-row-card.component.scss b/src/app/shared/components/product-row-card/product-row-card.component.scss index af30676..596a1e0 100644 --- a/src/app/shared/components/product-row-card/product-row-card.component.scss +++ b/src/app/shared/components/product-row-card/product-row-card.component.scss @@ -31,28 +31,6 @@ color: var(--tenant-primary, #009933) !important; /* Green matching screenshot */ } - &__selectors { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; - } - - &__select { - width: auto; - min-width: 120px; - font-size: 14px; - height: 38px; - color: #666; - border-color: #ccc; - cursor: pointer; - - &:focus { - border-color: var(--tenant-primary); - box-shadow: 0 0 0 0.25rem rgba(0, 153, 51, 0.25); - } - } - &__btn-wrapper { min-width: 160px; /* To make both buttons equal width as in screenshot */ 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 4dc23b5..8820183 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 @@ -1,44 +1,21 @@ -import { - ChangeDetectionStrategy, - Component, - computed, - effect, - input, - model, - output, - signal, - untracked, -} from '@angular/core'; -import { FormsModule } from '@angular/forms'; +import { ChangeDetectionStrategy, Component, computed, input, model, output } from '@angular/core'; import { ButtonComponent } from '../button/button.component'; import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component'; +import { + VariantSelectorComponent, + VariantSelectorVariant, +} from '../variant-selector/variant-selector.component'; -export interface Variant { +export interface Variant extends VariantSelectorVariant { label?: string; - value: unknown; descripcion?: string | null; precio?: string | number; - values: Record; -} - -type VariantAttributeValue = string | string[]; - -interface VariantSelectorOption { - key: string; - label: string; - value: VariantAttributeValue; -} - -interface VariantSelector { - key: string; - label: string; - options: VariantSelectorOption[]; } @Component({ selector: 'app-product-row-card', standalone: true, - imports: [ButtonComponent, QuantitySelectorComponent, FormsModule], + imports: [ButtonComponent, QuantitySelectorComponent, VariantSelectorComponent], templateUrl: './product-row-card.component.html', styleUrl: './product-row-card.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -58,51 +35,6 @@ export class ProductRowCardComponent { readonly buy = output<{ quantity: number; variant: unknown }>(); readonly addToCart = output<{ quantity: number; variant: unknown }>(); - protected readonly selectedValues = signal>({}); - protected readonly attributeKeys = computed(() => - Array.from(new Set(this.variants().flatMap((variant) => Object.keys(variant.values)))), - ); - protected readonly variantSelectors = computed(() => { - const variants = this.variants(); - const keys = this.attributeKeys(); - const selectedValues = this.selectedValues(); - - return keys.map((key, index) => { - const previousKeys = keys.slice(0, index); - const compatibleVariants = variants.filter((variant) => - previousKeys.every((previousKey) => - this.sameValue(variant.values[previousKey], selectedValues[previousKey]), - ), - ); - - return { - key, - label: this.formatVariantLabel(key), - options: this.optionsFor(compatibleVariants, key), - }; - }); - }); - - constructor() { - effect(() => { - const variants = this.variants(); - const selectedVariant = this.selectedVariant(); - - untracked(() => { - if (variants.length === 0) { - this.selectedValues.set({}); - this.selectedVariant.set(null); - return; - } - - const selected = - variants.find((variant) => Object.is(variant.value, selectedVariant)) ?? variants[0]; - this.selectedValues.set({ ...selected.values }); - this.selectedVariant.set(selected.value); - }); - }); - } - protected readonly selectedVariantData = computed(() => this.variants().find((variant) => Object.is(variant.value, this.selectedVariant())), ); @@ -117,42 +49,6 @@ export class ProductRowCardComponent { readonly formattedPrice = computed(() => this.formatCurrency(this.effectivePrice())); - protected onVariantValueChange(key: string, value: VariantAttributeValue): void { - const variants = this.variants(); - const keys = this.attributeKeys(); - const changedIndex = keys.indexOf(key); - const values = { ...this.selectedValues(), [key]: value }; - - for (let index = changedIndex + 1; index < keys.length; index++) { - const currentKey = keys[index]; - const previousKeys = keys.slice(0, index); - const compatibleVariants = variants.filter((variant) => - previousKeys.every((previousKey) => - this.sameValue(variant.values[previousKey], values[previousKey]), - ), - ); - const options = this.optionsFor(compatibleVariants, currentKey); - - if (!options.some((option) => this.sameValue(option.value, values[currentKey]))) { - const firstOption = options[0]; - if (firstOption) { - values[currentKey] = firstOption.value; - } else { - delete values[currentKey]; - } - } - } - - const matchingVariant = variants.find((variant) => - keys.every((attributeKey) => - this.sameValue(variant.values[attributeKey], values[attributeKey]), - ), - ); - - this.selectedValues.set(values); - this.selectedVariant.set(matchingVariant?.value ?? null); - } - protected onAddToCart(): void { this.addToCart.emit({ quantity: this.quantity(), @@ -167,46 +63,6 @@ export class ProductRowCardComponent { }); } - private optionsFor(variants: Variant[], key: string): VariantSelectorOption[] { - const options = new Map(); - - for (const variant of variants) { - const value = variant.values[key]; - if (value === undefined || value === '') { - continue; - } - - const optionKey = this.valueKey(value); - if (!options.has(optionKey)) { - options.set(optionKey, { - key: optionKey, - label: Array.isArray(value) ? value.join(', ') : value, - value, - }); - } - } - - return Array.from(options.values()); - } - - private sameValue( - left: VariantAttributeValue | undefined, - right: VariantAttributeValue | undefined, - ): boolean { - return ( - left !== undefined && right !== undefined && this.valueKey(left) === this.valueKey(right) - ); - } - - private valueKey(value: VariantAttributeValue): string { - return JSON.stringify(value); - } - - private formatVariantLabel(key: string): string { - const label = key.replace(/[_-]+/g, ' '); - return label.charAt(0).toUpperCase() + label.slice(1); - } - /** * Helper method to format currency numbers to Argentine Pesos style "$ XX.XXX". */ diff --git a/src/app/shared/components/quantity-selector/quantity-selector.component.scss b/src/app/shared/components/quantity-selector/quantity-selector.component.scss index c8fe8f5..bb6199e 100644 --- a/src/app/shared/components/quantity-selector/quantity-selector.component.scss +++ b/src/app/shared/components/quantity-selector/quantity-selector.component.scss @@ -7,7 +7,7 @@ width: 78px; height: 40px; min-height: 40px; - background: inherit; + background-color: #ffffff; box-sizing: border-box; &__button { @@ -15,8 +15,10 @@ height: 100%; font-size: 20px; color: #6f6f6f; - background: inherit; - transition: background-color 0.2s ease, color 0.2s ease; + background-color: #ffffff; + transition: + background-color 0.2s ease, + color 0.2s ease; cursor: pointer; &:not(:disabled):hover { @@ -25,7 +27,7 @@ } &:disabled { - color: #A0A0A0; + color: #a0a0a0; opacity: 1; cursor: not-allowed; } @@ -41,12 +43,12 @@ height: 21px; min-height: 21px; border: 1px solid #cfcfcf; - background: inherit; + background-color: #ffffff; .quantity-selector__button { width: 15px; font-size: 10px; - background: inherit; + background-color: #ffffff; color: #666666; &:not(:disabled):hover { diff --git a/src/app/shared/components/variant-selector/variant-selector.component.html b/src/app/shared/components/variant-selector/variant-selector.component.html new file mode 100644 index 0000000..571b3b2 --- /dev/null +++ b/src/app/shared/components/variant-selector/variant-selector.component.html @@ -0,0 +1,17 @@ +@if (selectors().length > 0) { +
+ @for (selector of selectors(); track selector.key) { + + } +
+} diff --git a/src/app/shared/components/variant-selector/variant-selector.component.scss b/src/app/shared/components/variant-selector/variant-selector.component.scss new file mode 100644 index 0000000..bf8049a --- /dev/null +++ b/src/app/shared/components/variant-selector/variant-selector.component.scss @@ -0,0 +1,40 @@ +:host { + display: block; + min-width: 0; +} + +.variant-selector { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex-wrap: wrap; +} + +.variant-selector__select { + width: auto; + min-width: 120px; + height: 38px; + color: #666666; + border-color: #cccccc; + font-size: 14px; + cursor: pointer; + + &:focus { + border-color: var(--tenant-primary); + box-shadow: 0 0 0 0.25rem rgba(0, 153, 51, 0.25); + } +} + +.variant-selector--compact { + justify-content: flex-end; + gap: 4px; + + .variant-selector__select { + min-width: 0; + max-width: 150px; + height: 28px; + padding: 0.2rem 1.75rem 0.2rem 0.45rem; + font-size: 11px; + } +} diff --git a/src/app/shared/components/variant-selector/variant-selector.component.spec.ts b/src/app/shared/components/variant-selector/variant-selector.component.spec.ts new file mode 100644 index 0000000..2440b05 --- /dev/null +++ b/src/app/shared/components/variant-selector/variant-selector.component.spec.ts @@ -0,0 +1,41 @@ +import '@angular/compiler'; +import { TestBed, getTestBed } from '@angular/core/testing'; +import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; + +import { VariantSelectorComponent } from './variant-selector.component'; + +describe('VariantSelectorComponent', () => { + beforeAll(() => { + try { + getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); + } catch { + // Test environment may already be initialized by another setup entrypoint. + } + }); + + afterEach(() => TestBed.resetTestingModule()); + + it('updates following selections to the first compatible variant', async () => { + await TestBed.configureTestingModule({ + imports: [VariantSelectorComponent], + }).compileComponents(); + const fixture = TestBed.createComponent(VariantSelectorComponent); + fixture.componentRef.setInput('variants', [ + { value: 1, values: { alojamiento: 'Carpa', servicio: 'Almuerzo' } }, + { value: 2, values: { alojamiento: 'Carpa', servicio: 'Cena' } }, + { value: 3, values: { alojamiento: 'Hotel', servicio: 'Cena' } }, + ]); + fixture.componentRef.setInput('selectedVariant', 1); + fixture.detectChanges(); + + (fixture.componentInstance as any).onValueChange('alojamiento', 'Hotel'); + fixture.detectChanges(); + + expect(fixture.componentInstance.selectedVariant()).toBe(3); + expect((fixture.componentInstance as any).selectedValues()).toEqual({ + alojamiento: 'Hotel', + servicio: 'Cena', + }); + }); +}); diff --git a/src/app/shared/components/variant-selector/variant-selector.component.ts b/src/app/shared/components/variant-selector/variant-selector.component.ts new file mode 100644 index 0000000..0eaaf9b --- /dev/null +++ b/src/app/shared/components/variant-selector/variant-selector.component.ts @@ -0,0 +1,161 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + input, + model, + signal, + untracked, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +export type VariantAttributeValue = string | string[]; + +export interface VariantSelectorVariant { + value: unknown; + values: Record; +} + +interface VariantSelectorOption { + key: string; + label: string; + value: VariantAttributeValue; +} + +interface VariantSelectorGroup { + key: string; + label: string; + options: VariantSelectorOption[]; +} + +@Component({ + selector: 'app-variant-selector', + standalone: true, + imports: [FormsModule], + templateUrl: './variant-selector.component.html', + styleUrl: './variant-selector.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VariantSelectorComponent { + readonly variants = input([]); + readonly selectedVariant = model(null); + readonly disabled = input(false); + readonly compact = input(false); + + protected readonly selectedValues = signal>({}); + protected readonly attributeKeys = computed(() => + Array.from(new Set(this.variants().flatMap((variant) => Object.keys(variant.values)))), + ); + protected readonly selectors = computed(() => { + const variants = this.variants(); + const keys = this.attributeKeys(); + const selectedValues = this.selectedValues(); + + return keys.map((key, index) => { + const previousKeys = keys.slice(0, index); + const compatibleVariants = variants.filter((variant) => + previousKeys.every((previousKey) => + this.sameValue(variant.values[previousKey], selectedValues[previousKey]), + ), + ); + + return { + key, + label: this.formatVariantLabel(key), + options: this.optionsFor(compatibleVariants, key), + }; + }); + }); + + constructor() { + effect(() => { + const variants = this.variants(); + const selectedVariant = this.selectedVariant(); + + untracked(() => { + if (variants.length === 0) { + this.selectedValues.set({}); + if (selectedVariant !== null) this.selectedVariant.set(null); + return; + } + + const selected = + variants.find((variant) => Object.is(variant.value, selectedVariant)) ?? variants[0]; + this.selectedValues.set({ ...selected.values }); + if (!Object.is(selected.value, selectedVariant)) this.selectedVariant.set(selected.value); + }); + }); + } + + protected onValueChange(key: string, value: VariantAttributeValue): void { + const variants = this.variants(); + const keys = this.attributeKeys(); + const changedIndex = keys.indexOf(key); + const values = { ...this.selectedValues(), [key]: value }; + + for (let index = changedIndex + 1; index < keys.length; index++) { + const currentKey = keys[index]; + const previousKeys = keys.slice(0, index); + const compatibleVariants = variants.filter((variant) => + previousKeys.every((previousKey) => + this.sameValue(variant.values[previousKey], values[previousKey]), + ), + ); + const options = this.optionsFor(compatibleVariants, currentKey); + + if (!options.some((option) => this.sameValue(option.value, values[currentKey]))) { + const firstOption = options[0]; + if (firstOption) values[currentKey] = firstOption.value; + else delete values[currentKey]; + } + } + + const matchingVariant = variants.find((variant) => + keys.every((attributeKey) => + this.sameValue(variant.values[attributeKey], values[attributeKey]), + ), + ); + + this.selectedValues.set(values); + this.selectedVariant.set(matchingVariant?.value ?? null); + } + + private optionsFor(variants: VariantSelectorVariant[], key: string): VariantSelectorOption[] { + const options = new Map(); + + for (const variant of variants) { + const value = variant.values[key]; + if (value === undefined || value === '') continue; + + const optionKey = this.valueKey(value); + if (!options.has(optionKey)) { + options.set(optionKey, { + key: optionKey, + label: Array.isArray(value) ? value.join(', ') : value, + value, + }); + } + } + + return Array.from(options.values()); + } + + private sameValue( + left: VariantAttributeValue | undefined, + right: VariantAttributeValue | undefined, + ): boolean { + return ( + left !== undefined && right !== undefined && this.valueKey(left) === this.valueKey(right) + ); + } + + private valueKey(value: VariantAttributeValue): string { + return JSON.stringify(value); + } + + private formatVariantLabel(key: string): string { + const label = key.replace(/[_-]+/g, ' '); + return label.charAt(0).toUpperCase() + label.slice(1); + } +} From 56f602d876951bbe1ba908bc0bcc26ec8b3c619e Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 10 Aug 2026 12:25:58 -0300 Subject: [PATCH 13/26] feat: enhance variant attribute handling across components for improved selection and display --- .../store-layout/store-layout.component.ts | 11 ++- src/app/core/services/cart/cart.interface.ts | 4 +- .../services/catalog/catalog.interface.ts | 15 +++- .../product-attribute-selector.component.ts | 7 +- ...uct-vertical-with-cart-card.component.html | 4 +- ...-vertical-with-cart-card.component.spec.ts | 18 ++++ ...oduct-vertical-with-cart-card.component.ts | 82 ++++++++++++++++--- .../variant-selector.component.spec.ts | 18 ++++ .../variant-selector.component.ts | 67 +++++++++++---- 9 files changed, 191 insertions(+), 35 deletions(-) diff --git a/src/app/core/layout/store-layout/store-layout.component.ts b/src/app/core/layout/store-layout/store-layout.component.ts index 98810f6..7e69a81 100644 --- a/src/app/core/layout/store-layout/store-layout.component.ts +++ b/src/app/core/layout/store-layout/store-layout.component.ts @@ -12,6 +12,7 @@ import { findMenu } from '../../services/menu.utils'; import { CheckoutService } from '../../services/checkout.service'; import { ToastService } from '../../services/toast.service'; import { Category } from '../../services/tenant.interface'; +import { VariantAttributeValue } from '../../services/catalog/catalog.interface'; @Component({ selector: 'app-store-layout', @@ -80,7 +81,7 @@ export class StoreLayoutComponent implements OnInit { if (selectedVariant) { attributes = Object.entries(selectedVariant.values).map(([label, value]) => ({ label: this.formatAttributeLabel(label), - value: Array.isArray(value) ? value.join(', ') : value, + value: this.variantAttributeLabel(value), })); } @@ -103,6 +104,14 @@ export class StoreLayoutComponent implements OnInit { return label.charAt(0).toUpperCase() + label.slice(1); } + private variantAttributeLabel(value: VariantAttributeValue): string { + if (typeof value === 'string') return value; + if (Array.isArray(value)) { + return value.map((item) => (typeof item === 'string' ? item : item.label)).join(', '); + } + return value.label; + } + protected readonly currentYear = new Date().getFullYear(); protected readonly tenant = this.tenantService.tenant; protected readonly user = this.authService.user; diff --git a/src/app/core/services/cart/cart.interface.ts b/src/app/core/services/cart/cart.interface.ts index 3145a6c..bc5b8ea 100644 --- a/src/app/core/services/cart/cart.interface.ts +++ b/src/app/core/services/cart/cart.interface.ts @@ -1,3 +1,5 @@ +import { VariantAttributeValue } from '../catalog/catalog.interface'; + export interface CartItemProduct { nombre: string; imagen: string | null; @@ -8,7 +10,7 @@ export interface CartItemVariant { id: number; precio: string; stock_tecnico: number | null; - values: Record; + values: Record; } export interface CartItem { diff --git a/src/app/core/services/catalog/catalog.interface.ts b/src/app/core/services/catalog/catalog.interface.ts index 913546c..663c398 100644 --- a/src/app/core/services/catalog/catalog.interface.ts +++ b/src/app/core/services/catalog/catalog.interface.ts @@ -47,6 +47,17 @@ export interface ProductAttribute { export type InventoryPolicy = 'tracked' | 'unlimited'; +export interface VariantAttributeOptionValue { + value: string; + label: string; +} + +export type VariantAttributeValue = + | VariantAttributeOptionValue + | VariantAttributeOptionValue[] + | string + | string[]; + export interface CatalogItemVariant { id: number; descripcion?: string | null; @@ -59,7 +70,7 @@ export interface CatalogItemVariant { maximum_use_date?: string | null; effective_minimum_use_date?: string | null; effective_maximum_use_date?: string | null; - values: Record; + values: Record; } export interface SelectedCatalogItemVariant extends CatalogItemVariant { @@ -98,7 +109,7 @@ export interface CatalogFeaturedItemVariant { descripcion?: string | null; precio?: string; stock_tecnico: number | null; - values: Record; + values: Record; } export interface CatalogFeaturedItem { diff --git a/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.ts b/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.ts index 3df839d..ca37395 100644 --- a/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.ts +++ b/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.ts @@ -14,6 +14,7 @@ import { InventoryPolicy, ProductAttribute, ProductAttributeOption, + VariantAttributeValue, } from '../../../../core/services/catalog/catalog.interface'; @Component({ @@ -198,7 +199,7 @@ export class ProductAttributeSelectorComponent { private getVariantAttributeValues( attribute: ProductAttribute, - variantAttributes: Record, + variantAttributes: Record, ): string[] { const normalizedCodigo = this.normalizeText(attribute.codigo); const normalizedNombre = this.normalizeText(attribute.nombre); @@ -207,7 +208,9 @@ export class ProductAttributeSelectorComponent { const normalizedKey = this.normalizeText(key); if (normalizedKey === normalizedCodigo || normalizedKey === normalizedNombre) { - return (Array.isArray(value) ? value : [value]).map((item) => this.normalizeText(item)); + return (Array.isArray(value) ? value : [value]).map((item) => + this.normalizeText(typeof item === 'string' ? item : item.value), + ); } } 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 267dd4b..ec3531d 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 @@ -28,8 +28,8 @@ [ngModel]="selectedValues()[selector.key]" (ngModelChange)="onVariantValueChange(selector.key, $event)" > - @for (option of selector.options; track option) { - + @for (option of selector.options; track option.key) { + } } 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 ef6fdca..aa8dbbf 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 @@ -136,6 +136,24 @@ describe('ProductVerticalWithCartCardComponent', () => { expect(selectors?.querySelector('app-quantity-selector')).toBeNull(); }); + it('uses the variant value for selection and renders its label', async () => { + const fixture = await createComponent(); + fixture.componentRef.setInput('variants', [ + { + id: 10, + values: { event_date: { value: '2', label: '10/10/2026 · 09:00 a 18:00' } }, + }, + ]); + fixture.detectChanges(); + + const option = fixture.nativeElement.querySelector( + '.product-vertical-with-cart-card__variant-select option', + ) as HTMLOptionElement; + + expect(option.textContent?.trim()).toBe('10/10/2026 · 09:00 a 18:00'); + expect((fixture.componentInstance as any).selectedValues()).toEqual({ event_date: '2' }); + }); + it('places all variant selectors together below price and quantity', async () => { const fixture = await createComponent(); fixture.componentRef.setInput('variants', [ 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 c4a06e7..19e5dc6 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 @@ -13,18 +13,27 @@ import { FormsModule } from '@angular/forms'; import { ButtonComponent } from '../button/button.component'; import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component'; +import { VariantAttributeValue } from '../../../core/services/catalog/catalog.interface'; + +type VariantSelectionValue = string | string[]; export interface VerticalCartVariant { id: number; descripcion?: string | null; precio?: string | number; - values: Record; + values: Record; +} + +interface VariantSelectorOption { + key: string; + value: VariantSelectionValue; + label: string; } interface VariantSelector { key: string; label: string; - options: string[]; + options: VariantSelectorOption[]; } @Component({ @@ -46,7 +55,7 @@ export class ProductVerticalWithCartCardComponent { readonly buy = output<{ quantity: number; variant: number | null }>(); readonly addToCart = output<{ quantity: number; variant: number | null }>(); - protected readonly selectedValues = signal>({}); + protected readonly selectedValues = signal>({}); protected readonly selectedVariantData = computed(() => this.variants().find((variant) => variant.id === this.selectedVariant()), @@ -67,13 +76,7 @@ export class ProductVerticalWithCartCardComponent { return keys.map((key) => ({ key, label: this.formatVariantLabel(key), - options: Array.from( - new Set( - variants - .map((variant) => variant.values[key]) - .filter((value): value is string => Boolean(value)), - ), - ), + options: this.optionsFor(variants, key), })); }); @@ -92,17 +95,21 @@ export class ProductVerticalWithCartCardComponent { const selected = variants.find((variant) => variant.id === this.selectedVariant()) ?? variants[0]; - this.selectedValues.set({ ...selected.values }); + this.selectedValues.set(this.selectionValues(selected.values)); this.selectedVariant.set(selected.id); }); }); } - protected onVariantValueChange(key: string, value: string): void { + protected onVariantValueChange(key: string, value: VariantSelectionValue): void { const values = { ...this.selectedValues(), [key]: value }; const selectors = this.variantSelectors(); const matchingVariant = this.variants().find((variant) => - selectors.every((selector) => variant.values[selector.key] === values[selector.key]), + selectors.every( + (selector) => + this.valueKey(this.selectionValue(variant.values[selector.key])) === + this.valueKey(values[selector.key]), + ), ); this.selectedValues.set(values); @@ -117,6 +124,55 @@ export class ProductVerticalWithCartCardComponent { this.buy.emit({ quantity: this.quantity(), variant: this.selectedVariant() }); } + private optionsFor(variants: VerticalCartVariant[], key: string): VariantSelectorOption[] { + const options = new Map(); + + for (const variant of variants) { + const attributeValue = variant.values[key]; + if (attributeValue === undefined) continue; + + const value = this.selectionValue(attributeValue); + const optionKey = this.valueKey(value); + if (!options.has(optionKey)) { + options.set(optionKey, { + key: optionKey, + value, + label: this.selectionLabel(attributeValue), + }); + } + } + + return Array.from(options.values()); + } + + private selectionValues( + values: Record, + ): Record { + return Object.fromEntries( + Object.entries(values).map(([key, value]) => [key, this.selectionValue(value)]), + ); + } + + private selectionValue(value: VariantAttributeValue): VariantSelectionValue { + if (typeof value === 'string') return value; + if (Array.isArray(value)) { + return value.map((item) => (typeof item === 'string' ? item : item.value)); + } + return value.value; + } + + private selectionLabel(value: VariantAttributeValue): string { + if (typeof value === 'string') return value; + if (Array.isArray(value)) { + return value.map((item) => (typeof item === 'string' ? item : item.label)).join(', '); + } + return value.label; + } + + private valueKey(value: VariantSelectionValue | undefined): string { + return JSON.stringify(value); + } + private formatVariantLabel(key: string): string { const label = key.replace(/[_-]+/g, ' '); return label.charAt(0).toUpperCase() + label.slice(1); diff --git a/src/app/shared/components/variant-selector/variant-selector.component.spec.ts b/src/app/shared/components/variant-selector/variant-selector.component.spec.ts index 2440b05..0650008 100644 --- a/src/app/shared/components/variant-selector/variant-selector.component.spec.ts +++ b/src/app/shared/components/variant-selector/variant-selector.component.spec.ts @@ -38,4 +38,22 @@ describe('VariantSelectorComponent', () => { servicio: 'Cena', }); }); + + it('renders labels while matching variants by value', async () => { + await TestBed.configureTestingModule({ + imports: [VariantSelectorComponent], + }).compileComponents(); + const fixture = TestBed.createComponent(VariantSelectorComponent); + fixture.componentRef.setInput('variants', [ + { + value: 1, + values: { event_date: { value: '2', label: '10/10/2026 · 09:00 a 18:00' } }, + }, + ]); + fixture.detectChanges(); + + const option = fixture.nativeElement.querySelector('option') as HTMLOptionElement; + expect(option.textContent?.trim()).toBe('10/10/2026 · 09:00 a 18:00'); + expect((fixture.componentInstance as any).selectedValues()).toEqual({ event_date: '2' }); + }); }); diff --git a/src/app/shared/components/variant-selector/variant-selector.component.ts b/src/app/shared/components/variant-selector/variant-selector.component.ts index 0eaaf9b..78fa991 100644 --- a/src/app/shared/components/variant-selector/variant-selector.component.ts +++ b/src/app/shared/components/variant-selector/variant-selector.component.ts @@ -9,8 +9,9 @@ import { untracked, } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { VariantAttributeValue } from '../../../core/services/catalog/catalog.interface'; -export type VariantAttributeValue = string | string[]; +type VariantSelectionValue = string | string[]; export interface VariantSelectorVariant { value: unknown; @@ -20,7 +21,7 @@ export interface VariantSelectorVariant { interface VariantSelectorOption { key: string; label: string; - value: VariantAttributeValue; + value: VariantSelectionValue; } interface VariantSelectorGroup { @@ -43,7 +44,7 @@ export class VariantSelectorComponent { readonly disabled = input(false); readonly compact = input(false); - protected readonly selectedValues = signal>({}); + protected readonly selectedValues = signal>({}); protected readonly attributeKeys = computed(() => Array.from(new Set(this.variants().flatMap((variant) => Object.keys(variant.values)))), ); @@ -56,7 +57,10 @@ export class VariantSelectorComponent { const previousKeys = keys.slice(0, index); const compatibleVariants = variants.filter((variant) => previousKeys.every((previousKey) => - this.sameValue(variant.values[previousKey], selectedValues[previousKey]), + this.sameValue( + this.selectionValue(variant.values[previousKey]), + selectedValues[previousKey], + ), ), ); @@ -82,13 +86,13 @@ export class VariantSelectorComponent { const selected = variants.find((variant) => Object.is(variant.value, selectedVariant)) ?? variants[0]; - this.selectedValues.set({ ...selected.values }); + this.selectedValues.set(this.selectionValues(selected.values)); if (!Object.is(selected.value, selectedVariant)) this.selectedVariant.set(selected.value); }); }); } - protected onValueChange(key: string, value: VariantAttributeValue): void { + protected onValueChange(key: string, value: VariantSelectionValue): void { const variants = this.variants(); const keys = this.attributeKeys(); const changedIndex = keys.indexOf(key); @@ -99,7 +103,7 @@ export class VariantSelectorComponent { const previousKeys = keys.slice(0, index); const compatibleVariants = variants.filter((variant) => previousKeys.every((previousKey) => - this.sameValue(variant.values[previousKey], values[previousKey]), + this.sameValue(this.selectionValue(variant.values[previousKey]), values[previousKey]), ), ); const options = this.optionsFor(compatibleVariants, currentKey); @@ -113,7 +117,7 @@ export class VariantSelectorComponent { const matchingVariant = variants.find((variant) => keys.every((attributeKey) => - this.sameValue(variant.values[attributeKey], values[attributeKey]), + this.sameValue(this.selectionValue(variant.values[attributeKey]), values[attributeKey]), ), ); @@ -125,14 +129,19 @@ export class VariantSelectorComponent { const options = new Map(); for (const variant of variants) { - const value = variant.values[key]; - if (value === undefined || value === '') continue; + const attributeValue = variant.values[key]; + if (attributeValue === undefined) continue; + + const value = this.selectionValue(attributeValue); + if (value === undefined || value === '' || (Array.isArray(value) && value.length === 0)) { + continue; + } const optionKey = this.valueKey(value); if (!options.has(optionKey)) { options.set(optionKey, { key: optionKey, - label: Array.isArray(value) ? value.join(', ') : value, + label: this.selectionLabel(attributeValue), value, }); } @@ -142,18 +151,48 @@ export class VariantSelectorComponent { } private sameValue( - left: VariantAttributeValue | undefined, - right: VariantAttributeValue | undefined, + left: VariantSelectionValue | undefined, + right: VariantSelectionValue | undefined, ): boolean { return ( left !== undefined && right !== undefined && this.valueKey(left) === this.valueKey(right) ); } - private valueKey(value: VariantAttributeValue): string { + private valueKey(value: VariantSelectionValue): string { return JSON.stringify(value); } + private selectionValues( + values: Record, + ): Record { + return Object.fromEntries( + Object.entries(values).flatMap(([key, value]) => { + const selectionValue = this.selectionValue(value); + return selectionValue === undefined ? [] : [[key, selectionValue]]; + }), + ); + } + + private selectionValue( + value: VariantAttributeValue | undefined, + ): VariantSelectionValue | undefined { + if (value === undefined) return undefined; + if (typeof value === 'string') return value; + if (Array.isArray(value)) { + return value.map((item) => (typeof item === 'string' ? item : item.value)); + } + return value.value; + } + + private selectionLabel(value: VariantAttributeValue): string { + if (typeof value === 'string') return value; + if (Array.isArray(value)) { + return value.map((item) => (typeof item === 'string' ? item : item.label)).join(', '); + } + return value.label; + } + private formatVariantLabel(key: string): string { const label = key.replace(/[_-]+/g, ' '); return label.charAt(0).toUpperCase() + label.slice(1); From c645feba809f0fe96410c0594fa2c0120193c1a3 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 10 Aug 2026 14:17:09 -0300 Subject: [PATCH 14/26] feat: simplify event date labels in product components for clearer display --- src/app/core/layout/store-layout/store-layout.component.scss | 4 +++- .../product-detail-page/product-detail-page.component.spec.ts | 4 ++-- .../product-vertical-with-cart-card.component.spec.ts | 4 ++-- .../variant-selector/variant-selector.component.spec.ts | 4 ++-- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/app/core/layout/store-layout/store-layout.component.scss b/src/app/core/layout/store-layout/store-layout.component.scss index 272ca6d..b78e519 100644 --- a/src/app/core/layout/store-layout/store-layout.component.scss +++ b/src/app/core/layout/store-layout/store-layout.component.scss @@ -23,7 +23,8 @@ position: absolute; top: 76px; right: calc((100% - 1320px) / 2 + 1.5rem); - + width: 100%; + max-width: 400px; max-height: calc(100vh - 120px); z-index: 1050; background-color: #ffffff; @@ -40,6 +41,7 @@ right: 1rem; left: 1rem; width: auto; + max-width: none; } } 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 928d8d4..da11222 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 @@ -349,14 +349,14 @@ describe('ProductDetailPageComponent', () => { { id: 20, value: '20', - label: '09/10/2026 · 10:00 a 20:00', + label: '09/10/2026', sort_order: 0, metadata: null, }, { id: 21, value: '21', - label: '10/10/2026 · 10:00 a 20:00', + label: '10/10/2026', sort_order: 1, metadata: null, }, 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 aa8dbbf..f5db669 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 @@ -141,7 +141,7 @@ describe('ProductVerticalWithCartCardComponent', () => { fixture.componentRef.setInput('variants', [ { id: 10, - values: { event_date: { value: '2', label: '10/10/2026 · 09:00 a 18:00' } }, + values: { event_date: { value: '2', label: '10/10/2026' } }, }, ]); fixture.detectChanges(); @@ -150,7 +150,7 @@ describe('ProductVerticalWithCartCardComponent', () => { '.product-vertical-with-cart-card__variant-select option', ) as HTMLOptionElement; - expect(option.textContent?.trim()).toBe('10/10/2026 · 09:00 a 18:00'); + expect(option.textContent?.trim()).toBe('10/10/2026'); expect((fixture.componentInstance as any).selectedValues()).toEqual({ event_date: '2' }); }); diff --git a/src/app/shared/components/variant-selector/variant-selector.component.spec.ts b/src/app/shared/components/variant-selector/variant-selector.component.spec.ts index 0650008..e495add 100644 --- a/src/app/shared/components/variant-selector/variant-selector.component.spec.ts +++ b/src/app/shared/components/variant-selector/variant-selector.component.spec.ts @@ -47,13 +47,13 @@ describe('VariantSelectorComponent', () => { fixture.componentRef.setInput('variants', [ { value: 1, - values: { event_date: { value: '2', label: '10/10/2026 · 09:00 a 18:00' } }, + values: { event_date: { value: '2', label: '10/10/2026' } }, }, ]); fixture.detectChanges(); const option = fixture.nativeElement.querySelector('option') as HTMLOptionElement; - expect(option.textContent?.trim()).toBe('10/10/2026 · 09:00 a 18:00'); + expect(option.textContent?.trim()).toBe('10/10/2026'); expect((fixture.componentInstance as any).selectedValues()).toEqual({ event_date: '2' }); }); }); From 0769bb8683f4a6af7fe4ca778e1fdf183f6b0e0c Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 10 Aug 2026 16:17:09 -0300 Subject: [PATCH 15/26] Revert "feat: enhance variant attribute handling across components for improved selection and display" This reverts commit 56f602d876951bbe1ba908bc0bcc26ec8b3c619e. --- .../store-layout/store-layout.component.ts | 11 +-- src/app/core/services/cart/cart.interface.ts | 4 +- .../services/catalog/catalog.interface.ts | 15 +--- .../product-attribute-selector.component.ts | 7 +- ...uct-vertical-with-cart-card.component.html | 4 +- ...-vertical-with-cart-card.component.spec.ts | 18 ---- ...oduct-vertical-with-cart-card.component.ts | 82 +++---------------- .../variant-selector.component.spec.ts | 18 ---- .../variant-selector.component.ts | 67 ++++----------- 9 files changed, 35 insertions(+), 191 deletions(-) diff --git a/src/app/core/layout/store-layout/store-layout.component.ts b/src/app/core/layout/store-layout/store-layout.component.ts index 7e69a81..98810f6 100644 --- a/src/app/core/layout/store-layout/store-layout.component.ts +++ b/src/app/core/layout/store-layout/store-layout.component.ts @@ -12,7 +12,6 @@ import { findMenu } from '../../services/menu.utils'; import { CheckoutService } from '../../services/checkout.service'; import { ToastService } from '../../services/toast.service'; import { Category } from '../../services/tenant.interface'; -import { VariantAttributeValue } from '../../services/catalog/catalog.interface'; @Component({ selector: 'app-store-layout', @@ -81,7 +80,7 @@ export class StoreLayoutComponent implements OnInit { if (selectedVariant) { attributes = Object.entries(selectedVariant.values).map(([label, value]) => ({ label: this.formatAttributeLabel(label), - value: this.variantAttributeLabel(value), + value: Array.isArray(value) ? value.join(', ') : value, })); } @@ -104,14 +103,6 @@ export class StoreLayoutComponent implements OnInit { return label.charAt(0).toUpperCase() + label.slice(1); } - private variantAttributeLabel(value: VariantAttributeValue): string { - if (typeof value === 'string') return value; - if (Array.isArray(value)) { - return value.map((item) => (typeof item === 'string' ? item : item.label)).join(', '); - } - return value.label; - } - protected readonly currentYear = new Date().getFullYear(); protected readonly tenant = this.tenantService.tenant; protected readonly user = this.authService.user; diff --git a/src/app/core/services/cart/cart.interface.ts b/src/app/core/services/cart/cart.interface.ts index bc5b8ea..3145a6c 100644 --- a/src/app/core/services/cart/cart.interface.ts +++ b/src/app/core/services/cart/cart.interface.ts @@ -1,5 +1,3 @@ -import { VariantAttributeValue } from '../catalog/catalog.interface'; - export interface CartItemProduct { nombre: string; imagen: string | null; @@ -10,7 +8,7 @@ export interface CartItemVariant { id: number; precio: string; stock_tecnico: number | null; - values: Record; + values: Record; } export interface CartItem { diff --git a/src/app/core/services/catalog/catalog.interface.ts b/src/app/core/services/catalog/catalog.interface.ts index 663c398..913546c 100644 --- a/src/app/core/services/catalog/catalog.interface.ts +++ b/src/app/core/services/catalog/catalog.interface.ts @@ -47,17 +47,6 @@ export interface ProductAttribute { export type InventoryPolicy = 'tracked' | 'unlimited'; -export interface VariantAttributeOptionValue { - value: string; - label: string; -} - -export type VariantAttributeValue = - | VariantAttributeOptionValue - | VariantAttributeOptionValue[] - | string - | string[]; - export interface CatalogItemVariant { id: number; descripcion?: string | null; @@ -70,7 +59,7 @@ export interface CatalogItemVariant { maximum_use_date?: string | null; effective_minimum_use_date?: string | null; effective_maximum_use_date?: string | null; - values: Record; + values: Record; } export interface SelectedCatalogItemVariant extends CatalogItemVariant { @@ -109,7 +98,7 @@ export interface CatalogFeaturedItemVariant { descripcion?: string | null; precio?: string; stock_tecnico: number | null; - values: Record; + values: Record; } export interface CatalogFeaturedItem { diff --git a/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.ts b/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.ts index ca37395..3df839d 100644 --- a/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.ts +++ b/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.ts @@ -14,7 +14,6 @@ import { InventoryPolicy, ProductAttribute, ProductAttributeOption, - VariantAttributeValue, } from '../../../../core/services/catalog/catalog.interface'; @Component({ @@ -199,7 +198,7 @@ export class ProductAttributeSelectorComponent { private getVariantAttributeValues( attribute: ProductAttribute, - variantAttributes: Record, + variantAttributes: Record, ): string[] { const normalizedCodigo = this.normalizeText(attribute.codigo); const normalizedNombre = this.normalizeText(attribute.nombre); @@ -208,9 +207,7 @@ export class ProductAttributeSelectorComponent { const normalizedKey = this.normalizeText(key); if (normalizedKey === normalizedCodigo || normalizedKey === normalizedNombre) { - return (Array.isArray(value) ? value : [value]).map((item) => - this.normalizeText(typeof item === 'string' ? item : item.value), - ); + return (Array.isArray(value) ? value : [value]).map((item) => this.normalizeText(item)); } } 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 ec3531d..267dd4b 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 @@ -28,8 +28,8 @@ [ngModel]="selectedValues()[selector.key]" (ngModelChange)="onVariantValueChange(selector.key, $event)" > - @for (option of selector.options; track option.key) { - + @for (option of selector.options; track option) { + } } 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 f5db669..ef6fdca 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 @@ -136,24 +136,6 @@ describe('ProductVerticalWithCartCardComponent', () => { expect(selectors?.querySelector('app-quantity-selector')).toBeNull(); }); - it('uses the variant value for selection and renders its label', async () => { - const fixture = await createComponent(); - fixture.componentRef.setInput('variants', [ - { - id: 10, - values: { event_date: { value: '2', label: '10/10/2026' } }, - }, - ]); - fixture.detectChanges(); - - const option = fixture.nativeElement.querySelector( - '.product-vertical-with-cart-card__variant-select option', - ) as HTMLOptionElement; - - expect(option.textContent?.trim()).toBe('10/10/2026'); - expect((fixture.componentInstance as any).selectedValues()).toEqual({ event_date: '2' }); - }); - it('places all variant selectors together below price and quantity', async () => { const fixture = await createComponent(); fixture.componentRef.setInput('variants', [ 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 19e5dc6..c4a06e7 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 @@ -13,27 +13,18 @@ import { FormsModule } from '@angular/forms'; import { ButtonComponent } from '../button/button.component'; import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component'; -import { VariantAttributeValue } from '../../../core/services/catalog/catalog.interface'; - -type VariantSelectionValue = string | string[]; export interface VerticalCartVariant { id: number; descripcion?: string | null; precio?: string | number; - values: Record; -} - -interface VariantSelectorOption { - key: string; - value: VariantSelectionValue; - label: string; + values: Record; } interface VariantSelector { key: string; label: string; - options: VariantSelectorOption[]; + options: string[]; } @Component({ @@ -55,7 +46,7 @@ export class ProductVerticalWithCartCardComponent { readonly buy = output<{ quantity: number; variant: number | null }>(); readonly addToCart = output<{ quantity: number; variant: number | null }>(); - protected readonly selectedValues = signal>({}); + protected readonly selectedValues = signal>({}); protected readonly selectedVariantData = computed(() => this.variants().find((variant) => variant.id === this.selectedVariant()), @@ -76,7 +67,13 @@ export class ProductVerticalWithCartCardComponent { return keys.map((key) => ({ key, label: this.formatVariantLabel(key), - options: this.optionsFor(variants, key), + options: Array.from( + new Set( + variants + .map((variant) => variant.values[key]) + .filter((value): value is string => Boolean(value)), + ), + ), })); }); @@ -95,21 +92,17 @@ export class ProductVerticalWithCartCardComponent { const selected = variants.find((variant) => variant.id === this.selectedVariant()) ?? variants[0]; - this.selectedValues.set(this.selectionValues(selected.values)); + this.selectedValues.set({ ...selected.values }); this.selectedVariant.set(selected.id); }); }); } - protected onVariantValueChange(key: string, value: VariantSelectionValue): void { + protected onVariantValueChange(key: string, value: string): void { const values = { ...this.selectedValues(), [key]: value }; const selectors = this.variantSelectors(); const matchingVariant = this.variants().find((variant) => - selectors.every( - (selector) => - this.valueKey(this.selectionValue(variant.values[selector.key])) === - this.valueKey(values[selector.key]), - ), + selectors.every((selector) => variant.values[selector.key] === values[selector.key]), ); this.selectedValues.set(values); @@ -124,55 +117,6 @@ export class ProductVerticalWithCartCardComponent { this.buy.emit({ quantity: this.quantity(), variant: this.selectedVariant() }); } - private optionsFor(variants: VerticalCartVariant[], key: string): VariantSelectorOption[] { - const options = new Map(); - - for (const variant of variants) { - const attributeValue = variant.values[key]; - if (attributeValue === undefined) continue; - - const value = this.selectionValue(attributeValue); - const optionKey = this.valueKey(value); - if (!options.has(optionKey)) { - options.set(optionKey, { - key: optionKey, - value, - label: this.selectionLabel(attributeValue), - }); - } - } - - return Array.from(options.values()); - } - - private selectionValues( - values: Record, - ): Record { - return Object.fromEntries( - Object.entries(values).map(([key, value]) => [key, this.selectionValue(value)]), - ); - } - - private selectionValue(value: VariantAttributeValue): VariantSelectionValue { - if (typeof value === 'string') return value; - if (Array.isArray(value)) { - return value.map((item) => (typeof item === 'string' ? item : item.value)); - } - return value.value; - } - - private selectionLabel(value: VariantAttributeValue): string { - if (typeof value === 'string') return value; - if (Array.isArray(value)) { - return value.map((item) => (typeof item === 'string' ? item : item.label)).join(', '); - } - return value.label; - } - - private valueKey(value: VariantSelectionValue | undefined): string { - return JSON.stringify(value); - } - private formatVariantLabel(key: string): string { const label = key.replace(/[_-]+/g, ' '); return label.charAt(0).toUpperCase() + label.slice(1); diff --git a/src/app/shared/components/variant-selector/variant-selector.component.spec.ts b/src/app/shared/components/variant-selector/variant-selector.component.spec.ts index e495add..2440b05 100644 --- a/src/app/shared/components/variant-selector/variant-selector.component.spec.ts +++ b/src/app/shared/components/variant-selector/variant-selector.component.spec.ts @@ -38,22 +38,4 @@ describe('VariantSelectorComponent', () => { servicio: 'Cena', }); }); - - it('renders labels while matching variants by value', async () => { - await TestBed.configureTestingModule({ - imports: [VariantSelectorComponent], - }).compileComponents(); - const fixture = TestBed.createComponent(VariantSelectorComponent); - fixture.componentRef.setInput('variants', [ - { - value: 1, - values: { event_date: { value: '2', label: '10/10/2026' } }, - }, - ]); - fixture.detectChanges(); - - const option = fixture.nativeElement.querySelector('option') as HTMLOptionElement; - expect(option.textContent?.trim()).toBe('10/10/2026'); - expect((fixture.componentInstance as any).selectedValues()).toEqual({ event_date: '2' }); - }); }); diff --git a/src/app/shared/components/variant-selector/variant-selector.component.ts b/src/app/shared/components/variant-selector/variant-selector.component.ts index 78fa991..0eaaf9b 100644 --- a/src/app/shared/components/variant-selector/variant-selector.component.ts +++ b/src/app/shared/components/variant-selector/variant-selector.component.ts @@ -9,9 +9,8 @@ import { untracked, } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { VariantAttributeValue } from '../../../core/services/catalog/catalog.interface'; -type VariantSelectionValue = string | string[]; +export type VariantAttributeValue = string | string[]; export interface VariantSelectorVariant { value: unknown; @@ -21,7 +20,7 @@ export interface VariantSelectorVariant { interface VariantSelectorOption { key: string; label: string; - value: VariantSelectionValue; + value: VariantAttributeValue; } interface VariantSelectorGroup { @@ -44,7 +43,7 @@ export class VariantSelectorComponent { readonly disabled = input(false); readonly compact = input(false); - protected readonly selectedValues = signal>({}); + protected readonly selectedValues = signal>({}); protected readonly attributeKeys = computed(() => Array.from(new Set(this.variants().flatMap((variant) => Object.keys(variant.values)))), ); @@ -57,10 +56,7 @@ export class VariantSelectorComponent { const previousKeys = keys.slice(0, index); const compatibleVariants = variants.filter((variant) => previousKeys.every((previousKey) => - this.sameValue( - this.selectionValue(variant.values[previousKey]), - selectedValues[previousKey], - ), + this.sameValue(variant.values[previousKey], selectedValues[previousKey]), ), ); @@ -86,13 +82,13 @@ export class VariantSelectorComponent { const selected = variants.find((variant) => Object.is(variant.value, selectedVariant)) ?? variants[0]; - this.selectedValues.set(this.selectionValues(selected.values)); + this.selectedValues.set({ ...selected.values }); if (!Object.is(selected.value, selectedVariant)) this.selectedVariant.set(selected.value); }); }); } - protected onValueChange(key: string, value: VariantSelectionValue): void { + protected onValueChange(key: string, value: VariantAttributeValue): void { const variants = this.variants(); const keys = this.attributeKeys(); const changedIndex = keys.indexOf(key); @@ -103,7 +99,7 @@ export class VariantSelectorComponent { const previousKeys = keys.slice(0, index); const compatibleVariants = variants.filter((variant) => previousKeys.every((previousKey) => - this.sameValue(this.selectionValue(variant.values[previousKey]), values[previousKey]), + this.sameValue(variant.values[previousKey], values[previousKey]), ), ); const options = this.optionsFor(compatibleVariants, currentKey); @@ -117,7 +113,7 @@ export class VariantSelectorComponent { const matchingVariant = variants.find((variant) => keys.every((attributeKey) => - this.sameValue(this.selectionValue(variant.values[attributeKey]), values[attributeKey]), + this.sameValue(variant.values[attributeKey], values[attributeKey]), ), ); @@ -129,19 +125,14 @@ export class VariantSelectorComponent { const options = new Map(); for (const variant of variants) { - const attributeValue = variant.values[key]; - if (attributeValue === undefined) continue; - - const value = this.selectionValue(attributeValue); - if (value === undefined || value === '' || (Array.isArray(value) && value.length === 0)) { - continue; - } + const value = variant.values[key]; + if (value === undefined || value === '') continue; const optionKey = this.valueKey(value); if (!options.has(optionKey)) { options.set(optionKey, { key: optionKey, - label: this.selectionLabel(attributeValue), + label: Array.isArray(value) ? value.join(', ') : value, value, }); } @@ -151,48 +142,18 @@ export class VariantSelectorComponent { } private sameValue( - left: VariantSelectionValue | undefined, - right: VariantSelectionValue | undefined, + left: VariantAttributeValue | undefined, + right: VariantAttributeValue | undefined, ): boolean { return ( left !== undefined && right !== undefined && this.valueKey(left) === this.valueKey(right) ); } - private valueKey(value: VariantSelectionValue): string { + private valueKey(value: VariantAttributeValue): string { return JSON.stringify(value); } - private selectionValues( - values: Record, - ): Record { - return Object.fromEntries( - Object.entries(values).flatMap(([key, value]) => { - const selectionValue = this.selectionValue(value); - return selectionValue === undefined ? [] : [[key, selectionValue]]; - }), - ); - } - - private selectionValue( - value: VariantAttributeValue | undefined, - ): VariantSelectionValue | undefined { - if (value === undefined) return undefined; - if (typeof value === 'string') return value; - if (Array.isArray(value)) { - return value.map((item) => (typeof item === 'string' ? item : item.value)); - } - return value.value; - } - - private selectionLabel(value: VariantAttributeValue): string { - if (typeof value === 'string') return value; - if (Array.isArray(value)) { - return value.map((item) => (typeof item === 'string' ? item : item.label)).join(', '); - } - return value.label; - } - private formatVariantLabel(key: string): string { const label = key.replace(/[_-]+/g, ' '); return label.charAt(0).toUpperCase() + label.slice(1); From 9ea85b7924e5ca73a3e27eef00bb02d61a0c6aec Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 10 Aug 2026 16:34:02 -0300 Subject: [PATCH 16/26] feat: enhance variant handling in selector and layout components for improved display and selection --- .../store-layout/store-layout.component.ts | 9 +++- src/app/core/services/cart/cart.interface.ts | 12 +++++- .../variant-selector.component.html | 1 + .../variant-selector.component.spec.ts | 41 +++++++++++++++++++ .../variant-selector.component.ts | 29 +++++++++++-- 5 files changed, 86 insertions(+), 6 deletions(-) diff --git a/src/app/core/layout/store-layout/store-layout.component.ts b/src/app/core/layout/store-layout/store-layout.component.ts index 98810f6..cb4ef29 100644 --- a/src/app/core/layout/store-layout/store-layout.component.ts +++ b/src/app/core/layout/store-layout/store-layout.component.ts @@ -6,7 +6,7 @@ import { StoreFooterComponent, StoreFooterSection } from './store-footer/store-f import { StoreHeaderComponent } from './store-header/store-header.component'; import { CartComponent, CartItemMock } from '../../../shared/components/cart/cart.component'; import { ButtonComponent } from '../../../shared/components/button/button.component'; -import { CartItem } from '../../services/cart/cart.interface'; +import { CartItem, CartItemVariantValue } from '../../services/cart/cart.interface'; import { AuthService } from '../../services/auth/auth.service'; import { findMenu } from '../../services/menu.utils'; import { CheckoutService } from '../../services/checkout.service'; @@ -80,7 +80,7 @@ export class StoreLayoutComponent implements OnInit { if (selectedVariant) { attributes = Object.entries(selectedVariant.values).map(([label, value]) => ({ label: this.formatAttributeLabel(label), - value: Array.isArray(value) ? value.join(', ') : value, + value: this.formatAttributeValue(value), })); } @@ -103,6 +103,11 @@ export class StoreLayoutComponent implements OnInit { return label.charAt(0).toUpperCase() + label.slice(1); } + private formatAttributeValue(value: CartItemVariantValue): string { + const values = Array.isArray(value) ? value : [value]; + return values.map((item) => (typeof item === 'string' ? item : item.label)).join(', '); + } + protected readonly currentYear = new Date().getFullYear(); protected readonly tenant = this.tenantService.tenant; protected readonly user = this.authService.user; diff --git a/src/app/core/services/cart/cart.interface.ts b/src/app/core/services/cart/cart.interface.ts index 3145a6c..6846737 100644 --- a/src/app/core/services/cart/cart.interface.ts +++ b/src/app/core/services/cart/cart.interface.ts @@ -4,11 +4,21 @@ export interface CartItemProduct { variants?: CartItemVariant[]; } +export interface CartItemVariantOption { + value: string; + label: string; +} + +export type CartItemVariantValue = + | string + | CartItemVariantOption + | Array; + export interface CartItemVariant { id: number; precio: string; stock_tecnico: number | null; - values: Record; + values: Record; } export interface CartItem { diff --git a/src/app/shared/components/variant-selector/variant-selector.component.html b/src/app/shared/components/variant-selector/variant-selector.component.html index 571b3b2..bb068dc 100644 --- a/src/app/shared/components/variant-selector/variant-selector.component.html +++ b/src/app/shared/components/variant-selector/variant-selector.component.html @@ -5,6 +5,7 @@ class="form-select variant-selector__select" [attr.aria-label]="selector.label" [disabled]="disabled()" + [compareWith]="compareValues" [ngModel]="selectedValues()[selector.key]" (ngModelChange)="onValueChange(selector.key, $event)" > diff --git a/src/app/shared/components/variant-selector/variant-selector.component.spec.ts b/src/app/shared/components/variant-selector/variant-selector.component.spec.ts index 2440b05..ee14425 100644 --- a/src/app/shared/components/variant-selector/variant-selector.component.spec.ts +++ b/src/app/shared/components/variant-selector/variant-selector.component.spec.ts @@ -38,4 +38,45 @@ describe('VariantSelectorComponent', () => { servicio: 'Cena', }); }); + + it('renders backend option labels and compares equivalent option objects by value', async () => { + await TestBed.configureTestingModule({ + imports: [VariantSelectorComponent], + }).compileComponents(); + const fixture = TestBed.createComponent(VariantSelectorComponent); + fixture.componentRef.setInput('variants', [ + { + value: 1, + values: { + color: { value: 'red', label: 'Rojo' }, + talle: { value: 's', label: 'Small' }, + }, + }, + { + value: 2, + values: { + color: { value: 'red', label: 'Rojo' }, + talle: { value: 'm', label: 'Medium' }, + }, + }, + ]); + fixture.componentRef.setInput('selectedVariant', 2); + fixture.detectChanges(); + + const selects = Array.from( + fixture.nativeElement.querySelectorAll('select'), + ) as HTMLSelectElement[]; + + expect(selects).toHaveLength(2); + expect(selects[0].selectedOptions[0]?.textContent).toBe('Rojo'); + expect(selects[1].selectedOptions[0]?.textContent).toBe('Medium'); + expect(fixture.nativeElement.textContent).not.toContain('[object Object]'); + + (fixture.componentInstance as any).onValueChange('talle', { + value: 's', + label: 'Small', + }); + + expect(fixture.componentInstance.selectedVariant()).toBe(1); + }); }); diff --git a/src/app/shared/components/variant-selector/variant-selector.component.ts b/src/app/shared/components/variant-selector/variant-selector.component.ts index 0eaaf9b..324e035 100644 --- a/src/app/shared/components/variant-selector/variant-selector.component.ts +++ b/src/app/shared/components/variant-selector/variant-selector.component.ts @@ -10,7 +10,13 @@ import { } from '@angular/core'; import { FormsModule } from '@angular/forms'; -export type VariantAttributeValue = string | string[]; +export interface VariantAttributeOption { + value: string; + label: string; +} + +export type VariantAttributeScalar = string | VariantAttributeOption; +export type VariantAttributeValue = VariantAttributeScalar | VariantAttributeScalar[]; export interface VariantSelectorVariant { value: unknown; @@ -44,6 +50,10 @@ export class VariantSelectorComponent { readonly compact = input(false); protected readonly selectedValues = signal>({}); + protected readonly compareValues = ( + left: VariantAttributeValue | null, + right: VariantAttributeValue | null, + ): boolean => left !== null && right !== null && this.sameValue(left, right); protected readonly attributeKeys = computed(() => Array.from(new Set(this.variants().flatMap((variant) => Object.keys(variant.values)))), ); @@ -132,7 +142,7 @@ export class VariantSelectorComponent { if (!options.has(optionKey)) { options.set(optionKey, { key: optionKey, - label: Array.isArray(value) ? value.join(', ') : value, + label: this.valueLabel(value), value, }); } @@ -151,7 +161,20 @@ export class VariantSelectorComponent { } private valueKey(value: VariantAttributeValue): string { - return JSON.stringify(value); + const comparableValue = Array.isArray(value) + ? value.map((item) => this.scalarValue(item)) + : this.scalarValue(value); + + return JSON.stringify(comparableValue); + } + + private valueLabel(value: VariantAttributeValue): string { + const values = Array.isArray(value) ? value : [value]; + return values.map((item) => (typeof item === 'string' ? item : item.label)).join(', '); + } + + private scalarValue(value: VariantAttributeScalar): string { + return typeof value === 'string' ? value : value.value; } private formatVariantLabel(key: string): string { From 019c9df23bf80b006508f2d2a95e5b1eea6fdcf7 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 11 Aug 2026 08:24:17 -0300 Subject: [PATCH 17/26] feat: update modal and cart components for improved user feedback and layout adjustments --- src/app/core/services/modal.service.spec.ts | 2 ++ src/app/core/services/modal.service.ts | 5 ++++- .../components/ticket/ticket.component.scss | 5 +++++ .../components/cart/cart.component.spec.ts | 2 +- .../shared/components/cart/cart.component.ts | 2 +- .../confirm-delete-modal.component.scss | 20 ++++++++++++------- 6 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/app/core/services/modal.service.spec.ts b/src/app/core/services/modal.service.spec.ts index f258b47..b25464e 100644 --- a/src/app/core/services/modal.service.spec.ts +++ b/src/app/core/services/modal.service.spec.ts @@ -163,6 +163,8 @@ describe('ModalService', () => { const activeModal = service.activeModal(); const resultPromise = firstValueFrom(result$); + expect(activeModal?.config.size).toBe('sm'); + activeModal?.ref.dismiss('escape'); await expect(resultPromise).resolves.toBe(false); diff --git a/src/app/core/services/modal.service.ts b/src/app/core/services/modal.service.ts index 4abdcb1..3dfd1ff 100644 --- a/src/app/core/services/modal.service.ts +++ b/src/app/core/services/modal.service.ts @@ -166,7 +166,10 @@ export class ModalService { } openConfirmDeleteRef(config: ConfirmModalConfig): ModalRef { - return this.open(ConfirmDeleteModalComponent, this.buildConfirmModalConfig(config)); + return this.open( + ConfirmDeleteModalComponent, + this.buildConfirmModalConfig({ size: 'sm', ...config }), + ); } openSimple(config: SimpleModalConfig): Observable { diff --git a/src/app/features/store/pages/account-page/pages/tickets-page/components/ticket/ticket.component.scss b/src/app/features/store/pages/account-page/pages/tickets-page/components/ticket/ticket.component.scss index 3b6ece5..764a3c7 100644 --- a/src/app/features/store/pages/account-page/pages/tickets-page/components/ticket/ticket.component.scss +++ b/src/app/features/store/pages/account-page/pages/tickets-page/components/ticket/ticket.component.scss @@ -74,6 +74,7 @@ .ticket__actions { display: flex; + grid-column: 4; align-items: center; gap: 8px; } @@ -91,6 +92,10 @@ color: #8a8a8a; } +.ticket--disabled .ticket__actions { + grid-column: 3; +} + :host ::ng-deep .ticket--disabled button.ticket__qr-button:disabled { --bs-btn-disabled-bg: #8a8a8a; --bs-btn-disabled-border-color: #8a8a8a; diff --git a/src/app/shared/components/cart/cart.component.spec.ts b/src/app/shared/components/cart/cart.component.spec.ts index bdf87d1..0909875 100644 --- a/src/app/shared/components/cart/cart.component.spec.ts +++ b/src/app/shared/components/cart/cart.component.spec.ts @@ -115,7 +115,7 @@ describe('CartComponent', () => { expect(openConfirmDelete).toHaveBeenCalledWith({ title: 'Eliminar producto', - content: 'Se eliminara "Producto de prueba" del carrito. Esta accion no se puede deshacer.', + content: 'Se eliminará “Producto de prueba” del carrito. Esta acción no se puede deshacer.', confirmLabel: 'Eliminar', cancelLabel: 'Cancelar', }); diff --git a/src/app/shared/components/cart/cart.component.ts b/src/app/shared/components/cart/cart.component.ts index c1f852c..c08cae9 100644 --- a/src/app/shared/components/cart/cart.component.ts +++ b/src/app/shared/components/cart/cart.component.ts @@ -204,7 +204,7 @@ export class CartComponent { this.modalService .openConfirmDelete({ title: 'Eliminar producto', - content: `Se eliminara "${target.productName}" del carrito. Esta accion no se puede deshacer.`, + content: `Se eliminará “${target.productName}” del carrito. Esta acción no se puede deshacer.`, confirmLabel: 'Eliminar', cancelLabel: 'Cancelar', }) diff --git a/src/app/shared/components/confirm-delete-modal/confirm-delete-modal.component.scss b/src/app/shared/components/confirm-delete-modal/confirm-delete-modal.component.scss index 3e07d69..08d7de4 100644 --- a/src/app/shared/components/confirm-delete-modal/confirm-delete-modal.component.scss +++ b/src/app/shared/components/confirm-delete-modal/confirm-delete-modal.component.scss @@ -1,19 +1,25 @@ .confirm-modal { display: grid; - gap: 1.5rem; - justify-items: center; + width: 100%; + gap: 1.25rem; text-align: center; } .confirm-modal__content { margin: 0; - color: #666666; - line-height: 1.5; + color: #5f6368; + line-height: 1.55; } .confirm-modal__actions { - display: flex; - justify-content: center; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + width: 100%; gap: 0.75rem; - flex-wrap: wrap; +} + +@media (max-width: 380px) { + .confirm-modal__actions { + grid-template-columns: 1fr; + } } From f6f3a997f73ff1bba7c864b0fab87f93c01355df Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 11 Aug 2026 08:28:42 -0300 Subject: [PATCH 18/26] feat: refactor variant selection to use app-variant-selector for improved maintainability --- ...uct-vertical-with-cart-card.component.html | 16 +--- ...uct-vertical-with-cart-card.component.scss | 26 +++---- ...-vertical-with-cart-card.component.spec.ts | 8 +- ...oduct-vertical-with-cart-card.component.ts | 75 +++---------------- 4 files changed, 29 insertions(+), 96 deletions(-) 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 267dd4b..b6e3baa 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 @@ -21,18 +21,10 @@
- @for (selector of variantSelectors(); track selector.key) { - - } +
} diff --git a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.scss b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.scss index 00ad1f3..1f33e56 100644 --- a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.scss +++ b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.scss @@ -66,24 +66,22 @@ } &__variant-selectors { - display: flex; - align-items: center; width: 100%; - gap: 8px; } - &__variant-select { - flex: 1 1 0; - min-width: 0; - height: 40px; - border-color: #d8d8d8; - color: #6f6f6f; - font-size: 12px; + &__variant-selectors app-variant-selector { + width: 100%; + } - &:focus { - border-color: var(--tenant-primary, #009933); - box-shadow: 0 0 0 0.2rem color-mix(in srgb, transparent 75%, var(--tenant-primary, #009933)); - } + &__variant-selectors ::ng-deep .variant-selector { + flex-wrap: nowrap; + width: 100%; + } + + &__variant-selectors ::ng-deep .variant-selector__select { + flex: 1 1 0; + width: 0; + min-width: 0; } &__price { 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 ef6fdca..ea0b626 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 @@ -131,7 +131,7 @@ describe('ProductVerticalWithCartCardComponent', () => { expect(summary?.querySelector('.product-vertical-with-cart-card__price')).not.toBeNull(); expect(summary?.querySelector('app-quantity-selector')).not.toBeNull(); expect( - selectors?.querySelectorAll('.product-vertical-with-cart-card__variant-select'), + selectors?.querySelectorAll('.variant-selector__select'), ).toHaveLength(1); expect(selectors?.querySelector('app-quantity-selector')).toBeNull(); }); @@ -150,7 +150,7 @@ describe('ProductVerticalWithCartCardComponent', () => { expect(layout?.querySelector('.product-vertical-with-cart-card__summary')).not.toBeNull(); expect( layout?.querySelectorAll( - '.product-vertical-with-cart-card__variant-selectors .product-vertical-with-cart-card__variant-select', + '.product-vertical-with-cart-card__variant-selectors .variant-selector__select', ), ).toHaveLength(2); }); @@ -166,7 +166,7 @@ describe('ProductVerticalWithCartCardComponent', () => { fixture.componentInstance.buy.subscribe(buySpy); const select = fixture.nativeElement.querySelector( - '.product-vertical-with-cart-card__variant-select', + '.variant-selector__select', ) as HTMLSelectElement; select.value = select.options[1].value; select.dispatchEvent(new Event('change')); @@ -207,7 +207,7 @@ describe('ProductVerticalWithCartCardComponent', () => { ).toBe('$ 10.000'); const select = element.querySelector( - '.product-vertical-with-cart-card__variant-select', + '.variant-selector__select', ) as HTMLSelectElement; select.value = select.options[1].value; select.dispatchEvent(new Event('change')); 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 c4a06e7..0ceed82 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 @@ -2,17 +2,17 @@ import { ChangeDetectionStrategy, Component, computed, - effect, input, model, output, - signal, - untracked, } from '@angular/core'; -import { FormsModule } from '@angular/forms'; import { ButtonComponent } from '../button/button.component'; import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component'; +import { + VariantSelectorComponent, + VariantSelectorVariant, +} from '../variant-selector/variant-selector.component'; export interface VerticalCartVariant { id: number; @@ -21,15 +21,9 @@ export interface VerticalCartVariant { values: Record; } -interface VariantSelector { - key: string; - label: string; - options: string[]; -} - @Component({ selector: 'app-product-vertical-with-cart-card', - imports: [ButtonComponent, FormsModule, QuantitySelectorComponent], + imports: [ButtonComponent, QuantitySelectorComponent, VariantSelectorComponent], templateUrl: './product-vertical-with-cart-card.component.html', styleUrl: './product-vertical-with-cart-card.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -46,8 +40,6 @@ export class ProductVerticalWithCartCardComponent { readonly buy = output<{ quantity: number; variant: number | null }>(); readonly addToCart = output<{ quantity: number; variant: number | null }>(); - protected readonly selectedValues = signal>({}); - protected readonly selectedVariantData = computed(() => this.variants().find((variant) => variant.id === this.selectedVariant()), ); @@ -60,54 +52,10 @@ export class ProductVerticalWithCartCardComponent { 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)))); - - return keys.map((key) => ({ - key, - label: this.formatVariantLabel(key), - options: Array.from( - new Set( - variants - .map((variant) => variant.values[key]) - .filter((value): value is string => Boolean(value)), - ), - ), - })); - }); - - protected readonly hasVariants = computed(() => this.variantSelectors().length > 0); - - constructor() { - effect(() => { - const variants = this.variants(); - - untracked(() => { - if (variants.length === 0) { - this.selectedValues.set({}); - this.selectedVariant.set(null); - return; - } - - const selected = - variants.find((variant) => variant.id === this.selectedVariant()) ?? variants[0]; - this.selectedValues.set({ ...selected.values }); - this.selectedVariant.set(selected.id); - }); - }); - } - - protected onVariantValueChange(key: string, value: string): void { - const values = { ...this.selectedValues(), [key]: value }; - const selectors = this.variantSelectors(); - const matchingVariant = this.variants().find((variant) => - selectors.every((selector) => variant.values[selector.key] === values[selector.key]), - ); - - this.selectedValues.set(values); - this.selectedVariant.set(matchingVariant?.id ?? null); - } + protected readonly selectorVariants = computed(() => + this.variants().map((variant) => ({ value: variant.id, values: variant.values })), + ); + protected readonly hasVariants = computed(() => this.selectorVariants().length > 0); protected onAddToCart(): void { this.addToCart.emit({ quantity: this.quantity(), variant: this.selectedVariant() }); @@ -117,11 +65,6 @@ export class ProductVerticalWithCartCardComponent { this.buy.emit({ quantity: this.quantity(), variant: this.selectedVariant() }); } - private formatVariantLabel(key: string): string { - const label = key.replace(/[_-]+/g, ' '); - return label.charAt(0).toUpperCase() + label.slice(1); - } - private formatCurrency(value: number): string { const rounded = Math.round(value); const parts = rounded.toString().split('.'); From c75787a59c2c3c793cbe2ad85491b5069ce3dd14 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 11 Aug 2026 08:50:32 -0300 Subject: [PATCH 19/26] feat: enhance hero banner component with event schedule toggle and styling improvements --- .../hero-banner/hero-banner.component.html | 122 ++++++++++++------ .../hero-banner/hero-banner.component.scss | 109 +++++++++++----- .../hero-banner/hero-banner.component.spec.ts | 50 +++++++ .../hero-banner/hero-banner.component.ts | 26 ++++ src/styles.scss | 28 +++- 5 files changed, 260 insertions(+), 75 deletions(-) create mode 100644 src/app/shared/components/hero-banner/hero-banner.component.spec.ts diff --git a/src/app/shared/components/hero-banner/hero-banner.component.html b/src/app/shared/components/hero-banner/hero-banner.component.html index 79aea97..e3a20bd 100644 --- a/src/app/shared/components/hero-banner/hero-banner.component.html +++ b/src/app/shared/components/hero-banner/hero-banner.component.html @@ -1,13 +1,15 @@
+ + @if (eventConfig) { +
+
+ @if (eventConfig.title) { +

{{ eventConfig.title }}

+ } + +
+ @if (eventConfig.dates && eventConfig.dates.length > 0) { +
+ + {{ formattedDates }} +
+ } + @if (eventConfig.location) { +
+ + {{ eventConfig.location }} +
+ } +
+ + @if (eventConfig.dates && eventConfig.dates.length > 0) { + + + @if (schedulesExpanded) { +
+ + + @for (eventDate of eventConfig.dates; track eventDate.id ?? eventDate.date) { + + + + + } + +
+ + + {{ formatScheduleDate(eventDate.date) }} + + + + + + {{ formatScheduleTime(eventDate.start_time) }} - + {{ formatScheduleTime(eventDate.end_time) }} + + +
+
+ } + } +
+
+ }
diff --git a/src/app/shared/components/hero-banner/hero-banner.component.scss b/src/app/shared/components/hero-banner/hero-banner.component.scss index 52a25cd..a030b6a 100644 --- a/src/app/shared/components/hero-banner/hero-banner.component.scss +++ b/src/app/shared/components/hero-banner/hero-banner.component.scss @@ -1,5 +1,4 @@ .hero-banner-container { - padding-bottom: 4rem; /* Spacing to accommodate the overlapping card */ margin-bottom: 1rem; } @@ -19,10 +18,15 @@ border-radius: inherit; &::after { - content: ""; + content: ''; position: absolute; inset: 0; - background: linear-gradient(to right, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.7) 50%, rgba(255, 255, 255, 0.95) 100%); + background: linear-gradient( + to right, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 0.7) 50%, + rgba(255, 255, 255, 0.95) 100% + ); border-radius: inherit; } } @@ -39,8 +43,8 @@ flex-grow: 1; } - -::ng-deep .hero-title, .hero-title { +::ng-deep .hero-title, +.hero-title { color: #666666; font-size: 1.5rem; margin-bottom: 1rem; @@ -49,7 +53,8 @@ } } -::ng-deep .hero-description, .hero-description { +::ng-deep .hero-description, +.hero-description { color: #666666; font-size: 1.1rem; line-height: 1.5; @@ -59,8 +64,8 @@ } .event-card-container { - bottom: -40px; /* Negative margin to pull it down and overlap */ - left: 0; + position: relative; + margin-top: -40px; z-index: 10; } @@ -70,12 +75,13 @@ } .event-title { - font-size: 1.4rem; + font-size: 25px; + font-weight: 700; letter-spacing: 0.5px; } .event-details { - font-size: 1.1rem; + font-size: 21px; } .event-icon { @@ -85,11 +91,55 @@ .event-info-text { color: #8a8a8a; - font-weight: 500; + font-weight: 400; } -.event-schedule-label { - display: none; +.event-schedule-toggle { + display: inline-flex; + justify-content: center; + align-items: center; + gap: 0.55rem; + margin-top: 1.75rem; + padding: 0; + border: 0; + background: transparent; + color: var(--tenant-primary, #009d4f); + font-size: 15px; + font-weight: 700; + + &:focus-visible { + outline: 2px solid currentColor; + outline-offset: 0.25rem; + } + + i { + font-size: 0.7rem; + } +} + +.event-schedules { + margin-top: 1.25rem; + + td { + width: 50%; + padding: 0.65rem 1rem; + border-radius: 0.5rem; + vertical-align: middle; + } +} + +.event-schedule-value { + display: inline-flex; + align-items: center; + gap: 0.75rem; + color: #8a8a8a; + font-size: 19px; + font-weight: 400; + + i { + width: 1rem; + color: #c9ccce; + } } @media (max-width: 767.98px) { @@ -175,9 +225,7 @@ } .event-card-container { - position: relative !important; - bottom: auto; - left: auto; + margin-top: 0; padding: 0 1.25rem 1.2rem; } @@ -193,14 +241,12 @@ .event-title { max-width: 16rem; margin: 0 auto 0.85rem !important; - font-size: 1rem; line-height: 1.1; letter-spacing: 0; } .event-details { gap: 0.45rem !important; - font-size: 0.7rem; line-height: 1.25; } @@ -213,22 +259,23 @@ font-size: 0.85rem; } - .event-info-text { - font-weight: 400; - } - - .event-schedule-label { - display: flex; - justify-content: center; - align-items: center; - gap: 0.55rem; + .event-schedule-toggle { margin-top: 1.9rem; - color: var(--tenant-primary, #009d4f); - font-size: 0.625rem; - font-weight: 700; } - .event-schedule-label i { + .event-schedule-toggle i { font-size: 0.65rem; } + + .event-schedules { + margin-top: 1rem; + + td { + padding: 0.55rem 0.4rem; + } + } + + .event-schedule-value { + gap: 0.4rem; + } } diff --git a/src/app/shared/components/hero-banner/hero-banner.component.spec.ts b/src/app/shared/components/hero-banner/hero-banner.component.spec.ts new file mode 100644 index 0000000..235e236 --- /dev/null +++ b/src/app/shared/components/hero-banner/hero-banner.component.spec.ts @@ -0,0 +1,50 @@ +import { TestBed } from '@angular/core/testing'; +import { HeroBannerComponent } from './hero-banner.component'; + +describe('HeroBannerComponent', () => { + it('expands and collapses the event schedules', async () => { + await TestBed.configureTestingModule({ imports: [HeroBannerComponent] }).compileComponents(); + + const fixture = TestBed.createComponent(HeroBannerComponent); + fixture.componentRef.setInput('eventConfig', { + title: 'Fiesta Nacional del Fútbol Infantil', + location: 'Sunchales, Santa Fe', + dates: [ + { + id: 1, + date: '2026-10-09', + start_time: '10:00:00', + end_time: '20:30:00', + }, + { + id: 2, + date: '2026-10-10', + start_time: '10:00:00', + end_time: '22:00:00', + }, + ], + }); + fixture.detectChanges(); + + const element = fixture.nativeElement as HTMLElement; + const toggle = element.querySelector('.event-schedule-toggle'); + + expect(toggle?.textContent).toContain('Ver horarios'); + expect(toggle?.getAttribute('aria-expanded')).toBe('false'); + expect(element.querySelector('.event-schedules')).toBeNull(); + + toggle?.click(); + fixture.detectChanges(); + + expect(toggle?.textContent).toContain('Ocultar horarios'); + expect(toggle?.getAttribute('aria-expanded')).toBe('true'); + expect(element.querySelectorAll('.event-schedules tbody tr')).toHaveLength(2); + expect(element.querySelector('.event-schedules')?.textContent).toContain('9 de Octubre 2026'); + expect(element.querySelector('.event-schedules')?.textContent).toContain('10:00 - 20:30'); + + toggle?.click(); + fixture.detectChanges(); + + expect(element.querySelector('.event-schedules')).toBeNull(); + }); +}); diff --git a/src/app/shared/components/hero-banner/hero-banner.component.ts b/src/app/shared/components/hero-banner/hero-banner.component.ts index 2a32e6b..e3385c6 100644 --- a/src/app/shared/components/hero-banner/hero-banner.component.ts +++ b/src/app/shared/components/hero-banner/hero-banner.component.ts @@ -16,6 +16,8 @@ export class HeroBannerComponent { @Input() heroConfig?: HeroConfig | null; @Input() eventConfig?: EventConfig | null; + protected schedulesExpanded = false; + get formattedDates(): string { if (this.eventConfig?.dates_text) { return this.eventConfig.dates_text; @@ -27,4 +29,28 @@ export class HeroBannerComponent { return this.eventConfig.dates.map(({ date }) => date).join(', '); } + + protected toggleSchedules(): void { + this.schedulesExpanded = !this.schedulesExpanded; + } + + protected formatScheduleDate(date: string): string { + const [year, month, day] = date.split('-').map(Number); + + if (!year || !month || !day) { + return date; + } + + const monthName = new Intl.DateTimeFormat('es-AR', { + month: 'long', + timeZone: 'UTC', + }).format(new Date(Date.UTC(year, month - 1, day))); + const capitalizedMonth = `${monthName.charAt(0).toUpperCase()}${monthName.slice(1)}`; + + return `${day} de ${capitalizedMonth} ${year}`; + } + + protected formatScheduleTime(time: string): string { + return time.match(/^\d{2}:\d{2}/)?.[0] ?? time; + } } diff --git a/src/styles.scss b/src/styles.scss index 75a4605..63dd8cc 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -1,13 +1,14 @@ -@use "./fonts"; +@use './fonts'; -@import "@fortawesome/fontawesome-free/css/all.min.css"; +@import '@fortawesome/fontawesome-free/css/all.min.css'; -@import "./theme"; +@import './theme'; -@import "../node_modules/bootstrap/scss/bootstrap"; +@import '../node_modules/bootstrap/scss/bootstrap'; -:root, app-root { - --bs-body-font-family: "Gotham", sans-serif; +:root, +app-root { + --bs-body-font-family: 'Gotham', sans-serif; --color-primary: var(--tenant-primary); --color-primary-rgb: var(--tenant-primary-rgb); --color-secondary: var(--tenant-secondary); @@ -57,3 +58,18 @@ label { color: var(--color-danger, #dc3545); } } + +.table-striped { + --bs-table-bg: transparent; + --bs-table-color: #8a8a8a; + --bs-table-color-state: #8a8a8a; + --bs-table-color-type: #8a8a8a; + --bs-table-striped-bg: transparent; + --bs-table-striped-color: #8a8a8a; + + color: #8a8a8a; + + > tbody > tr:nth-of-type(odd) { + background-color: rgba(221, 221, 221, 0.25); + } +} From 563e4634d1e729b74fedd50da6a55d5105cfb93f Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 11 Aug 2026 09:55:49 -0300 Subject: [PATCH 20/26] feat: update styles across components for improved layout and responsiveness --- .../store-footer/store-footer.component.scss | 25 +++++++++---------- .../store-header/store-header.component.scss | 18 +++++++++++-- .../store-home-page.component.scss | 7 ++++++ .../hero-banner/hero-banner.component.scss | 17 +++++++++---- ...uct-vertical-with-cart-card.component.scss | 11 +++++++- .../variant-selector.component.scss | 18 +++++++++++++ 6 files changed, 75 insertions(+), 21 deletions(-) diff --git a/src/app/core/layout/store-layout/store-footer/store-footer.component.scss b/src/app/core/layout/store-layout/store-footer/store-footer.component.scss index 6f0e595..9c76d5e 100644 --- a/src/app/core/layout/store-layout/store-footer/store-footer.component.scss +++ b/src/app/core/layout/store-layout/store-footer/store-footer.component.scss @@ -7,21 +7,21 @@ } .store-layout__brand-slot { - width: min(100%, 12rem); + width: min(100%, 230px); min-width: 10rem; } .store-layout__brand-logo-shell { position: relative; - width: min(100%, 12rem); - height: 2.75rem; + width: min(100%, 230px); + height: 65px; } .store-layout__brand-logo { display: block; width: 100%; height: 100%; - max-height: 2.75rem; + max-height: 65px; object-fit: contain; object-position: left center; } @@ -67,7 +67,7 @@ .store-layout__footer-link { width: fit-content; - color: rgba(255, 255, 255, 0.72); + color: #ffffff; text-decoration: none; &:hover, @@ -98,6 +98,7 @@ @media (max-width: 767.98px) { .store-layout__footer-content { padding: 1.45rem 2.5rem 1.8rem !important; + font-size: 13px; } .store-layout__footer-grid { @@ -117,7 +118,6 @@ order: 1; width: auto; padding-bottom: 1.65rem; - font-size: 0.72rem; line-height: 1.25; &:nth-of-type(even) { @@ -145,21 +145,21 @@ } .store-layout__brand-slot { - width: min(100%, 7.75rem); + width: min(100%, 150px); min-width: 0; } .store-layout__brand-logo { - max-height: 2.5rem; + max-height: 55px; } .store-layout__brand-logo-shell { - width: min(100%, 7.75rem); - height: 2.5rem; + width: min(100%, 150px); + height: 55px; } .store-layout__brand-text { - font-size: 1.45rem; + font-size: 13px; } .store-layout__social-column { @@ -183,11 +183,10 @@ .store-layout__contact-details { justify-items: center; gap: 0.4rem !important; - font-size: 0.7rem; } .store-layout__copyright { - font-size: 0.6rem; + font-size: 11px; line-height: 1.3; } } diff --git a/src/app/core/layout/store-layout/store-header/store-header.component.scss b/src/app/core/layout/store-layout/store-header/store-header.component.scss index d1008cf..086ee09 100644 --- a/src/app/core/layout/store-layout/store-header/store-header.component.scss +++ b/src/app/core/layout/store-layout/store-header/store-header.component.scss @@ -158,8 +158,21 @@ max-width: 150px; } + .store-layout__actions-column { + flex: 1 1 auto; + width: auto; + min-width: 0; + } + + ::ng-deep .store-layout__tickets-btn-host { + flex: 1 1 0; + width: auto; + min-width: 0; + max-width: 50vw; + } + .store-layout__mobile-separator { - border-color: #DDDDDD; + border-color: #dddddd; opacity: 1; margin: 0 -1rem; } @@ -180,8 +193,9 @@ } .store-layout__actions-column { - flex: 0 0 auto; + flex: 1 1 auto; width: auto; + min-width: 0; margin-left: 0 !important; padding-left: 0.25rem; gap: 0.25rem !important; diff --git a/src/app/features/store/pages/store-home-page/store-home-page.component.scss b/src/app/features/store/pages/store-home-page/store-home-page.component.scss index 3ee8682..a7310b9 100644 --- a/src/app/features/store/pages/store-home-page/store-home-page.component.scss +++ b/src/app/features/store/pages/store-home-page/store-home-page.component.scss @@ -30,6 +30,13 @@ app-store-section + app-store-section { margin-bottom: 0; } +@media (max-width: 767.98px) { + :host > app-hero-banner:first-child { + display: block; + margin-top: -3rem; + } +} + .store-home__alert-error { margin: 0; border-color: rgba(var(--tenant-danger-rgb, 220, 53, 69), 0.18); diff --git a/src/app/shared/components/hero-banner/hero-banner.component.scss b/src/app/shared/components/hero-banner/hero-banner.component.scss index a030b6a..90dc635 100644 --- a/src/app/shared/components/hero-banner/hero-banner.component.scss +++ b/src/app/shared/components/hero-banner/hero-banner.component.scss @@ -78,6 +78,7 @@ font-size: 25px; font-weight: 700; letter-spacing: 0.5px; + text-transform: uppercase; } .event-details { @@ -149,9 +150,12 @@ } .hero-banner { + width: 100vw; min-height: 0; + margin-inline: calc(50% - 50vw); overflow: hidden; background-color: #f5f5f5; + border-radius: 0 !important; } .hero-media { @@ -159,7 +163,7 @@ inset: auto; flex: 0 0 auto; width: 100%; - height: clamp(8.75rem, 44vw, 10.5rem); + height: clamp(8.75rem, 44vw, 211px); background-position: center center; background-size: 140% auto; border-radius: 0; @@ -226,27 +230,28 @@ .event-card-container { margin-top: 0; - padding: 0 1.25rem 1.2rem; + padding: 0 0 1.2rem; } .event-card { width: 100%; - max-width: 22rem; + max-width: none; min-height: 11.4rem; - padding: 1.75rem 1rem 0.8rem !important; + padding: 2.5rem 1.5rem !important; border-radius: 0 !important; box-shadow: 0 1px 5px rgba(0, 0, 0, 0.04) !important; } .event-title { - max-width: 16rem; margin: 0 auto 0.85rem !important; + font-size: 22px; line-height: 1.1; letter-spacing: 0; } .event-details { gap: 0.45rem !important; + font-size: 15px; line-height: 1.25; } @@ -261,6 +266,7 @@ .event-schedule-toggle { margin-top: 1.9rem; + font-size: 13px; } .event-schedule-toggle i { @@ -277,5 +283,6 @@ .event-schedule-value { gap: 0.4rem; + font-size: 14px; } } diff --git a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.scss b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.scss index 1f33e56..c8e8b10 100644 --- a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.scss +++ b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.scss @@ -80,7 +80,6 @@ &__variant-selectors ::ng-deep .variant-selector__select { flex: 1 1 0; - width: 0; min-width: 0; } @@ -114,6 +113,16 @@ @media (max-width: 767.98px) { .product-vertical-with-cart-card { border-color: var(--color-primary, var(--tenant-primary, #009933)); + + &__title { + color: var(--color-primary, var(--tenant-primary, #009933)); + font-size: 15px; + } + + &__description { + font-size: 13px; + font-weight: 400; + } } } diff --git a/src/app/shared/components/variant-selector/variant-selector.component.scss b/src/app/shared/components/variant-selector/variant-selector.component.scss index bf8049a..0c5d11f 100644 --- a/src/app/shared/components/variant-selector/variant-selector.component.scss +++ b/src/app/shared/components/variant-selector/variant-selector.component.scss @@ -38,3 +38,21 @@ font-size: 11px; } } + +@media (max-width: 767.98px) { + .variant-selector { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + width: 100%; + } + + .variant-selector__select { + width: 100%; + min-width: 0; + max-width: none; + + &:first-child:nth-last-child(odd) { + grid-column: 1 / -1; + } + } +} From 5d629e27b18ef1ba64bb3d2a66e4443fbd819170 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 11 Aug 2026 09:55:57 -0300 Subject: [PATCH 21/26] feat: add configurable display options for categories and search bar in store header --- .../store-header/store-header.component.html | 151 ++++++++++-------- .../store-header/store-header.component.ts | 2 + .../store-layout/store-layout.component.html | 12 +- .../store-layout.component.spec.ts | 23 +++ src/app/core/services/tenant.interface.ts | 2 + .../hero-banner/hero-banner.component.scss | 5 +- 6 files changed, 113 insertions(+), 82 deletions(-) diff --git a/src/app/core/layout/store-layout/store-header/store-header.component.html b/src/app/core/layout/store-layout/store-header/store-header.component.html index 54bc5ab..5e02710 100644 --- a/src/app/core/layout/store-layout/store-header/store-header.component.html +++ b/src/app/core/layout/store-layout/store-header/store-header.component.html @@ -19,46 +19,50 @@
-
-
-
- -
-
- - - @if (showSearchError()) { - - } + @if (displaySeachBar()) { +
+
-
-
-
-
-
- + @if (displayCategories()) { +
+
+
+ - + +
-
+ } diff --git a/src/app/core/layout/store-layout/store-header/store-header.component.ts b/src/app/core/layout/store-layout/store-header/store-header.component.ts index b189253..12cd58e 100644 --- a/src/app/core/layout/store-layout/store-header/store-header.component.ts +++ b/src/app/core/layout/store-layout/store-header/store-header.component.ts @@ -35,6 +35,8 @@ export class StoreHeaderComponent { readonly accountNavigationMenus = input([]); readonly ticketsMenu = input(null); readonly categories = input([]); + readonly displayCategories = input(true); + readonly displaySeachBar = input(true); readonly cartClick = output(); readonly ticketsClick = output(); readonly loginClick = output(); diff --git a/src/app/core/layout/store-layout/store-layout.component.html b/src/app/core/layout/store-layout/store-layout.component.html index 763beb4..e119c2d 100644 --- a/src/app/core/layout/store-layout/store-layout.component.html +++ b/src/app/core/layout/store-layout/store-layout.component.html @@ -7,6 +7,8 @@ [accountNavigationMenus]="accountNavigationMenus()" [ticketsMenu]="ticketsMenu() ?? null" [categories]="tenant()?.categories ?? []" + [displayCategories]="tenant()?.display_categories ?? true" + [displaySeachBar]="tenant()?.display_seach_bar ?? true" (cartClick)="isCartOpen.set(!isCartOpen())" (ticketsClick)="onTicketsClick()" (loginClick)="onLoginClick()" @@ -16,10 +18,7 @@ /> @if (isCartOpen()) { -
+
- Seguir comprando { expect(compiled.querySelector('app-store-footer .store-layout__footer')).not.toBeNull(); }); + it('hides the configured header elements when the tenant disables them', () => { + tenantState.set({ + ...tenant, + categories: [{ id: 1, nombre: 'Remeras', subcategories: [] }], + display_categories: false, + display_seach_bar: false, + }); + + const fixture = TestBed.createComponent(StoreLayoutComponent); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + + expect(compiled.querySelector('#store-search-input')).toBeNull(); + expect(compiled.querySelector('.store-layout__category-trigger')).toBeNull(); + + const header = fixture.debugElement.query(By.directive(StoreHeaderComponent)); + (header.componentInstance as any).toggleMobileMenu(); + fixture.detectChanges(); + + expect(compiled.querySelectorAll('.store-layout__mobile-dropdown-item')).toHaveLength(1); + }); + it('renders the tenant branding and action icons', () => { const fixture = TestBed.createComponent(StoreLayoutComponent); fixture.detectChanges(); diff --git a/src/app/core/services/tenant.interface.ts b/src/app/core/services/tenant.interface.ts index 5efbfda..52c93a6 100644 --- a/src/app/core/services/tenant.interface.ts +++ b/src/app/core/services/tenant.interface.ts @@ -114,6 +114,8 @@ export interface Tenant { search_product_layout?: 'row' | 'column_with_image' | 'column_with_cart'; search_group_layout?: 'paginated' | 'simple' | 'simple_vertical' | 'carousel'; search_items_per_page?: number; + display_categories?: boolean; + display_seach_bar?: boolean; social_media?: SocialMedia[]; menues?: Menu[]; categories: Category[]; diff --git a/src/app/shared/components/hero-banner/hero-banner.component.scss b/src/app/shared/components/hero-banner/hero-banner.component.scss index 90dc635..a664160 100644 --- a/src/app/shared/components/hero-banner/hero-banner.component.scss +++ b/src/app/shared/components/hero-banner/hero-banner.component.scss @@ -222,10 +222,7 @@ } .hero-action ::ng-deep .btn { - min-height: 1.875rem; - padding: 0.4rem 1.35rem; - border-radius: 0.25rem; - font-size: 0.625rem; + font-size: 11px; } .event-card-container { From e3599a48e41f3d582e836317d425bf800930b0c3 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 11 Aug 2026 10:14:39 -0300 Subject: [PATCH 22/26] feat: update layout and styles for product components and variant selectors --- .../store-layout/store-layout.component.html | 1 + .../cart-item/cart-item.component.scss | 4 +- .../product-list/product-list.component.scss | 2 + .../product-row-card.component.html | 36 +++++++------- .../product-row-card.component.scss | 49 +++++++++++++++++-- ...uct-vertical-with-cart-card.component.scss | 10 ---- .../variant-selector.component.scss | 36 +++++--------- 7 files changed, 79 insertions(+), 59 deletions(-) diff --git a/src/app/core/layout/store-layout/store-layout.component.html b/src/app/core/layout/store-layout/store-layout.component.html index e119c2d..15f2c51 100644 --- a/src/app/core/layout/store-layout/store-layout.component.html +++ b/src/app/core/layout/store-layout/store-layout.component.html @@ -1,5 +1,6 @@
+

@@ -13,28 +11,28 @@ }

- -
- -
+ +
+
+
+ + + +
+ {{ formattedPrice() }} +
+ +
Comprar
-
- - -
- - - -
Agregar al carrito
diff --git a/src/app/shared/components/product-row-card/product-row-card.component.scss b/src/app/shared/components/product-row-card/product-row-card.component.scss index 596a1e0..b4ddae3 100644 --- a/src/app/shared/components/product-row-card/product-row-card.component.scss +++ b/src/app/shared/components/product-row-card/product-row-card.component.scss @@ -4,9 +4,14 @@ } .product-row-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 2rem; width: 100%; - background-color: #f8f9fa; /* slightly light bg matching the screenshot if needed, but let's use white or #f8f9fa based on the border */ - border-color: #e9ecef !important; + padding: 2rem 0; + border-bottom: 1px solid #e3e3e3; + background: transparent; &__title { font-size: 16px; @@ -26,13 +31,38 @@ } &__price { + display: block; + align-self: flex-end; font-size: 20px; font-weight: 800; - color: var(--tenant-primary, #009933) !important; /* Green matching screenshot */ + color: var(--tenant-primary, #009933) !important; + white-space: nowrap; + } + + &__actions { + display: grid; + grid-template-columns: minmax(0, auto) 160px; + align-items: stretch; + gap: 0.75rem 2.5rem; + } + + &__summary, + &__buttons { + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.5rem; + } + + &__controls { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.5rem; } &__btn-wrapper { - min-width: 160px; /* To make both buttons equal width as in screenshot */ + min-width: 160px; app-button { display: block; @@ -46,3 +76,14 @@ } } } + +@media (max-width: 991.98px) { + .product-row-card { + align-items: stretch; + flex-direction: column; + + &__actions { + grid-template-columns: minmax(0, 1fr) 160px; + } + } +} diff --git a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.scss b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.scss index c8e8b10..16f362e 100644 --- a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.scss +++ b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.scss @@ -73,16 +73,6 @@ width: 100%; } - &__variant-selectors ::ng-deep .variant-selector { - flex-wrap: nowrap; - width: 100%; - } - - &__variant-selectors ::ng-deep .variant-selector__select { - flex: 1 1 0; - min-width: 0; - } - &__price { color: var(--tenant-primary, #009933); font-size: 22px; diff --git a/src/app/shared/components/variant-selector/variant-selector.component.scss b/src/app/shared/components/variant-selector/variant-selector.component.scss index 0c5d11f..821bec4 100644 --- a/src/app/shared/components/variant-selector/variant-selector.component.scss +++ b/src/app/shared/components/variant-selector/variant-selector.component.scss @@ -1,19 +1,22 @@ :host { display: block; + width: 100%; min-width: 0; } .variant-selector { - display: flex; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: center; gap: 8px; + width: 100%; min-width: 0; - flex-wrap: wrap; } .variant-selector__select { - width: auto; - min-width: 120px; + width: 100%; + min-width: 0; + max-width: none; height: 38px; color: #666666; border-color: #cccccc; @@ -24,35 +27,20 @@ border-color: var(--tenant-primary); box-shadow: 0 0 0 0.25rem rgba(0, 153, 51, 0.25); } + + &:first-child:nth-last-child(odd) { + grid-column: 1 / -1; + } } .variant-selector--compact { - justify-content: flex-end; gap: 4px; .variant-selector__select { min-width: 0; - max-width: 150px; + max-width: none; height: 28px; padding: 0.2rem 1.75rem 0.2rem 0.45rem; font-size: 11px; } } - -@media (max-width: 767.98px) { - .variant-selector { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - width: 100%; - } - - .variant-selector__select { - width: 100%; - min-width: 0; - max-width: none; - - &:first-child:nth-last-child(odd) { - grid-column: 1 / -1; - } - } -} From 5d760bfd80386effc520ee43b1a0dba3abd1eeea Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 11 Aug 2026 10:14:59 -0300 Subject: [PATCH 23/26] feat: improve modal styling and enhance padding management in modal host component --- .../modal-host/modal-host.component.spec.ts | 53 +++++++------------ .../modal-host/modal-host.component.ts | 27 ++++++---- .../modal-shell/modal-shell.component.scss | 3 +- 3 files changed, 39 insertions(+), 44 deletions(-) diff --git a/src/app/shared/components/modal-host/modal-host.component.spec.ts b/src/app/shared/components/modal-host/modal-host.component.spec.ts index fd5af67..4946a52 100644 --- a/src/app/shared/components/modal-host/modal-host.component.spec.ts +++ b/src/app/shared/components/modal-host/modal-host.component.spec.ts @@ -3,25 +3,10 @@ import { Component, inject } from '@angular/core'; import { DOCUMENT } from '@angular/common'; import { TestBed, getTestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { - BrowserTestingModule, - platformBrowserTesting -} from '@angular/platform-browser/testing'; -import { - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - vi -} from 'vitest'; +import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - MODAL_DATA, - ModalRef, - ModalService -} from '../../../core/services/modal.service'; +import { MODAL_DATA, ModalRef, ModalService } from '../../../core/services/modal.service'; import { ModalHostComponent } from './modal-host.component'; @Component({ @@ -31,7 +16,7 @@ import { ModalHostComponent } from './modal-host.component'; {{ data?.title }}
- ` + `, }) class ModalContentTestComponent { readonly data = inject<{ title: string } | null>(MODAL_DATA); @@ -48,10 +33,7 @@ describe('ModalHostComponent', () => { beforeAll(() => { try { - getTestBed().initTestEnvironment( - BrowserTestingModule, - platformBrowserTesting() - ); + getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); } catch { // Test environment may already be initialized by another setup entrypoint. } @@ -59,7 +41,7 @@ describe('ModalHostComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [ModalHostComponent] + imports: [ModalHostComponent], }).compileComponents(); service = TestBed.inject(ModalService); @@ -68,6 +50,7 @@ describe('ModalHostComponent', () => { afterEach(() => { doc.body.style.overflow = ''; + doc.body.style.paddingRight = ''; TestBed.resetTestingModule(); }); @@ -84,7 +67,7 @@ describe('ModalHostComponent', () => { service.open(ModalContentTestComponent, { title: 'Editar producto', - data: { title: 'Contenido del modal' } + data: { title: 'Contenido del modal' }, }); fixture.detectChanges(); @@ -99,27 +82,30 @@ describe('ModalHostComponent', () => { it('closes with a result from the child component', () => { const fixture = TestBed.createComponent(ModalHostComponent); const ref = service.open(ModalContentTestComponent, { - data: { title: 'Cerrar' } + data: { title: 'Cerrar' }, }); const closedSpy = vi.fn(); ref.afterClosed$.subscribe(closedSpy); fixture.detectChanges(); - const closeButton = fixture.nativeElement.querySelector('.modal-test-close') as HTMLButtonElement; + const closeButton = fixture.nativeElement.querySelector( + '.modal-test-close', + ) as HTMLButtonElement; closeButton.click(); fixture.detectChanges(); expect(closedSpy).toHaveBeenCalledWith('accepted'); expect(service.activeModal()).toBeNull(); expect(doc.body.style.overflow).toBe(''); + expect(doc.body.style.paddingRight).toBe(''); }); it('closes on backdrop click when enabled', () => { const fixture = TestBed.createComponent(ModalHostComponent); const ref = service.open(ModalContentTestComponent, { data: { title: 'Backdrop' }, - closeOnBackdrop: true + closeOnBackdrop: true, }); fixture.detectChanges(); @@ -136,7 +122,7 @@ describe('ModalHostComponent', () => { const fixture = TestBed.createComponent(ModalHostComponent); const ref = service.open(ModalContentTestComponent, { data: { title: 'Persistente' }, - closeOnBackdrop: false + closeOnBackdrop: false, }); fixture.detectChanges(); @@ -152,7 +138,7 @@ describe('ModalHostComponent', () => { const fixture = TestBed.createComponent(ModalHostComponent); const ref = service.open(ModalContentTestComponent, { data: { title: 'Escape' }, - closeOnEscape: true + closeOnEscape: true, }); fixture.detectChanges(); @@ -167,7 +153,7 @@ describe('ModalHostComponent', () => { const fixture = TestBed.createComponent(ModalHostComponent); const ref = service.open(ModalContentTestComponent, { data: { title: 'No Escape' }, - closeOnEscape: false + closeOnEscape: false, }); fixture.detectChanges(); @@ -181,12 +167,13 @@ describe('ModalHostComponent', () => { const fixture = TestBed.createComponent(ModalHostComponent); const ref = service.open(ModalContentTestComponent, { title: 'Con cierre', - data: { title: 'Boton' } + data: { title: 'Boton' }, }); fixture.detectChanges(); - const closeButton = fixture.debugElement.query(By.css('.btn-close')).nativeElement as HTMLButtonElement; + const closeButton = fixture.debugElement.query(By.css('.btn-close')) + .nativeElement as HTMLButtonElement; closeButton.click(); fixture.detectChanges(); diff --git a/src/app/shared/components/modal-host/modal-host.component.ts b/src/app/shared/components/modal-host/modal-host.component.ts index 7bc840e..a85e7e9 100644 --- a/src/app/shared/components/modal-host/modal-host.component.ts +++ b/src/app/shared/components/modal-host/modal-host.component.ts @@ -7,21 +7,17 @@ import { computed, effect, inject, - viewChild + viewChild, } from '@angular/core'; -import { - MODAL_DATA, - ModalRef, - ModalService -} from '../../../core/services/modal.service'; +import { MODAL_DATA, ModalRef, ModalService } from '../../../core/services/modal.service'; import { ModalShellComponent } from '../modal-shell/modal-shell.component'; @Component({ selector: 'app-modal-host', imports: [NgComponentOutlet, ModalShellComponent], templateUrl: './modal-host.component.html', - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, }) export class ModalHostComponent { private readonly modalService = inject(ModalService); @@ -42,9 +38,9 @@ export class ModalHostComponent { return Injector.create({ providers: [ { provide: ModalRef, useValue: modal.ref }, - { provide: MODAL_DATA, useValue: modal.config.data ?? null } + { provide: MODAL_DATA, useValue: modal.config.data ?? null }, ], - parent: this.injector + parent: this.injector, }); }); @@ -58,6 +54,18 @@ export class ModalHostComponent { const body = this.document.body; const previousOverflow = body.style.overflow; + const previousPaddingRight = body.style.paddingRight; + const view = this.document.defaultView; + const scrollbarWidth = view + ? Math.max(0, view.innerWidth - this.document.documentElement.clientWidth) + : 0; + + if (scrollbarWidth > 0 && view) { + const currentPaddingRight = + Number.parseFloat(view.getComputedStyle(body).paddingRight) || 0; + body.style.paddingRight = `${currentPaddingRight + scrollbarWidth}px`; + } + body.style.overflow = 'hidden'; const onKeyDown = (event: KeyboardEvent) => { @@ -75,6 +83,7 @@ export class ModalHostComponent { onCleanup(() => { this.document.removeEventListener('keydown', onKeyDown); body.style.overflow = previousOverflow; + body.style.paddingRight = previousPaddingRight; }); }); } diff --git a/src/app/shared/components/modal-shell/modal-shell.component.scss b/src/app/shared/components/modal-shell/modal-shell.component.scss index d5f80b8..d016d61 100644 --- a/src/app/shared/components/modal-shell/modal-shell.component.scss +++ b/src/app/shared/components/modal-shell/modal-shell.component.scss @@ -10,8 +10,7 @@ align-items: center; justify-content: center; padding: 1rem; - background: rgba(16, 18, 22, 0.48); - backdrop-filter: blur(2px); + background: rgba(16, 18, 22, 0.56); } .modal-shell__dialog { From ede2173a5a30d497c35dd1869c6287ddcfd06ff5 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 11 Aug 2026 10:38:42 -0300 Subject: [PATCH 24/26] feat: update styles for small quantity selector to improve appearance and usability --- .../product-row-card.component.scss | 48 +++++++++++++++++++ .../quantity-selector.component.scss | 14 +++--- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/app/shared/components/product-row-card/product-row-card.component.scss b/src/app/shared/components/product-row-card/product-row-card.component.scss index b4ddae3..9451a69 100644 --- a/src/app/shared/components/product-row-card/product-row-card.component.scss +++ b/src/app/shared/components/product-row-card/product-row-card.component.scss @@ -77,6 +77,54 @@ } } +@media (min-width: 992px) { + .product-row-card { + &__info { + min-width: 0; + } + + &__actions, + &__summary, + &__buttons { + display: flex; + flex-direction: row; + align-items: center; + } + + &__actions { + flex: 0 0 auto; + gap: 0.75rem; + } + + &__summary, + &__buttons { + gap: 0.5rem; + } + + &__price { + align-self: center; + } + + &__selectors { + width: auto; + + ::ng-deep .variant-selector { + display: flex; + width: auto; + } + + ::ng-deep .variant-selector__select { + width: auto; + min-width: 108px; + } + } + + &__btn-wrapper { + min-width: 140px; + } + } +} + @media (max-width: 991.98px) { .product-row-card { align-items: stretch; diff --git a/src/app/shared/components/quantity-selector/quantity-selector.component.scss b/src/app/shared/components/quantity-selector/quantity-selector.component.scss index bb6199e..241af5d 100644 --- a/src/app/shared/components/quantity-selector/quantity-selector.component.scss +++ b/src/app/shared/components/quantity-selector/quantity-selector.component.scss @@ -39,15 +39,15 @@ } &--small { - width: 46px; - height: 21px; - min-height: 21px; - border: 1px solid #cfcfcf; + width: 62px; + height: 28px; + min-height: 28px; + border-color: #cccccc; background-color: #ffffff; .quantity-selector__button { - width: 15px; - font-size: 10px; + width: 18px; + font-size: 11px; background-color: #ffffff; color: #666666; @@ -57,7 +57,7 @@ } .quantity-selector__value { - font-size: 10px; + font-size: 11px; color: #666666; } } From 7b0c9831f1e74abc62c211b22e755b539b50a478 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 11 Aug 2026 13:26:05 -0300 Subject: [PATCH 25/26] feat: add validity_time_id to ProductAttributeOption and enhance TicketResponse with validity_times and validity_groups --- src/app/core/services/catalog/catalog.interface.ts | 1 + .../pages/tickets-page/ticket.service.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/src/app/core/services/catalog/catalog.interface.ts b/src/app/core/services/catalog/catalog.interface.ts index 913546c..4bf3e55 100644 --- a/src/app/core/services/catalog/catalog.interface.ts +++ b/src/app/core/services/catalog/catalog.interface.ts @@ -18,6 +18,7 @@ export interface Product { export interface ProductAttributeOption { id: number; + validity_time_id?: number | null; value: string; label: string; sort_order: number; diff --git a/src/app/features/store/pages/account-page/pages/tickets-page/ticket.service.ts b/src/app/features/store/pages/account-page/pages/tickets-page/ticket.service.ts index 3c97e50..b47c8cb 100644 --- a/src/app/features/store/pages/account-page/pages/tickets-page/ticket.service.ts +++ b/src/app/features/store/pages/account-page/pages/tickets-page/ticket.service.ts @@ -3,6 +3,16 @@ import { firstValueFrom } from 'rxjs'; import { BaseApiService } from '../../../../../../core/services/base-api.service'; import { TenantService } from '../../../../../../core/services/tenant.service'; +import { ValidityTime } from '../../../../../../core/services/validity-time.interface'; + +export interface TicketValidityGroupResponse { + id: number; + validity_times: ValidityTime[]; + starts_at: string | null; + expires_at: string | null; + is_valid: boolean; + is_expired: boolean; +} export interface TicketResponse { id: number; @@ -12,6 +22,8 @@ export interface TicketResponse { description: string | null; source_catalog_item_id: number | null; source_variant_id: number | null; + validity_times?: ValidityTime[]; + validity_groups?: TicketValidityGroupResponse[]; starts_at: string | null; expires_at: string | null; used_at: string | null; From 369cfb0a40e6b5d00eb2afe3a3640593eefc60aa Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 11 Aug 2026 16:50:47 -0300 Subject: [PATCH 26/26] feat: update header background color to transparent and modify API URL for QA environment --- .../store-layout/store-header/store-header.component.scss | 2 +- src/environments/environment.homo.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/core/layout/store-layout/store-header/store-header.component.scss b/src/app/core/layout/store-layout/store-header/store-header.component.scss index 086ee09..7aa474e 100644 --- a/src/app/core/layout/store-layout/store-header/store-header.component.scss +++ b/src/app/core/layout/store-layout/store-header/store-header.component.scss @@ -1,6 +1,6 @@ .store-layout__header { color: #ffffff; - background-color: var(--tenant-header-bg); + background-color: transparent; box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.08); } diff --git a/src/environments/environment.homo.ts b/src/environments/environment.homo.ts index abab87d..6b23224 100644 --- a/src/environments/environment.homo.ts +++ b/src/environments/environment.homo.ts @@ -1,6 +1,6 @@ export const environment = { production: false, nombre:"Homologación - activo", - url:"https://backend.shopit.com.ar/api/", + url:"https://backend.qa.shopit.com.ar/api/", urlDescarga:"url/" };