diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts index a446d61..ce72eea 100644 --- a/src/app/app.spec.ts +++ b/src/app/app.spec.ts @@ -1,10 +1,7 @@ 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'; @@ -27,7 +24,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 +35,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 +59,9 @@ describe('App', () => { provideRouter([]), { provide: TenantService, - useValue: createTenantServiceStub('ready', tenant) - } - ] + useValue: createTenantServiceStub('ready', tenant), + }, + ], }).compileComponents(); const fixture = TestBed.createComponent(App); @@ -75,26 +69,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 +99,21 @@ 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"); }); + }); diff --git a/src/app/app.ts b/src/app/app.ts index e69b2e4..268c29d 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -13,7 +13,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 +30,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,8 +53,8 @@ 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); @@ -87,37 +84,27 @@ export class App { 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/layout/store-layout/store-layout.component.spec.ts b/src/app/core/layout/store-layout/store-layout.component.spec.ts index cfed07f..5b86850 100644 --- a/src/app/core/layout/store-layout/store-layout.component.spec.ts +++ b/src/app/core/layout/store-layout/store-layout.component.spec.ts @@ -26,6 +26,7 @@ import { StoreHeaderComponent } from './store-header/store-header.component'; import { CheckoutService } from '../../services/checkout.service'; import { CheckoutCountdownService } from '../../services/checkout-countdown.service'; import { TenantUrlSerializer } from '../../services/tenant-url.serializer'; +import { EventDateNoticeService } from '../../services/event-date-notice.service'; const tenant: Tenant = { id: 1, @@ -224,6 +225,12 @@ describe('StoreLayoutComponent', () => { remainingSeconds: checkoutRemainingSecondsState.asReadonly(), }, }, + { + provide: EventDateNoticeService, + useValue: { + claim: vi.fn().mockReturnValue(of({ data: [] })), + }, + }, ], }).compileComponents(); }); @@ -574,17 +581,13 @@ describe('StoreLayoutComponent', () => { expect(authService.logout).toHaveBeenCalled(); expect(cartService.clearCart).toHaveBeenCalled(); expect(router.navigate).toHaveBeenCalledWith(['/']); - expect(TestBed.inject(ToastService).info).toHaveBeenCalledWith( - 'Sesión cerrada correctamente.', - ); + expect(TestBed.inject(ToastService).info).toHaveBeenCalledWith('Sesión cerrada correctamente.'); }); it('shows a danger toast when logout fails', async () => { const authService = TestBed.inject(AuthService); const message = 'La sesión no pudo cerrarse en el servidor.'; - (authService.logout as any).mockReturnValue( - throwError(() => ({ error: { message } })), - ); + (authService.logout as any).mockReturnValue(throwError(() => ({ error: { message } }))); const fixture = TestBed.createComponent(StoreLayoutComponent); fixture.detectChanges(); 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 9a40985..803f6c1 100644 --- a/src/app/core/layout/store-layout/store-layout.component.ts +++ b/src/app/core/layout/store-layout/store-layout.component.ts @@ -1,5 +1,15 @@ +import { isPlatformBrowser } from '@angular/common'; 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 { ActivatedRoute, @@ -8,7 +18,7 @@ import { Router, RouterOutlet, } from '@angular/router'; -import { filter } from 'rxjs'; +import { filter, firstValueFrom } from 'rxjs'; import { TenantService } from '../../services/tenant.service'; import { CartService } from '../../services/cart/cart.service'; import { StoreFooterComponent, StoreFooterSection } from './store-footer/store-footer.component'; @@ -25,6 +35,9 @@ import { import { ToastService } from '../../services/toast.service'; import { Category } from '../../services/tenant.interface'; 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({ selector: 'app-store-layout', @@ -48,6 +61,10 @@ export class StoreLayoutComponent implements OnInit { private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); 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 isCheckoutRoute = signal(this.isCheckoutUrl(this.router.url)); @@ -68,6 +85,27 @@ export class StoreLayoutComponent implements OnInit { () => 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(() => { const cart = this.cartService.cart(); return cart ? parseFloat(cart.subtotal) : 0; @@ -207,6 +245,28 @@ export class StoreLayoutComponent implements OnInit { this.isCartOpen.update((isOpen) => !isOpen); } + private async showEventDateNotices(notices: EventDateNotice[], userId: number): Promise { + for (const notice of notices) { + if (this.authService.user()?.id !== userId) { + return; + } + + const modalRef = this.modalService.open( + EventDateNoticeModalComponent, + { + size: 'sm', + data: notice, + }, + ); + + await firstValueFrom(modalRef.afterClosed$); + + if (modalRef.dismissReason() === 'replaced') { + return; + } + } + } + protected onSearch(term: string): void { void this.router.navigate(['/buscar'], { queryParams: { q: term, page: 1 }, 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..310ecfb --- /dev/null +++ b/src/app/core/services/event-date-notice.service.spec.ts @@ -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 | 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); + }); +}); 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..d1ba947 --- /dev/null +++ b/src/app/core/services/event-date-notice.service.ts @@ -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> { + const tenant = this.tenantService.getTenant(); + + if (!tenant) { + throw new Error('No se pudo resolver el tenant activo.'); + } + + return this.withoutLoading().http.post>( + `${environment.url}tenants/${tenant.codigo}/event-date-notices/claim`, + {}, + ); + } +} diff --git a/src/app/core/services/modal.service.spec.ts b/src/app/core/services/modal.service.spec.ts index 12e140d..6053020 100644 --- a/src/app/core/services/modal.service.spec.ts +++ b/src/app/core/services/modal.service.spec.ts @@ -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(); diff --git a/src/app/core/services/modal.service.ts b/src/app/core/services/modal.service.ts index 958631f..fc8abb9 100644 --- a/src/app/core/services/modal.service.ts +++ b/src/app/core/services/modal.service.ts @@ -253,8 +253,8 @@ export class ModalService { return; } - ref.finalize(result); this.activeModalState.set(null); + ref.finalize(result); } private dismiss( @@ -265,8 +265,8 @@ export class ModalService { return; } - ref.finalize(undefined, reason); this.activeModalState.set(null); + ref.finalize(undefined, reason); } private normalizeConfig(config: ModalConfig): NormalizedModalConfig { diff --git a/src/app/core/services/tenant.interface.ts b/src/app/core/services/tenant.interface.ts index 5976260..0792a45 100644 --- a/src/app/core/services/tenant.interface.ts +++ b/src/app/core/services/tenant.interface.ts @@ -51,6 +51,8 @@ export interface EventDate { date: string; start_time: string; end_time: string; + info_text: string | null; + isCanceled: boolean; } export interface EventConfig { @@ -67,6 +69,8 @@ export interface ActiveEventDate { date: string; time_start: string; time_end: string; + info_text: string | null; + isCanceled: boolean; } export interface TenantEvent { @@ -143,6 +147,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[]; 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..2e73168 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 @@ -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 { return firstValueFrom( - this.http.get<{ data: TicketResponse[] }>( - `${this.tenantService.getTenantApiUrl()}/tickets`, - ), + this.http.get<{ data: TicketResponse[] }>(`${this.tenantService.getTenantApiUrl()}/tickets`), ).then((response) => response.data ?? []); } diff --git a/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.ts b/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.ts index a1f4ad3..affdf19 100644 --- a/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.ts +++ b/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.ts @@ -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 { 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 60367d9..47e3c14 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 @@ -233,12 +233,16 @@ describe('StoreHomePageComponent', () => { date: '2026-12-05', time_start: '09:00:00', time_end: '18:00:00', + info_text: null, + isCanceled: false, }, { id: 21, date: '2026-12-06', time_start: '09:00:00', time_end: '18:00:00', + info_text: null, + isCanceled: false, }, ], }; @@ -369,6 +373,8 @@ describe('StoreHomePageComponent', () => { date: '2026-10-09', time_start: '09:00:00', time_end: '18:00:00', + info_text: null, + isCanceled: false, }, ], }; 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 3092cd0..45e9c93 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 @@ -89,6 +89,8 @@ export class StoreHomePageComponent implements OnInit, OnDestroy { date: eventDate.date, start_time: eventDate.time_start, end_time: eventDate.time_end, + info_text: eventDate.info_text, + isCanceled: eventDate.isCanceled, })), contact, }; 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 14e9c02..2401388 100644 --- a/src/app/shared/components/cart-item/cart-item.component.html +++ b/src/app/shared/components/cart-item/cart-item.component.html @@ -31,8 +31,9 @@ class="cart-item-variant-selector" [variants]="variants()" [selectedVariant]="selectedVariant()" + [autoSelectFirst]="false" [compact]="true" - (selectedVariantChange)="onVariantChange($event)" + (selectionValuesChange)="onVariantChange($event.selectedVariant)" /> } @else {
diff --git a/src/app/shared/components/cart/cart.component.spec.ts b/src/app/shared/components/cart/cart.component.spec.ts index b367a0d..663bfe2 100644 --- a/src/app/shared/components/cart/cart.component.spec.ts +++ b/src/app/shared/components/cart/cart.component.spec.ts @@ -3,13 +3,14 @@ import { signal } from '@angular/core'; import { TestBed, getTestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; -import { of, throwError } from 'rxjs'; +import { of, Subject, throwError } from 'rxjs'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { CartService } from '../../../core/services/cart/cart.service'; import { ModalService } from '../../../core/services/modal.service'; import { ToastService } from '../../../core/services/toast.service'; import { CartComponent } from './cart.component'; +import { VariantSelectorComponent } from '../variant-selector/variant-selector.component'; describe('CartComponent', () => { beforeAll(() => { @@ -22,6 +23,7 @@ describe('CartComponent', () => { afterEach(() => { TestBed.resetTestingModule(); + vi.restoreAllMocks(); }); it('shows an empty cart message when there are no items', async () => { @@ -565,6 +567,57 @@ describe('CartComponent', () => { expect(quantityChange).not.toHaveBeenCalled(); }); + it('does not automatically replace an unavailable variant or retry a rejected selection', async () => { + const response = new Subject(); + const updateItemVariant = vi.fn().mockReturnValue(response); + const danger = vi.fn(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + await TestBed.configureTestingModule({ + imports: [CartComponent], + providers: [ + { provide: CartService, useValue: { cart: signal(null), updateItemVariant } }, + { provide: ModalService, useValue: {} }, + { provide: ToastService, useValue: { success: vi.fn(), danger } }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent(CartComponent); + const item = { + cartItemId: 10, + imageUrl: null, + product: 'Entrada', + originalPrice: null, + discountedPrice: 1000, + discountPercentage: null, + attributes: [], + quantity: 2, + variantId: 20, + variants: [{ id: 20, values: { fecha: '09/10' } }], + }; + fixture.componentRef.setInput('items', [item]); + fixture.detectChanges(); + + const rescheduledItem = { + ...item, variants: [{ id: 21, values: { fecha: '20/10' } }], + }; + fixture.componentRef.setInput('items', [rescheduledItem]); + fixture.detectChanges(); + expect(updateItemVariant).not.toHaveBeenCalled(); + + const selector = fixture.debugElement.query(By.directive(VariantSelectorComponent)); + (selector.componentInstance as any).onValueChange('fecha', '20/10'); + fixture.detectChanges(); + expect(updateItemVariant).toHaveBeenCalledExactlyOnceWith(10, 2, 21); + + response.error({ status: 422, error: { message: 'Variante no disponible.' } }); + fixture.detectChanges(); + fixture.componentRef.setInput('items', [{ + ...rescheduledItem, variants: [...rescheduledItem.variants], + }]); + fixture.detectChanges(); + expect(updateItemVariant).toHaveBeenCalledTimes(1); + expect(danger).toHaveBeenCalledOnce(); + }); + it('persists a variant selected from a cart row', async () => { const updateItemVariant = vi.fn().mockReturnValue( of({ 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..96fb0ba --- /dev/null +++ b/src/app/shared/components/event-date-notice-modal/event-date-notice-modal.component.html @@ -0,0 +1,15 @@ +
+

{{ notice.title }}

+ +

+ @for (segment of notice.message; track $index) { + @if (segment.bold) { + {{ segment.text }} + } @else { + {{ segment.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..9832096 --- /dev/null +++ b/src/app/shared/components/event-date-notice-modal/event-date-notice-modal.component.scss @@ -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; +} 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..d565b47 --- /dev/null +++ b/src/app/shared/components/event-date-notice-modal/event-date-notice-modal.component.spec.ts @@ -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(); + }); +}); 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..4ad9e5d --- /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 { 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(MODAL_DATA); + private readonly modalRef = inject>(ModalRef); + + protected close(): void { + this.modalRef.close(); + } +} 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 0fcf6db..c46dff9 100644 --- a/src/app/shared/components/hero-banner/hero-banner.component.html +++ b/src/app/shared/components/hero-banner/hero-banner.component.html @@ -88,7 +88,12 @@ - {{ formatScheduleDate(eventDate.date) }} + + {{ formatScheduleDate(eventDate.date) }} + + @if (eventDate.info_text) { + + } 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 1b42b53..25223a1 100644 --- a/src/app/shared/components/hero-banner/hero-banner.component.scss +++ b/src/app/shared/components/hero-banner/hero-banner.component.scss @@ -164,6 +164,10 @@ } } +.event-schedule-date--canceled { + text-decoration: line-through; +} + @media (max-width: 767.98px) { .hero-banner-container { padding-bottom: 0; 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 index 6d11bbe..8d7fd88 100644 --- a/src/app/shared/components/hero-banner/hero-banner.component.spec.ts +++ b/src/app/shared/components/hero-banner/hero-banner.component.spec.ts @@ -22,9 +22,7 @@ describe('HeroBannerComponent', () => { expect(element.querySelector('img')?.getAttribute('src')).toBe( 'https://example.com/desktop.jpg', ); - expect(element.querySelector('.hero-banner')?.classList).toContain( - 'hero-banner--with-media', - ); + expect(element.querySelector('.hero-banner')?.classList).toContain('hero-banner--with-media'); }); it('keeps the fallback banner sizing when there is no image', async () => { @@ -52,12 +50,16 @@ describe('HeroBannerComponent', () => { date: '2026-10-09', start_time: '10:00:00', end_time: '20:30:00', + info_text: 'Esta fecha fue cancelada.', + isCanceled: true, }, { id: 2, date: '2026-10-10', start_time: '10: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.querySelector('.event-schedules')?.textContent).toContain('9 de Octubre 2026'); 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(); fixture.detectChanges(); 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 4859c54..7509445 100644 --- a/src/app/shared/components/hero-banner/hero-banner.component.ts +++ b/src/app/shared/components/hero-banner/hero-banner.component.ts @@ -3,11 +3,12 @@ import { CommonModule } from '@angular/common'; import { RouterModule } from '@angular/router'; import { EventConfig, HeroConfig } from '../../../core/services/tenant.interface'; import { ButtonComponent } from '../button/button.component'; +import { TooltipComponent } from '../tooltip/tooltip.component'; @Component({ selector: 'app-hero-banner', standalone: true, - imports: [CommonModule, RouterModule, ButtonComponent], + imports: [CommonModule, RouterModule, ButtonComponent, TooltipComponent], templateUrl: './hero-banner.component.html', styleUrl: './hero-banner.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/src/app/shared/components/tooltip/tooltip.component.html b/src/app/shared/components/tooltip/tooltip.component.html index 35788c8..f3c2475 100644 --- a/src/app/shared/components/tooltip/tooltip.component.html +++ b/src/app/shared/components/tooltip/tooltip.component.html @@ -3,9 +3,21 @@ class="tooltip-trigger" [attr.aria-describedby]="tooltipId" [attr.aria-label]="message()" + (mouseenter)="showTooltip($event)" + (mouseleave)="hideTooltip()" + (focus)="showTooltip($event)" + (blur)="hideTooltip()" > - + {{ message() }} diff --git a/src/app/shared/components/tooltip/tooltip.component.scss b/src/app/shared/components/tooltip/tooltip.component.scss index c195b68..e77d912 100644 --- a/src/app/shared/components/tooltip/tooltip.component.scss +++ b/src/app/shared/components/tooltip/tooltip.component.scss @@ -22,22 +22,15 @@ outline: 2px solid currentColor; outline-offset: 2px; } - - &:hover .tooltip-message, - &:focus-visible .tooltip-message { - visibility: visible; - opacity: 1; - transform: translate(-50%, -0.25rem); - } } .tooltip-message { - position: absolute; - z-index: 1100; - bottom: calc(100% + 0.625rem); - left: 50%; + position: fixed; + inset: auto; + z-index: 2000; width: max-content; max-width: min(16rem, 75vw); + margin: 0; padding: 0.5rem 0.75rem; border-radius: 0.375rem; border: 1px solid var(--tenant-primary, var(--color-primary, #0d6efd)); @@ -54,9 +47,13 @@ visibility: hidden; opacity: 0; pointer-events: none; - transform: translate(-50%, 0); + transform: translateX(-50%); transition: opacity 0.15s ease, - transform 0.15s ease, visibility 0.15s ease; } + +.tooltip-message--visible { + visibility: visible; + opacity: 1; +} diff --git a/src/app/shared/components/tooltip/tooltip.component.spec.ts b/src/app/shared/components/tooltip/tooltip.component.spec.ts index 1702ac7..c78b05a 100644 --- a/src/app/shared/components/tooltip/tooltip.component.spec.ts +++ b/src/app/shared/components/tooltip/tooltip.component.spec.ts @@ -28,5 +28,15 @@ describe('TooltipComponent', () => { expect(trigger?.querySelector('i.fa-solid.fa-circle-info')).not.toBeNull(); expect(message?.textContent?.trim()).toBe('Este producto no tiene stock disponible.'); 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'); }); }); diff --git a/src/app/shared/components/tooltip/tooltip.component.ts b/src/app/shared/components/tooltip/tooltip.component.ts index 9f70c23..68786f8 100644 --- a/src/app/shared/components/tooltip/tooltip.component.ts +++ b/src/app/shared/components/tooltip/tooltip.component.ts @@ -11,4 +11,40 @@ let nextTooltipId = 0; export class TooltipComponent { readonly message = input.required(); 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('.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; + } }