9 Commits

25 changed files with 464 additions and 89 deletions

View File

@@ -1,10 +1,7 @@
import '@angular/compiler'; import '@angular/compiler';
import { signal } from '@angular/core'; import { signal } from '@angular/core';
import { TestBed, getTestBed } from '@angular/core/testing'; import { TestBed, getTestBed } from '@angular/core/testing';
import { import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
BrowserTestingModule,
platformBrowserTesting
} from '@angular/platform-browser/testing';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
@@ -27,7 +24,7 @@ const tenant: Tenant = {
footer_bg_color: '#313131', footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png', header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png', footer_logo: 'https://example.com/footer.png',
categories: [] categories: [],
}; };
function createTenantServiceStub(status: 'ready' | 'not-found', currentTenant: Tenant | null) { function createTenantServiceStub(status: 'ready' | 'not-found', currentTenant: Tenant | null) {
@@ -38,17 +35,14 @@ function createTenantServiceStub(status: 'ready' | 'not-found', currentTenant: T
tenant: tenantState.asReadonly(), tenant: tenantState.asReadonly(),
status: statusState.asReadonly(), status: statusState.asReadonly(),
getTenant: () => tenantState(), getTenant: () => tenantState(),
bootstrap: vi.fn().mockResolvedValue(undefined) bootstrap: vi.fn().mockResolvedValue(undefined),
}; };
} }
describe('App', () => { describe('App', () => {
beforeAll(() => { beforeAll(() => {
try { try {
getTestBed().initTestEnvironment( getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
BrowserTestingModule,
platformBrowserTesting()
);
} catch { } catch {
// Test environment may already be initialized by another setup entrypoint. // Test environment may already be initialized by another setup entrypoint.
} }
@@ -65,9 +59,9 @@ describe('App', () => {
provideRouter([]), provideRouter([]),
{ {
provide: TenantService, provide: TenantService,
useValue: createTenantServiceStub('ready', tenant) useValue: createTenantServiceStub('ready', tenant),
} },
] ],
}).compileComponents(); }).compileComponents();
const fixture = TestBed.createComponent(App); const fixture = TestBed.createComponent(App);
@@ -75,26 +69,27 @@ describe('App', () => {
expect(fixture.componentInstance).toBeTruthy(); expect(fixture.componentInstance).toBeTruthy();
expect(fixture.nativeElement.style.getPropertyValue('--tenant-primary')).toBe( expect(fixture.nativeElement.style.getPropertyValue('--tenant-primary')).toBe(
tenant.primary_color tenant.primary_color,
); );
expect(fixture.nativeElement.style.getPropertyValue('--tenant-secondary')).toBe( expect(fixture.nativeElement.style.getPropertyValue('--tenant-secondary')).toBe(
tenant.secondary_color tenant.secondary_color,
); );
expect(fixture.nativeElement.style.getPropertyValue('--tenant-danger')).toBe( expect(fixture.nativeElement.style.getPropertyValue('--tenant-danger')).toBe(
tenant.danger_color tenant.danger_color,
); );
expect(fixture.nativeElement.style.getPropertyValue('--tenant-primary-rgb')).toBe( expect(fixture.nativeElement.style.getPropertyValue('--tenant-primary-rgb')).toBe(
'99, 118, 243' '99, 118, 243',
); );
expect(fixture.nativeElement.style.getPropertyValue('--tenant-secondary-rgb')).toBe( expect(fixture.nativeElement.style.getPropertyValue('--tenant-secondary-rgb')).toBe(
'160, 160, 160' '160, 160, 160',
); );
expect(fixture.nativeElement.style.getPropertyValue('--tenant-danger-rgb')).toBe( expect(fixture.nativeElement.style.getPropertyValue('--tenant-danger-rgb')).toBe(
'255, 136, 136' '255, 136, 136',
); );
expect(document.title).toBe(tenant.site_title); expect(document.title).toBe(tenant.site_title);
expect(document.head.querySelector<HTMLLinkElement>('link[rel~="icon"]')?.getAttribute('href')) expect(
.toBe(tenant.favicon); 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 () => { it('renders the tenant not found screen when the tenant is missing', async () => {
@@ -104,17 +99,21 @@ describe('App', () => {
provideRouter([]), provideRouter([]),
{ {
provide: TenantService, provide: TenantService,
useValue: createTenantServiceStub('not-found', null) useValue: createTenantServiceStub('not-found', null),
} },
] ],
}).compileComponents(); }).compileComponents();
const fixture = TestBed.createComponent(App); const fixture = TestBed.createComponent(App);
fixture.detectChanges(); 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.title).toBe('ShopitFront');
expect(document.head.querySelector<HTMLLinkElement>('link[rel~="icon"]')?.getAttribute('href')) expect(
.toBe("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E"); document.head.querySelector<HTMLLinkElement>('link[rel~="icon"]')?.getAttribute('href'),
).toBe("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E");
}); });
}); });

