From b5fae81795be9d5049ad447019ef9b721f1e3e14 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 14 Sep 2026 14:50:11 -0300 Subject: [PATCH] feat(storefront): show event date notices --- src/app/app.spec.ts | 98 ++++++++++++++----- src/app/app.ts | 58 +++++------ .../event-date-notice.service.spec.ts | 85 ++++++++++++++++ .../services/event-date-notice.service.ts | 32 ++++++ src/app/core/services/tenant.interface.ts | 24 +++++ .../event-date-notice-modal.component.html | 17 ++++ .../event-date-notice-modal.component.scss | 33 +++++++ .../event-date-notice-modal.component.spec.ts | 57 +++++++++++ .../event-date-notice-modal.component.ts | 21 ++++ 9 files changed, 367 insertions(+), 58 deletions(-) create mode 100644 src/app/core/services/event-date-notice.service.spec.ts create mode 100644 src/app/core/services/event-date-notice.service.ts create mode 100644 src/app/shared/components/event-date-notice-modal/event-date-notice-modal.component.html create mode 100644 src/app/shared/components/event-date-notice-modal/event-date-notice-modal.component.scss create mode 100644 src/app/shared/components/event-date-notice-modal/event-date-notice-modal.component.spec.ts create mode 100644 src/app/shared/components/event-date-notice-modal/event-date-notice-modal.component.ts diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts index a446d61..47518b3 100644 --- a/src/app/app.spec.ts +++ b/src/app/app.spec.ts @@ -1,14 +1,12 @@ import '@angular/compiler'; import { signal } from '@angular/core'; import { TestBed, getTestBed } from '@angular/core/testing'; -import { - BrowserTestingModule, - platformBrowserTesting -} from '@angular/platform-browser/testing'; +import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; import { provideRouter } from '@angular/router'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { App } from './app'; +import { EventDateNoticeService } from './core/services/event-date-notice.service'; import { Tenant } from './core/services/tenant.interface'; import { TenantService } from './core/services/tenant.service'; @@ -27,7 +25,7 @@ const tenant: Tenant = { footer_bg_color: '#313131', header_logo: 'https://example.com/header.png', footer_logo: 'https://example.com/footer.png', - categories: [] + categories: [], }; function createTenantServiceStub(status: 'ready' | 'not-found', currentTenant: Tenant | null) { @@ -38,17 +36,14 @@ function createTenantServiceStub(status: 'ready' | 'not-found', currentTenant: T tenant: tenantState.asReadonly(), status: statusState.asReadonly(), getTenant: () => tenantState(), - bootstrap: vi.fn().mockResolvedValue(undefined) + bootstrap: vi.fn().mockResolvedValue(undefined), }; } describe('App', () => { beforeAll(() => { try { - getTestBed().initTestEnvironment( - BrowserTestingModule, - platformBrowserTesting() - ); + getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); } catch { // Test environment may already be initialized by another setup entrypoint. } @@ -65,9 +60,9 @@ describe('App', () => { provideRouter([]), { provide: TenantService, - useValue: createTenantServiceStub('ready', tenant) - } - ] + useValue: createTenantServiceStub('ready', tenant), + }, + ], }).compileComponents(); const fixture = TestBed.createComponent(App); @@ -75,26 +70,27 @@ describe('App', () => { expect(fixture.componentInstance).toBeTruthy(); expect(fixture.nativeElement.style.getPropertyValue('--tenant-primary')).toBe( - tenant.primary_color + tenant.primary_color, ); expect(fixture.nativeElement.style.getPropertyValue('--tenant-secondary')).toBe( - tenant.secondary_color + tenant.secondary_color, ); expect(fixture.nativeElement.style.getPropertyValue('--tenant-danger')).toBe( - tenant.danger_color + tenant.danger_color, ); expect(fixture.nativeElement.style.getPropertyValue('--tenant-primary-rgb')).toBe( - '99, 118, 243' + '99, 118, 243', ); expect(fixture.nativeElement.style.getPropertyValue('--tenant-secondary-rgb')).toBe( - '160, 160, 160' + '160, 160, 160', ); expect(fixture.nativeElement.style.getPropertyValue('--tenant-danger-rgb')).toBe( - '255, 136, 136' + '255, 136, 136', ); expect(document.title).toBe(tenant.site_title); - expect(document.head.querySelector('link[rel~="icon"]')?.getAttribute('href')) - .toBe(tenant.favicon); + expect( + document.head.querySelector('link[rel~="icon"]')?.getAttribute('href'), + ).toBe(tenant.favicon); }); it('renders the tenant not found screen when the tenant is missing', async () => { @@ -104,17 +100,65 @@ describe('App', () => { provideRouter([]), { provide: TenantService, - useValue: createTenantServiceStub('not-found', null) - } - ] + useValue: createTenantServiceStub('not-found', null), + }, + ], }).compileComponents(); const fixture = TestBed.createComponent(App); fixture.detectChanges(); - expect(fixture.nativeElement.textContent).toContain('No encontramos una tienda para este dominio'); + expect(fixture.nativeElement.textContent).toContain( + 'No encontramos una tienda para este dominio', + ); expect(document.title).toBe('ShopitFront'); - expect(document.head.querySelector('link[rel~="icon"]')?.getAttribute('href')) - .toBe("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E"); + expect( + document.head.querySelector('link[rel~="icon"]')?.getAttribute('href'), + ).toBe("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E"); + }); + + it('shows the date notices returned by the tenant bootstrap once', async () => { + const show = vi.fn(); + const dateNotices = [ + { + type: 'suspended' as const, + title: 'FECHA CANCELADA!', + message: [ + { text: 'La fecha del ', bold: false }, + { text: '09 de Octubre de 2026', bold: true }, + { text: ' ha sido cancelada.', bold: false }, + ], + }, + ]; + + await TestBed.configureTestingModule({ + imports: [App], + providers: [ + provideRouter([]), + { + provide: TenantService, + useValue: createTenantServiceStub('ready', { + ...tenant, + event: { + title: 'Festival', + location: 'Predio', + dates: [], + date_notices: dateNotices, + }, + }), + }, + { + provide: EventDateNoticeService, + useValue: { show }, + }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + fixture.detectChanges(); + + expect(show).toHaveBeenCalledOnce(); + expect(show).toHaveBeenCalledWith(dateNotices); }); }); diff --git a/src/app/app.ts b/src/app/app.ts index e69b2e4..b332a32 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,8 +1,9 @@ -import { DOCUMENT } from '@angular/common'; -import { Component, computed, effect, inject } from '@angular/core'; +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; +import { Component, PLATFORM_ID, computed, effect, inject } from '@angular/core'; import { Title } from '@angular/platform-browser'; import { RouterOutlet } from '@angular/router'; +import { EventDateNoticeService } from './core/services/event-date-notice.service'; import { TenantService } from './core/services/tenant.service'; import { DEFAULT_TENANT_BRANDING } from './core/services/tenant-ssr-cache.store'; import { GlobalLoadingComponent } from './shared/components/global-loading/global-loading.component'; @@ -13,7 +14,9 @@ const EMPTY_FAVICON = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/s function hexToRgb(hex: string): string { const cleanHex = hex.replace('#', '').trim(); - let r = 0, g = 0, b = 0; + let r = 0, + g = 0, + b = 0; if (cleanHex.length === 3) { r = parseInt(cleanHex[0] + cleanHex[0], 16); g = parseInt(cleanHex[1] + cleanHex[1], 16); @@ -28,12 +31,7 @@ function hexToRgb(hex: string): string { @Component({ selector: 'app-root', - imports: [ - RouterOutlet, - ToastContainerComponent, - ModalHostComponent, - GlobalLoadingComponent - ], + imports: [RouterOutlet, ToastContainerComponent, ModalHostComponent, GlobalLoadingComponent], templateUrl: './app.html', styleUrl: './app.scss', host: { @@ -56,13 +54,16 @@ function hexToRgb(hex: string): string { '[style.--tenant-header-bg]': 'tenantHeaderBgColor()', '[style.--tenant-footer-bg]': 'tenantFooterBgColor()', '[style.--color-header-bg]': 'tenantHeaderBgColor()', - '[style.--color-footer-bg]': 'tenantFooterBgColor()' - } + '[style.--color-footer-bg]': 'tenantFooterBgColor()', + }, }) export class App { private readonly tenantService = inject(TenantService); + private readonly eventDateNoticeService = inject(EventDateNoticeService); private readonly document = inject(DOCUMENT); private readonly title = inject(Title); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); + private notifiedTenantCode: string | null = null; constructor() { effect(() => { @@ -81,43 +82,38 @@ export class App { } favicon.setAttribute('href', faviconHref); + + if (this.isBrowser && tenant && this.notifiedTenantCode !== tenant.codigo) { + this.notifiedTenantCode = tenant.codigo; + this.eventDateNoticeService.show(tenant.event?.date_notices ?? []); + } }); } protected readonly status = this.tenantService.status; protected readonly tenantPrimaryColor = computed( - () => this.tenantService.tenant()?.primary_color ?? DEFAULT_TENANT_BRANDING.primaryColor + () => this.tenantService.tenant()?.primary_color ?? DEFAULT_TENANT_BRANDING.primaryColor, ); protected readonly tenantSecondaryColor = computed( - () => this.tenantService.tenant()?.secondary_color ?? DEFAULT_TENANT_BRANDING.secondaryColor + () => this.tenantService.tenant()?.secondary_color ?? DEFAULT_TENANT_BRANDING.secondaryColor, ); protected readonly tenantDangerColor = computed( - () => this.tenantService.tenant()?.danger_color ?? DEFAULT_TENANT_BRANDING.dangerColor + () => this.tenantService.tenant()?.danger_color ?? DEFAULT_TENANT_BRANDING.dangerColor, ); protected readonly tenantSuccessColor = computed( - () => this.tenantService.tenant()?.success_color ?? DEFAULT_TENANT_BRANDING.successColor - ); - protected readonly tenantPrimaryColorRgb = computed(() => - hexToRgb(this.tenantPrimaryColor()) + () => this.tenantService.tenant()?.success_color ?? DEFAULT_TENANT_BRANDING.successColor, ); + protected readonly tenantPrimaryColorRgb = computed(() => hexToRgb(this.tenantPrimaryColor())); protected readonly tenantSecondaryColorRgb = computed(() => - hexToRgb(this.tenantSecondaryColor()) - ); - protected readonly tenantDangerColorRgb = computed(() => - hexToRgb(this.tenantDangerColor()) - ); - protected readonly tenantSuccessColorRgb = computed(() => - hexToRgb(this.tenantSuccessColor()) + hexToRgb(this.tenantSecondaryColor()), ); + protected readonly tenantDangerColorRgb = computed(() => hexToRgb(this.tenantDangerColor())); + protected readonly tenantSuccessColorRgb = computed(() => hexToRgb(this.tenantSuccessColor())); protected readonly tenantHeaderBgColor = computed( - () => - this.tenantService.tenant()?.header_bg_color ?? - DEFAULT_TENANT_BRANDING.headerBgColor + () => this.tenantService.tenant()?.header_bg_color ?? DEFAULT_TENANT_BRANDING.headerBgColor, ); protected readonly tenantFooterBgColor = computed( - () => - this.tenantService.tenant()?.footer_bg_color ?? - DEFAULT_TENANT_BRANDING.footerBgColor + () => this.tenantService.tenant()?.footer_bg_color ?? DEFAULT_TENANT_BRANDING.footerBgColor, ); } diff --git a/src/app/core/services/event-date-notice.service.spec.ts b/src/app/core/services/event-date-notice.service.spec.ts new file mode 100644 index 0000000..7e86d56 --- /dev/null +++ b/src/app/core/services/event-date-notice.service.spec.ts @@ -0,0 +1,85 @@ +import '@angular/compiler'; +import { TestBed } from '@angular/core/testing'; +import { getTestBed } from '@angular/core/testing'; +import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; +import { Subject } from 'rxjs'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { EventDateNoticeModalComponent } from '../../shared/components/event-date-notice-modal/event-date-notice-modal.component'; +import { EventDateNoticeService } from './event-date-notice.service'; +import { ModalService } from './modal.service'; +import { TenantEventDateNotice } from './tenant.interface'; + +describe('EventDateNoticeService', () => { + let service: EventDateNoticeService; + let open: ReturnType; + + beforeAll(() => { + try { + getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); + } catch { + // Test environment may already be initialized by another setup entrypoint. + } + }); + + beforeEach(() => { + open = vi.fn(); + TestBed.configureTestingModule({ + providers: [ + EventDateNoticeService, + { + provide: ModalService, + useValue: { open }, + }, + ], + }); + service = TestBed.inject(EventDateNoticeService); + }); + + afterEach(() => { + TestBed.resetTestingModule(); + }); + + it('shows backend notices sequentially', () => { + const firstClosed = new Subject(); + const secondClosed = new Subject(); + const notices: TenantEventDateNotice[] = [ + { + type: 'suspended', + title: 'FECHA CANCELADA!', + message: [{ text: 'La fecha fue cancelada.', bold: false }], + }, + { + type: 'rescheduled', + title: 'FECHA REPROGRAMADA!', + message: [{ text: 'La fecha fue reprogramada.', bold: false }], + }, + ]; + + open + .mockReturnValueOnce({ afterClosed$: firstClosed.asObservable() }) + .mockReturnValueOnce({ afterClosed$: secondClosed.asObservable() }); + + service.show(notices); + + expect(open).toHaveBeenCalledTimes(1); + expect(open).toHaveBeenNthCalledWith(1, EventDateNoticeModalComponent, { + size: 'sm', + data: notices[0], + }); + + firstClosed.next(undefined); + + expect(open).toHaveBeenCalledTimes(2); + expect(open).toHaveBeenNthCalledWith(2, EventDateNoticeModalComponent, { + size: 'sm', + data: notices[1], + }); + }); + + it('does not open a modal without notices', () => { + service.show([]); + + expect(open).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/core/services/event-date-notice.service.ts b/src/app/core/services/event-date-notice.service.ts new file mode 100644 index 0000000..d6b8b66 --- /dev/null +++ b/src/app/core/services/event-date-notice.service.ts @@ -0,0 +1,32 @@ +import { Injectable, inject } from '@angular/core'; + +import { EventDateNoticeModalComponent } from '../../shared/components/event-date-notice-modal/event-date-notice-modal.component'; +import { ModalService } from './modal.service'; +import { TenantEventDateNotice } from './tenant.interface'; + +@Injectable({ providedIn: 'root' }) +export class EventDateNoticeService { + private readonly modalService = inject(ModalService); + + show(notices: readonly TenantEventDateNotice[]): void { + this.showNext(notices, 0); + } + + private showNext(notices: readonly TenantEventDateNotice[], index: number): void { + const notice = notices[index]; + + if (!notice) { + return; + } + + this.modalService + .open( + EventDateNoticeModalComponent, + { + size: 'sm', + data: notice, + }, + ) + .afterClosed$.subscribe(() => this.showNext(notices, index + 1)); + } +} diff --git a/src/app/core/services/tenant.interface.ts b/src/app/core/services/tenant.interface.ts index 9a29eaa..8dbdfd6 100644 --- a/src/app/core/services/tenant.interface.ts +++ b/src/app/core/services/tenant.interface.ts @@ -69,10 +69,34 @@ export interface ActiveEventDate { time_end: string; } +export type TenantEventDateChangeType = 'rescheduled' | 'suspended'; + +export interface TenantEventDateChange { + type: TenantEventDateChangeType; + source_event_date_id: number | null; + destination_event_date_id: number | null; + previous_date: string; + new_date: string | null; + occurred_at: string; +} + +export interface TenantEventDateNoticeMessagePart { + text: string; + bold: boolean; +} + +export interface TenantEventDateNotice { + type: TenantEventDateChangeType; + title: string; + message: TenantEventDateNoticeMessagePart[]; +} + export interface TenantEvent { title: string; location: string; dates: ActiveEventDate[]; + date_changes?: TenantEventDateChange[]; + date_notices?: TenantEventDateNotice[]; } export interface WebsiteExtras { diff --git a/src/app/shared/components/event-date-notice-modal/event-date-notice-modal.component.html b/src/app/shared/components/event-date-notice-modal/event-date-notice-modal.component.html new file mode 100644 index 0000000..470e4d5 --- /dev/null +++ b/src/app/shared/components/event-date-notice-modal/event-date-notice-modal.component.html @@ -0,0 +1,17 @@ +
+

{{ data.title }}

+ +

+ @for (part of data.message; track $index) { + @if (part.bold) { + {{ part.text }} + } @else { + {{ part.text }} + } + } +

+ +
+ Cerrar +
+
diff --git a/src/app/shared/components/event-date-notice-modal/event-date-notice-modal.component.scss b/src/app/shared/components/event-date-notice-modal/event-date-notice-modal.component.scss new file mode 100644 index 0000000..cb00217 --- /dev/null +++ b/src/app/shared/components/event-date-notice-modal/event-date-notice-modal.component.scss @@ -0,0 +1,33 @@ +.event-date-notice { + display: grid; + gap: 20px; + justify-items: center; + text-align: center; +} + +.event-date-notice__title, +.event-date-notice__description { + margin: 0; +} + +.event-date-notice__title { + color: var(--color-danger, #dc3545); + font-size: 16px; + font-weight: 700; + line-height: 1.25; +} + +.event-date-notice__description { + color: #666666; + font-size: 15px; + font-weight: 400; + line-height: 1.4; +} + +.event-date-notice__description strong { + font-weight: 700; +} + +.event-date-notice__actions { + width: 100%; +} diff --git a/src/app/shared/components/event-date-notice-modal/event-date-notice-modal.component.spec.ts b/src/app/shared/components/event-date-notice-modal/event-date-notice-modal.component.spec.ts new file mode 100644 index 0000000..252f7dc --- /dev/null +++ b/src/app/shared/components/event-date-notice-modal/event-date-notice-modal.component.spec.ts @@ -0,0 +1,57 @@ +import '@angular/compiler'; +import { TestBed, getTestBed } from '@angular/core/testing'; +import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { MODAL_DATA, ModalRef } from '../../../core/services/modal.service'; +import { TenantEventDateNotice } from '../../../core/services/tenant.interface'; +import { EventDateNoticeModalComponent } from './event-date-notice-modal.component'; + +describe('EventDateNoticeModalComponent', () => { + beforeAll(() => { + try { + getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); + } catch { + // Test environment may already be initialized by another setup entrypoint. + } + }); + + afterEach(() => { + TestBed.resetTestingModule(); + }); + + it('renders the precalculated message and emphasizes its marked parts', async () => { + const close = vi.fn(); + const data: TenantEventDateNotice = { + type: 'rescheduled', + title: 'FECHAS REPROGRAMADAS!', + message: [ + { text: 'Las fechas del ', bold: false }, + { text: '09 y 10 de Octubre de 2026', bold: true }, + { text: ' han sido reprogramadas, ', bold: false }, + { text: 'respectivamente', bold: true }, + { text: '.', bold: false }, + ], + }; + + await TestBed.configureTestingModule({ + imports: [EventDateNoticeModalComponent], + providers: [ + { provide: MODAL_DATA, useValue: data }, + { provide: ModalRef, useValue: { close } }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(EventDateNoticeModalComponent); + fixture.detectChanges(); + const element = fixture.nativeElement as HTMLElement; + + expect(element.querySelector('h2')?.textContent).toContain(data.title); + expect( + [...element.querySelectorAll('strong')].map((strong) => strong.textContent?.trim()), + ).toEqual(['09 y 10 de Octubre de 2026', 'respectivamente']); + + element.querySelector('button')?.click(); + expect(close).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/shared/components/event-date-notice-modal/event-date-notice-modal.component.ts b/src/app/shared/components/event-date-notice-modal/event-date-notice-modal.component.ts new file mode 100644 index 0000000..caf6f87 --- /dev/null +++ b/src/app/shared/components/event-date-notice-modal/event-date-notice-modal.component.ts @@ -0,0 +1,21 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; + +import { MODAL_DATA, ModalRef } from '../../../core/services/modal.service'; +import { TenantEventDateNotice } from '../../../core/services/tenant.interface'; +import { ButtonComponent } from '../button/button.component'; + +@Component({ + selector: 'app-event-date-notice-modal', + imports: [ButtonComponent], + templateUrl: './event-date-notice-modal.component.html', + styleUrl: './event-date-notice-modal.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EventDateNoticeModalComponent { + protected readonly data = inject(MODAL_DATA); + private readonly modalRef = inject>(ModalRef); + + protected close(): void { + this.modalRef.close(); + } +}