6 Commits

13 changed files with 396 additions and 64 deletions

View File

@@ -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<HTMLLinkElement>('link[rel~="icon"]')?.getAttribute('href'))
.toBe(tenant.favicon);
expect(
document.head.querySelector<HTMLLinkElement>('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<HTMLLinkElement>('link[rel~="icon"]')?.getAttribute('href'))
.toBe("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E");
expect(
document.head.querySelector<HTMLLinkElement>('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);
});
});

View File

@@ -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,
);
}

View File

@@ -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<typeof vi.fn>;
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<void | undefined>();
const secondClosed = new Subject<void | undefined>();
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();
});
});

View File

@@ -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, void, TenantEventDateNotice>(
EventDateNoticeModalComponent,
{
size: 'sm',
data: notice,
},
)
.afterClosed$.subscribe(() => this.showNext(notices, index + 1));
}
}

View File

@@ -80,6 +80,21 @@ describe('ModalService', () => {
expect(ref.dismissReason()).toBeNull();
});
it('keeps a modal opened synchronously from an afterClosed subscriber', () => {
const firstRef = service.open(FirstTestModalComponent);
let secondRef: ModalRef | undefined;
firstRef.afterClosed$.subscribe(() => {
secondRef = service.open(SecondTestModalComponent);
});
firstRef.close();
expect(secondRef).toBeDefined();
expect(service.activeModal()?.ref).toBe(secondRef);
expect(service.activeModal()?.component).toBe(SecondTestModalComponent);
});
it('tracks dismiss reasons when closing programmatically', () => {
const ref = service.open(FirstTestModalComponent);
const closedSpy = vi.fn();

View File

@@ -253,8 +253,8 @@ export class ModalService {
return;
}
ref.finalize(result);
this.activeModalState.set(null);
ref.finalize(result);
}
private dismiss<TResult>(
@@ -265,8 +265,8 @@ export class ModalService {
return;
}
ref.finalize(undefined, reason);
this.activeModalState.set(null);
ref.finalize(undefined, reason);
}
private normalizeConfig<TData>(config: ModalConfig<TData>): NormalizedModalConfig<TData> {

View File

@@ -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 {
@@ -143,6 +167,10 @@ export interface Tenant {
cart_editing_policy?: CartEditingPolicy;
checkout_editing_policy?: CartEditingPolicy;
display_cart_item_images?: boolean;
allow_ticket_refund?: boolean;
allow_ticket_total_refund?: boolean;
allow_ticket_partial_refund?: boolean;
ticket_partial_refund_percentage?: string;
social_media?: SocialMedia[];
menues?: Menu[];
categories: Category[];

View File

@@ -15,6 +15,8 @@ export interface TicketResponse {
starts_at: string | null;
expires_at: string | null;
used_at: string | null;
status?: 'active' | 'expired' | 'used' | 'disabled' | 'cancelled' | 'refunded';
status_label?: string;
is_valid: boolean;
is_expired: boolean;
is_used: boolean;
@@ -26,9 +28,7 @@ export class TicketService extends BaseApiService {
getTickets(): Promise<TicketResponse[]> {
return firstValueFrom(
this.http.get<{ data: TicketResponse[] }>(
`${this.tenantService.getTenantApiUrl()}/tickets`,
),
this.http.get<{ data: TicketResponse[] }>(`${this.tenantService.getTenantApiUrl()}/tickets`),
).then((response) => response.data ?? []);
}

View File

@@ -63,7 +63,11 @@ export class TicketsPage implements OnInit {
}
protected isInactive(ticket: TicketResponse): boolean {
return ticket.is_expired || ticket.is_used;
return (
(ticket.status !== undefined && ticket.status !== 'active') ||
ticket.is_expired ||
ticket.is_used
);
}
protected formatDate(ticket: TicketResponse): string | null {

View File

@@ -0,0 +1,17 @@
<article class="event-date-notice">
<h2 class="event-date-notice__title">{{ data.title }}</h2>
<p class="event-date-notice__description">
@for (part of data.message; track $index) {
@if (part.bold) {
<strong>{{ part.text }}</strong>
} @else {
{{ part.text }}
}
}
</p>
<div class="event-date-notice__actions">
<app-button (click)="close()">Cerrar</app-button>
</div>
</article>

View File

@@ -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%;
}

View File

@@ -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();
});
});

View File

@@ -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<TenantEventDateNotice>(MODAL_DATA);
private readonly modalRef = inject<ModalRef<void>>(ModalRef);
protected close(): void {
this.modalRef.close();
}
}