View File

@@ -13,7 +13,9 @@ const EMPTY_FAVICON = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/s
function hexToRgb(hex: string): string { function hexToRgb(hex: string): string {
const cleanHex = hex.replace('#', '').trim(); const cleanHex = hex.replace('#', '').trim();
let r = 0, g = 0, b = 0; let r = 0,
g = 0,
b = 0;
if (cleanHex.length === 3) { if (cleanHex.length === 3) {
r = parseInt(cleanHex[0] + cleanHex[0], 16); r = parseInt(cleanHex[0] + cleanHex[0], 16);
g = parseInt(cleanHex[1] + cleanHex[1], 16); g = parseInt(cleanHex[1] + cleanHex[1], 16);
@@ -28,12 +30,7 @@ function hexToRgb(hex: string): string {
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [ imports: [RouterOutlet, ToastContainerComponent, ModalHostComponent, GlobalLoadingComponent],
RouterOutlet,
ToastContainerComponent,
ModalHostComponent,
GlobalLoadingComponent
],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.scss', styleUrl: './app.scss',
host: { host: {
@@ -56,8 +53,8 @@ function hexToRgb(hex: string): string {
'[style.--tenant-header-bg]': 'tenantHeaderBgColor()', '[style.--tenant-header-bg]': 'tenantHeaderBgColor()',
'[style.--tenant-footer-bg]': 'tenantFooterBgColor()', '[style.--tenant-footer-bg]': 'tenantFooterBgColor()',
'[style.--color-header-bg]': 'tenantHeaderBgColor()', '[style.--color-header-bg]': 'tenantHeaderBgColor()',
'[style.--color-footer-bg]': 'tenantFooterBgColor()' '[style.--color-footer-bg]': 'tenantFooterBgColor()',
} },
}) })
export class App { export class App {
private readonly tenantService = inject(TenantService); private readonly tenantService = inject(TenantService);
@@ -87,37 +84,27 @@ export class App {
protected readonly status = this.tenantService.status; protected readonly status = this.tenantService.status;
protected readonly tenantPrimaryColor = computed( 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( 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( 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( protected readonly tenantSuccessColor = computed(
() => this.tenantService.tenant()?.success_color ?? DEFAULT_TENANT_BRANDING.successColor () => this.tenantService.tenant()?.success_color ?? DEFAULT_TENANT_BRANDING.successColor,
);
protected readonly tenantPrimaryColorRgb = computed(() =>
hexToRgb(this.tenantPrimaryColor())
); );
protected readonly tenantPrimaryColorRgb = computed(() => hexToRgb(this.tenantPrimaryColor()));
protected readonly tenantSecondaryColorRgb = computed(() => protected readonly tenantSecondaryColorRgb = computed(() =>
hexToRgb(this.tenantSecondaryColor()) hexToRgb(this.tenantSecondaryColor()),
);
protected readonly tenantDangerColorRgb = computed(() =>
hexToRgb(this.tenantDangerColor())
);
protected readonly tenantSuccessColorRgb = computed(() =>
hexToRgb(this.tenantSuccessColor())
); );
protected readonly tenantDangerColorRgb = computed(() => hexToRgb(this.tenantDangerColor()));
protected readonly tenantSuccessColorRgb = computed(() => hexToRgb(this.tenantSuccessColor()));
protected readonly tenantHeaderBgColor = computed( 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( 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

@@ -26,6 +26,7 @@ import { StoreHeaderComponent } from './store-header/store-header.component';
import { CheckoutService } from '../../services/checkout.service'; import { CheckoutService } from '../../services/checkout.service';
import { CheckoutCountdownService } from '../../services/checkout-countdown.service'; import { CheckoutCountdownService } from '../../services/checkout-countdown.service';
import { TenantUrlSerializer } from '../../services/tenant-url.serializer'; import { TenantUrlSerializer } from '../../services/tenant-url.serializer';
import { EventDateNoticeService } from '../../services/event-date-notice.service';
const tenant: Tenant = { const tenant: Tenant = {
id: 1, id: 1,
@@ -224,6 +225,12 @@ describe('StoreLayoutComponent', () => {
remainingSeconds: checkoutRemainingSecondsState.asReadonly(), remainingSeconds: checkoutRemainingSecondsState.asReadonly(),
}, },
}, },
{
provide: EventDateNoticeService,
useValue: {
claim: vi.fn().mockReturnValue(of({ data: [] })),
},
},
], ],
}).compileComponents(); }).compileComponents();
}); });
@@ -574,17 +581,13 @@ describe('StoreLayoutComponent', () => {
expect(authService.logout).toHaveBeenCalled(); expect(authService.logout).toHaveBeenCalled();
expect(cartService.clearCart).toHaveBeenCalled(); expect(cartService.clearCart).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(['/']); expect(router.navigate).toHaveBeenCalledWith(['/']);
expect(TestBed.inject(ToastService).info).toHaveBeenCalledWith( expect(TestBed.inject(ToastService).info).toHaveBeenCalledWith('Sesión cerrada correctamente.');
'Sesión cerrada correctamente.',
);
}); });
it('shows a danger toast when logout fails', async () => { it('shows a danger toast when logout fails', async () => {
const authService = TestBed.inject(AuthService); const authService = TestBed.inject(AuthService);
const message = 'La sesión no pudo cerrarse en el servidor.'; const message = 'La sesión no pudo cerrarse en el servidor.';
(authService.logout as any).mockReturnValue( (authService.logout as any).mockReturnValue(throwError(() => ({ error: { message } })));
throwError(() => ({ error: { message } })),
);
const fixture = TestBed.createComponent(StoreLayoutComponent); const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges(); fixture.detectChanges();

View File

@@ -1,5 +1,15 @@
import { isPlatformBrowser } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
import { Component, computed, DestroyRef, inject, OnInit, signal } from '@angular/core'; import {
Component,
computed,
DestroyRef,
effect,
inject,
OnInit,
PLATFORM_ID,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { import {
ActivatedRoute, ActivatedRoute,
@@ -8,7 +18,7 @@ import {
Router, Router,
RouterOutlet, RouterOutlet,
} from '@angular/router'; } from '@angular/router';
import { filter } from 'rxjs'; import { filter, firstValueFrom } from 'rxjs';
import { TenantService } from '../../services/tenant.service'; import { TenantService } from '../../services/tenant.service';
import { CartService } from '../../services/cart/cart.service'; import { CartService } from '../../services/cart/cart.service';
import { StoreFooterComponent, StoreFooterSection } from './store-footer/store-footer.component'; import { StoreFooterComponent, StoreFooterSection } from './store-footer/store-footer.component';
@@ -25,6 +35,9 @@ import {
import { ToastService } from '../../services/toast.service'; import { ToastService } from '../../services/toast.service';
import { Category } from '../../services/tenant.interface'; import { Category } from '../../services/tenant.interface';
import { CheckoutCountdownService } from '../../services/checkout-countdown.service'; import { CheckoutCountdownService } from '../../services/checkout-countdown.service';
import { EventDateNotice, EventDateNoticeService } from '../../services/event-date-notice.service';
import { ModalService } from '../../services/modal.service';
import { EventDateNoticeModalComponent } from '../../../shared/components/event-date-notice-modal/event-date-notice-modal.component';
@Component({ @Component({
selector: 'app-store-layout', selector: 'app-store-layout',
@@ -48,6 +61,10 @@ export class StoreLayoutComponent implements OnInit {
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly platformId = inject(PLATFORM_ID);
private readonly eventDateNoticeService = inject(EventDateNoticeService);
private readonly modalService = inject(ModalService);
private claimedNoticeUserId: number | null = null;
protected readonly isCartOpen = signal(false); protected readonly isCartOpen = signal(false);
protected readonly isCheckoutRoute = signal(this.isCheckoutUrl(this.router.url)); protected readonly isCheckoutRoute = signal(this.isCheckoutUrl(this.router.url));
@@ -68,6 +85,27 @@ export class StoreLayoutComponent implements OnInit {
() => this.cartEditingPolicy()?.allow_update_variant ?? false, () => this.cartEditingPolicy()?.allow_update_variant ?? false,
); );
constructor() {
effect(() => {
const user = this.authService.user();
if (!isPlatformBrowser(this.platformId) || !user) {
this.claimedNoticeUserId = null;
return;
}
if (this.claimedNoticeUserId === user.id) {
return;
}
this.claimedNoticeUserId = user.id;
this.eventDateNoticeService.claim().subscribe({
next: ({ data }) => void this.showEventDateNotices(data, user.id),
error: (error) => console.error('Error loading event date notices', error),
});
});
}
protected readonly cartSubtotal = computed(() => { protected readonly cartSubtotal = computed(() => {
const cart = this.cartService.cart(); const cart = this.cartService.cart();
return cart ? parseFloat(cart.subtotal) : 0; return cart ? parseFloat(cart.subtotal) : 0;
@@ -207,6 +245,28 @@ export class StoreLayoutComponent implements OnInit {
this.isCartOpen.update((isOpen) => !isOpen); this.isCartOpen.update((isOpen) => !isOpen);
} }
private async showEventDateNotices(notices: EventDateNotice[], userId: number): Promise<void> {
for (const notice of notices) {
if (this.authService.user()?.id !== userId) {
return;
}
const modalRef = this.modalService.open<EventDateNoticeModalComponent, void, EventDateNotice>(
EventDateNoticeModalComponent,
{
size: 'sm',
data: notice,
},
);
await firstValueFrom(modalRef.afterClosed$);
if (modalRef.dismissReason() === 'replaced') {
return;
}
}
}
protected onSearch(term: string): void { protected onSearch(term: string): void {
void this.router.navigate(['/buscar'], { void this.router.navigate(['/buscar'], {
queryParams: { q: term, page: 1 }, queryParams: { q: term, page: 1 },

View File

@@ -0,0 +1,59 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { environment } from '../../../environments/environment';
import { EventDateNotice, EventDateNoticeService } from './event-date-notice.service';
import { ApiResponse } from './api-response.interface';
import { TenantService } from './tenant.service';
describe('EventDateNoticeService', () => {
let service: EventDateNoticeService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
EventDateNoticeService,
provideHttpClient(),
provideHttpClientTesting(),
{
provide: TenantService,
useValue: { getTenant: () => ({ codigo: 'festival' }) },
},
],
});
service = TestBed.inject(EventDateNoticeService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('claims pending notices for the active tenant', () => {
const response = {
data: [
{
type: 'suspended' as const,
change_ids: [10],
title: 'FECHA CANCELADA!',
message: [{ text: 'La fecha fue cancelada.', bold: false }],
},
],
};
let result: ApiResponse<EventDateNotice[]> | undefined;
service.claim().subscribe((value) => (result = value));
const request = httpMock.expectOne(
`${environment.url}tenants/festival/event-date-notices/claim`,
);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({});
request.flush(response);
expect(result).toEqual(response);
});
});

View File

@@ -0,0 +1,39 @@
import { inject, Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { ApiResponse } from './api-response.interface';
import { BaseApiService } from './base-api.service';
import { TenantService } from './tenant.service';
export type EventDateNoticeType = 'rescheduled' | 'suspended';
export interface EventDateNoticeSegment {
text: string;
bold: boolean;
}
export interface EventDateNotice {
type: EventDateNoticeType;
change_ids: number[];
title: string;
message: EventDateNoticeSegment[];
}
@Injectable({ providedIn: 'root' })
export class EventDateNoticeService extends BaseApiService {
private readonly tenantService = inject(TenantService);
claim(): Observable<ApiResponse<EventDateNotice[]>> {
const tenant = this.tenantService.getTenant();
if (!tenant) {
throw new Error('No se pudo resolver el tenant activo.');
}
return this.withoutLoading().http.post<ApiResponse<EventDateNotice[]>>(
`${environment.url}tenants/${tenant.codigo}/event-date-notices/claim`,
{},
);
}
}

View File

@@ -80,6 +80,21 @@ describe('ModalService', () => {
expect(ref.dismissReason()).toBeNull(); 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', () => { it('tracks dismiss reasons when closing programmatically', () => {
const ref = service.open(FirstTestModalComponent); const ref = service.open(FirstTestModalComponent);
const closedSpy = vi.fn(); const closedSpy = vi.fn();

View File

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

View File

@@ -51,6 +51,8 @@ export interface EventDate {
date: string; date: string;
start_time: string; start_time: string;
end_time: string; end_time: string;
info_text: string | null;
isCanceled: boolean;
} }
export interface EventConfig { export interface EventConfig {
@@ -67,6 +69,8 @@ export interface ActiveEventDate {
date: string; date: string;
time_start: string; time_start: string;
time_end: string; time_end: string;
info_text: string | null;
isCanceled: boolean;
} }
export interface TenantEvent { export interface TenantEvent {
@@ -143,6 +147,10 @@ export interface Tenant {
cart_editing_policy?: CartEditingPolicy; cart_editing_policy?: CartEditingPolicy;
checkout_editing_policy?: CartEditingPolicy; checkout_editing_policy?: CartEditingPolicy;
display_cart_item_images?: boolean; 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[]; social_media?: SocialMedia[];
menues?: Menu[]; menues?: Menu[];
categories: Category[]; categories: Category[];

View File

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

View File

@@ -63,7 +63,11 @@ export class TicketsPage implements OnInit {
} }
protected isInactive(ticket: TicketResponse): boolean { 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 { protected formatDate(ticket: TicketResponse): string | null {

View File

@@ -233,12 +233,16 @@ describe('StoreHomePageComponent', () => {
date: '2026-12-05', date: '2026-12-05',
time_start: '09:00:00', time_start: '09:00:00',
time_end: '18:00:00', time_end: '18:00:00',
info_text: null,
isCanceled: false,
}, },
{ {
id: 21, id: 21,
date: '2026-12-06', date: '2026-12-06',
time_start: '09:00:00', time_start: '09:00:00',
time_end: '18:00:00', time_end: '18:00:00',
info_text: null,
isCanceled: false,
}, },
], ],
}; };
@@ -369,6 +373,8 @@ describe('StoreHomePageComponent', () => {
date: '2026-10-09', date: '2026-10-09',
time_start: '09:00:00', time_start: '09:00:00',
time_end: '18:00:00', time_end: '18:00:00',
info_text: null,
isCanceled: false,
}, },
], ],
}; };

View File

@@ -89,6 +89,8 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
date: eventDate.date, date: eventDate.date,
start_time: eventDate.time_start, start_time: eventDate.time_start,
end_time: eventDate.time_end, end_time: eventDate.time_end,
info_text: eventDate.info_text,
isCanceled: eventDate.isCanceled,
})), })),
contact, contact,
}; };

View File

@@ -0,0 +1,15 @@
<div class="event-date-notice-modal">
<h2>{{ notice.title }}</h2>
<p>
@for (segment of notice.message; track $index) {
@if (segment.bold) {
<b>{{ segment.text }}</b>
} @else {
<span>{{ segment.text }}</span>
}
}
</p>
<app-button (click)="close()">Cerrar</app-button>
</div>

View File

@@ -0,0 +1,28 @@
.event-date-notice-modal {
display: grid;
gap: 1.5rem;
justify-items: stretch;
text-align: center;
}
h2,
p {
margin: 0;
}
h2 {
color: var(--tenant-danger);
font-size: 16px;
font-weight: 700;
line-height: 1.25;
}
p {
color: #666666;
font-size: 15px;
line-height: 1.35;
}
b {
font-weight: 700;
}

View File

@@ -0,0 +1,58 @@
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 { EventDateNotice } from '../../../core/services/event-date-notice.service';
import { MODAL_DATA, ModalRef } from '../../../core/services/modal.service';
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: EventDateNotice = {
type: 'rescheduled',
change_ids: [1, 2],
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('b')].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 { EventDateNotice } from '../../../core/services/event-date-notice.service';
import { MODAL_DATA, ModalRef } from '../../../core/services/modal.service';
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 notice = inject<EventDateNotice>(MODAL_DATA);
private readonly modalRef = inject<ModalRef<void>>(ModalRef);
protected close(): void {
this.modalRef.close();
}
}

View File

@@ -88,7 +88,12 @@
<td> <td>
<span class="event-schedule-value"> <span class="event-schedule-value">
<i class="fa-regular fa-calendar" aria-hidden="true"></i> <i class="fa-regular fa-calendar" aria-hidden="true"></i>
<span>{{ formatScheduleDate(eventDate.date) }}</span> <span [class.event-schedule-date--canceled]="eventDate.isCanceled">
{{ formatScheduleDate(eventDate.date) }}
</span>
@if (eventDate.info_text) {
<app-tooltip [message]="eventDate.info_text" />
}
</span> </span>
</td> </td>
<td> <td>

View File

@@ -164,6 +164,10 @@
} }
} }
.event-schedule-date--canceled {
text-decoration: line-through;
}
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
.hero-banner-container { .hero-banner-container {
padding-bottom: 0; padding-bottom: 0;

View File

@@ -22,9 +22,7 @@ describe('HeroBannerComponent', () => {
expect(element.querySelector('img')?.getAttribute('src')).toBe( expect(element.querySelector('img')?.getAttribute('src')).toBe(
'https://example.com/desktop.jpg', 'https://example.com/desktop.jpg',
); );
expect(element.querySelector('.hero-banner')?.classList).toContain( expect(element.querySelector('.hero-banner')?.classList).toContain('hero-banner--with-media');
'hero-banner--with-media',
);
}); });
it('keeps the fallback banner sizing when there is no image', async () => { it('keeps the fallback banner sizing when there is no image', async () => {
@@ -52,12 +50,16 @@ describe('HeroBannerComponent', () => {
date: '2026-10-09', date: '2026-10-09',
start_time: '10:00:00', start_time: '10:00:00',
end_time: '20:30:00', end_time: '20:30:00',
info_text: 'Esta fecha fue cancelada.',
isCanceled: true,
}, },
{ {
id: 2, id: 2,
date: '2026-10-10', date: '2026-10-10',
start_time: '10:00:00', start_time: '10:00:00',
end_time: '22:00:00', end_time: '22:00:00',
info_text: null,
isCanceled: false,
}, },
], ],
}); });
@@ -78,6 +80,10 @@ describe('HeroBannerComponent', () => {
expect(element.querySelectorAll('.event-schedules tbody tr')).toHaveLength(2); 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('9 de Octubre 2026');
expect(element.querySelector('.event-schedules')?.textContent).toContain('10:00 - 20:30'); expect(element.querySelector('.event-schedules')?.textContent).toContain('10:00 - 20:30');
expect(element.querySelector('.event-schedule-date--canceled')).not.toBeNull();
expect(element.querySelector('app-tooltip')?.textContent).toContain(
'Esta fecha fue cancelada.',
);
toggle?.click(); toggle?.click();
fixture.detectChanges(); fixture.detectChanges();

View File

@@ -3,11 +3,12 @@ import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { EventConfig, HeroConfig } from '../../../core/services/tenant.interface'; import { EventConfig, HeroConfig } from '../../../core/services/tenant.interface';
import { ButtonComponent } from '../button/button.component'; import { ButtonComponent } from '../button/button.component';
import { TooltipComponent } from '../tooltip/tooltip.component';
@Component({ @Component({
selector: 'app-hero-banner', selector: 'app-hero-banner',
standalone: true, standalone: true,
imports: [CommonModule, RouterModule, ButtonComponent], imports: [CommonModule, RouterModule, ButtonComponent, TooltipComponent],
templateUrl: './hero-banner.component.html', templateUrl: './hero-banner.component.html',
styleUrl: './hero-banner.component.scss', styleUrl: './hero-banner.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -3,9 +3,21 @@
class="tooltip-trigger" class="tooltip-trigger"
[attr.aria-describedby]="tooltipId" [attr.aria-describedby]="tooltipId"
[attr.aria-label]="message()" [attr.aria-label]="message()"
(mouseenter)="showTooltip($event)"
(mouseleave)="hideTooltip()"
(focus)="showTooltip($event)"
(blur)="hideTooltip()"
> >
<i class="fa-solid fa-circle-info" aria-hidden="true"></i> <i class="fa-solid fa-circle-info" aria-hidden="true"></i>
<span class="tooltip-message" [id]="tooltipId" role="tooltip"> <span
class="tooltip-message"
[class.tooltip-message--visible]="tooltipVisible"
[style.left.px]="tooltipLeft"
[style.top.px]="tooltipTop"
[id]="tooltipId"
popover="manual"
role="tooltip"
>
{{ message() }} {{ message() }}
</span> </span>
</button> </button>

View File

@@ -22,22 +22,15 @@
outline: 2px solid currentColor; outline: 2px solid currentColor;
outline-offset: 2px; outline-offset: 2px;
} }
&:hover .tooltip-message,
&:focus-visible .tooltip-message {
visibility: visible;
opacity: 1;
transform: translate(-50%, -0.25rem);
}
} }
.tooltip-message { .tooltip-message {
position: absolute; position: fixed;
z-index: 1100; inset: auto;
bottom: calc(100% + 0.625rem); z-index: 2000;
left: 50%;
width: max-content; width: max-content;
max-width: min(16rem, 75vw); max-width: min(16rem, 75vw);
margin: 0;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border-radius: 0.375rem; border-radius: 0.375rem;
border: 1px solid var(--tenant-primary, var(--color-primary, #0d6efd)); border: 1px solid var(--tenant-primary, var(--color-primary, #0d6efd));
@@ -54,9 +47,13 @@
visibility: hidden; visibility: hidden;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
transform: translate(-50%, 0); transform: translateX(-50%);
transition: transition:
opacity 0.15s ease, opacity 0.15s ease,
transform 0.15s ease,
visibility 0.15s ease; visibility 0.15s ease;
} }
.tooltip-message--visible {
visibility: visible;
opacity: 1;
}

View File

@@ -28,5 +28,15 @@ describe('TooltipComponent', () => {
expect(trigger?.querySelector('i.fa-solid.fa-circle-info')).not.toBeNull(); expect(trigger?.querySelector('i.fa-solid.fa-circle-info')).not.toBeNull();
expect(message?.textContent?.trim()).toBe('Este producto no tiene stock disponible.'); expect(message?.textContent?.trim()).toBe('Este producto no tiene stock disponible.');
expect(trigger?.getAttribute('aria-describedby')).toBe(message?.id); expect(trigger?.getAttribute('aria-describedby')).toBe(message?.id);
trigger?.dispatchEvent(new MouseEvent('mouseenter'));
fixture.detectChanges();
expect(message?.classList).toContain('tooltip-message--visible');
trigger?.dispatchEvent(new MouseEvent('mouseleave'));
fixture.detectChanges();
expect(message?.classList).not.toContain('tooltip-message--visible');
}); });
}); });

View File

@@ -11,4 +11,40 @@ let nextTooltipId = 0;
export class TooltipComponent { export class TooltipComponent {
readonly message = input.required<string>(); readonly message = input.required<string>();
protected readonly tooltipId = `app-tooltip-${nextTooltipId++}`; protected readonly tooltipId = `app-tooltip-${nextTooltipId++}`;
protected tooltipVisible = false;
protected tooltipLeft = 0;
protected tooltipTop = 0;
private activeTooltip: HTMLElement | null = null;
protected showTooltip(event: MouseEvent | FocusEvent): void {
const trigger = event.currentTarget as HTMLElement;
const rect = trigger.getBoundingClientRect();
this.tooltipLeft = rect.left + rect.width / 2;
this.tooltipTop = rect.bottom + 10;
this.tooltipVisible = true;
this.activeTooltip = trigger.querySelector<HTMLElement>('.tooltip-message');
if (
this.activeTooltip &&
typeof this.activeTooltip.showPopover === 'function' &&
!this.activeTooltip.matches(':popover-open')
) {
this.activeTooltip.showPopover();
}
}
protected hideTooltip(): void {
this.tooltipVisible = false;
if (
this.activeTooltip &&
typeof this.activeTooltip.hidePopover === 'function' &&
this.activeTooltip.matches(':popover-open')
) {
this.activeTooltip.hidePopover();
}
this.activeTooltip = null;
}
} }