Merge pull request 'homologacion' (#7) from homologacion into main
Reviewed-on: #7
This commit is contained in:
commit
7247e8e694
|
|
@ -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<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 +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<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");
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
void this.router.navigate(['/buscar'], {
|
||||
queryParams: { q: term, page: 1 },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -122,7 +137,7 @@ describe('ModalService', () => {
|
|||
it('opens the standard confirm modal with default labels', () => {
|
||||
const result$ = service.openConfirm({
|
||||
title: 'Confirmar compra',
|
||||
content: 'Esto confirmara la compra actual.',
|
||||
description: 'Esto confirmara la compra actual.',
|
||||
});
|
||||
|
||||
const activeModal = service.activeModal();
|
||||
|
|
@ -130,9 +145,9 @@ describe('ModalService', () => {
|
|||
expect(activeModal?.component).toBe(ConfirmModalComponent);
|
||||
expect(result$).toBeDefined();
|
||||
expect(activeModal?.config).toEqual({
|
||||
title: 'Confirmar compra',
|
||||
data: {
|
||||
content: 'Esto confirmara la compra actual.',
|
||||
title: 'Confirmar compra',
|
||||
description: 'Esto confirmara la compra actual.',
|
||||
confirmLabel: 'Confirmar',
|
||||
cancelLabel: 'Cancelar',
|
||||
},
|
||||
|
|
@ -146,7 +161,7 @@ describe('ModalService', () => {
|
|||
it('maps the confirm modal close result to true', async () => {
|
||||
const result$ = service.openConfirm({
|
||||
title: 'Confirmar compra',
|
||||
content: 'Esto confirmara la compra actual.',
|
||||
description: 'Esto confirmara la compra actual.',
|
||||
});
|
||||
const activeModal = service.activeModal();
|
||||
const resultPromise = firstValueFrom(result$);
|
||||
|
|
@ -159,7 +174,7 @@ describe('ModalService', () => {
|
|||
it('maps dismissing a confirm modal to false', async () => {
|
||||
const result$ = service.openConfirmDelete({
|
||||
title: 'Eliminar producto',
|
||||
content: 'Se eliminara el producto.',
|
||||
description: 'Se eliminara el producto.',
|
||||
});
|
||||
const activeModal = service.activeModal();
|
||||
const resultPromise = firstValueFrom(result$);
|
||||
|
|
@ -174,7 +189,7 @@ describe('ModalService', () => {
|
|||
it('opens the delete confirm modal preserving modal overrides', () => {
|
||||
service.openConfirmDelete({
|
||||
title: 'Eliminar producto',
|
||||
content: 'Se eliminara el producto.',
|
||||
description: 'Se eliminara el producto.',
|
||||
confirmLabel: 'Eliminar',
|
||||
cancelLabel: 'Conservar',
|
||||
size: 'lg',
|
||||
|
|
@ -187,9 +202,9 @@ describe('ModalService', () => {
|
|||
|
||||
expect(activeModal?.component).toBe(ConfirmDeleteModalComponent);
|
||||
expect(activeModal?.config).toEqual({
|
||||
title: 'Eliminar producto',
|
||||
data: {
|
||||
content: 'Se eliminara el producto.',
|
||||
title: 'Eliminar producto',
|
||||
description: 'Se eliminara el producto.',
|
||||
confirmLabel: 'Eliminar',
|
||||
cancelLabel: 'Conservar',
|
||||
},
|
||||
|
|
@ -203,16 +218,16 @@ describe('ModalService', () => {
|
|||
it('opens the simple modal with default button label', () => {
|
||||
service.openSimple({
|
||||
title: 'Aviso',
|
||||
content: 'Este es un aviso simple.',
|
||||
description: 'Este es un aviso simple.',
|
||||
});
|
||||
|
||||
const activeModal = service.activeModal();
|
||||
|
||||
expect(activeModal?.component).toBe(SimpleModalComponent);
|
||||
expect(activeModal?.config).toEqual({
|
||||
title: 'Aviso',
|
||||
data: {
|
||||
content: 'Este es un aviso simple.',
|
||||
title: 'Aviso',
|
||||
description: 'Este es un aviso simple.',
|
||||
buttonLabel: 'Entendido',
|
||||
},
|
||||
size: 'md',
|
||||
|
|
@ -225,7 +240,7 @@ describe('ModalService', () => {
|
|||
it('maps the simple modal close result to undefined', async () => {
|
||||
const result$ = service.openSimple({
|
||||
title: 'Aviso',
|
||||
content: 'Este es un aviso simple.',
|
||||
description: 'Este es un aviso simple.',
|
||||
});
|
||||
const activeModal = service.activeModal();
|
||||
const resultPromise = firstValueFrom(result$);
|
||||
|
|
|
|||
|
|
@ -31,24 +31,28 @@ export interface NormalizedModalConfig<TData = unknown> extends Omit<
|
|||
}
|
||||
|
||||
export interface ConfirmModalData {
|
||||
content: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
confirmLabel: string;
|
||||
cancelLabel: string;
|
||||
}
|
||||
|
||||
export interface ConfirmModalConfig extends Omit<ModalConfig<ConfirmModalData>, 'data'> {
|
||||
content: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
}
|
||||
|
||||
export interface SimpleModalData {
|
||||
content: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
buttonLabel: string;
|
||||
}
|
||||
|
||||
export interface SimpleModalConfig extends Omit<ModalConfig<SimpleModalData>, 'data'> {
|
||||
content: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
buttonLabel?: string;
|
||||
}
|
||||
|
||||
|
|
@ -249,8 +253,8 @@ export class ModalService {
|
|||
return;
|
||||
}
|
||||
|
||||
ref.finalize(result);
|
||||
this.activeModalState.set(null);
|
||||
ref.finalize(result);
|
||||
}
|
||||
|
||||
private dismiss<TResult>(
|
||||
|
|
@ -261,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> {
|
||||
|
|
@ -274,7 +278,8 @@ export class ModalService {
|
|||
|
||||
private buildConfirmModalConfig(config: ConfirmModalConfig): ModalConfig<ConfirmModalData> {
|
||||
const {
|
||||
content,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = DEFAULT_CONFIRM_MODAL_LABELS.confirmLabel,
|
||||
cancelLabel = DEFAULT_CONFIRM_MODAL_LABELS.cancelLabel,
|
||||
...modalConfig
|
||||
|
|
@ -283,7 +288,8 @@ export class ModalService {
|
|||
return {
|
||||
...modalConfig,
|
||||
data: {
|
||||
content,
|
||||
title,
|
||||
description,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
},
|
||||
|
|
@ -291,12 +297,13 @@ export class ModalService {
|
|||
}
|
||||
|
||||
private buildSimpleModalConfig(config: SimpleModalConfig): ModalConfig<SimpleModalData> {
|
||||
const { content, buttonLabel = 'Entendido', ...modalConfig } = config;
|
||||
const { title, description, buttonLabel = 'Entendido', ...modalConfig } = config;
|
||||
|
||||
return {
|
||||
...modalConfig,
|
||||
data: {
|
||||
content,
|
||||
title,
|
||||
description,
|
||||
buttonLabel,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -299,12 +299,12 @@ describe('ReutilizablesTestPageComponent', () => {
|
|||
|
||||
expect(modalServiceStub.openConfirm).toHaveBeenCalledWith({
|
||||
title: 'Confirmar accion',
|
||||
content: 'Caso base para verificar apertura, cierre y devolucion de resultado.',
|
||||
description: 'Caso base para verificar apertura, cierre y devolucion de resultado.',
|
||||
confirmLabel: 'Confirmar',
|
||||
});
|
||||
expect(modalServiceStub.openConfirmDelete).toHaveBeenCalledWith({
|
||||
title: 'Eliminar producto',
|
||||
content:
|
||||
description:
|
||||
'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.',
|
||||
confirmLabel: 'Eliminar',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -448,7 +448,7 @@ export class ReutilizablesTestPageComponent {
|
|||
protected openBasicModal(): void {
|
||||
this.openConfirmModal({
|
||||
title: 'Confirmar accion',
|
||||
content: 'Caso base para verificar apertura, cierre y devolucion de resultado.',
|
||||
description: 'Caso base para verificar apertura, cierre y devolucion de resultado.',
|
||||
confirmLabel: 'Confirmar',
|
||||
});
|
||||
}
|
||||
|
|
@ -456,7 +456,7 @@ export class ReutilizablesTestPageComponent {
|
|||
protected openConfirmDeleteModal(): void {
|
||||
this.openConfirmDelete({
|
||||
title: 'Eliminar producto',
|
||||
content:
|
||||
description:
|
||||
'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.',
|
||||
confirmLabel: 'Eliminar',
|
||||
});
|
||||
|
|
@ -465,7 +465,7 @@ export class ReutilizablesTestPageComponent {
|
|||
protected openLockedModal(): void {
|
||||
this.openConfirmModal({
|
||||
title: 'Modal bloqueado',
|
||||
content: 'Este modal no se cierra tocando el backdrop ni con la tecla Escape.',
|
||||
description: 'Este modal no se cierra tocando el backdrop ni con la tecla Escape.',
|
||||
confirmLabel: 'Entendido',
|
||||
closeOnBackdrop: false,
|
||||
closeOnEscape: false,
|
||||
|
|
@ -475,7 +475,7 @@ export class ReutilizablesTestPageComponent {
|
|||
protected openWideModal(): void {
|
||||
this.openConfirmModal({
|
||||
title: 'Modal ancho',
|
||||
content: 'Demuestra una variante visual mas amplia para contenido mas pesado.',
|
||||
description: 'Demuestra una variante visual mas amplia para contenido mas pesado.',
|
||||
confirmLabel: 'Seguir',
|
||||
size: 'xl',
|
||||
});
|
||||
|
|
@ -485,7 +485,7 @@ export class ReutilizablesTestPageComponent {
|
|||
this.modalService
|
||||
.openSimple({
|
||||
title: 'Mensaje del sistema',
|
||||
content: 'Este es un mensaje simple del sistema que no requiere confirmación.',
|
||||
description: 'Este es un mensaje simple del sistema que no requiere confirmación.',
|
||||
buttonLabel: 'Entendido',
|
||||
})
|
||||
.subscribe(() => {
|
||||
|
|
|
|||
|
|
@ -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 ?? []);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ describe('RegisterPageComponent', () => {
|
|||
password_confirmation: 'Secret!123'
|
||||
});
|
||||
expect(modalService.openSimple).toHaveBeenCalledWith({
|
||||
content: 'Tu cuenta fue creada correctamente',
|
||||
title: 'Tu cuenta fue creada correctamente',
|
||||
buttonLabel: 'Cerrar'
|
||||
});
|
||||
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ export class RegisterPageComponent {
|
|||
next: () => {
|
||||
this.isSubmittingState.set(false);
|
||||
this.modalService.openSimple({
|
||||
content: 'Tu cuenta fue creada correctamente',
|
||||
title: 'Tu cuenta fue creada correctamente',
|
||||
buttonLabel: 'Cerrar'
|
||||
}).subscribe(() => {
|
||||
void this.router.navigate(['/login']);
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ describe('ResetPasswordPageComponent', () => {
|
|||
password_confirmation: 'Secret!123',
|
||||
});
|
||||
expect(modalService.openSimple).toHaveBeenCalledWith({
|
||||
content: 'Contraseña modificada correctamente',
|
||||
title: 'Contraseña modificada correctamente',
|
||||
buttonLabel: 'Cerrar',
|
||||
});
|
||||
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ export class ResetPasswordPageComponent {
|
|||
|
||||
this.modalService
|
||||
.openSimple({
|
||||
content: 'Contraseña modificada correctamente',
|
||||
title: 'Contraseña modificada correctamente',
|
||||
buttonLabel: 'Cerrar',
|
||||
})
|
||||
.subscribe(() => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
<div class="d-grid cart-item-attributes">
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
@ -115,7 +117,7 @@ describe('CartComponent', () => {
|
|||
|
||||
expect(openConfirmDelete).toHaveBeenCalledWith({
|
||||
title: 'Eliminar producto',
|
||||
content: 'Se eliminará “Producto de prueba” del carrito. Esta acción no se puede deshacer.',
|
||||
description: 'Se eliminará “Producto de prueba” del carrito. Esta acción no se puede deshacer.',
|
||||
confirmLabel: 'Eliminar',
|
||||
cancelLabel: 'Cancelar',
|
||||
});
|
||||
|
|
@ -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<unknown>();
|
||||
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({
|
||||
|
|
|
|||
|
|
@ -236,7 +236,7 @@ export class CartComponent {
|
|||
this.modalService
|
||||
.openConfirmDelete({
|
||||
title: 'Eliminar producto',
|
||||
content: `Se eliminará “${target.productName}” del carrito. Esta acción no se puede deshacer.`,
|
||||
description: `Se eliminará “${target.productName}” del carrito. Esta acción no se puede deshacer.`,
|
||||
confirmLabel: 'Eliminar',
|
||||
cancelLabel: 'Cancelar',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
<div class="confirm-modal">
|
||||
<p class="confirm-modal__content">{{ data.content }}</p>
|
||||
<h2 class="confirm-modal__content">{{ data.title }}</h2>
|
||||
@if (data.description) {
|
||||
<p class="confirm-modal__content">{{ data.description }}</p>
|
||||
}
|
||||
|
||||
<div class="confirm-modal__actions">
|
||||
<app-button variant="danger-secondary" (click)="cancel()">
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { ConfirmDeleteModalComponent } from './confirm-delete-modal.component';
|
|||
|
||||
describe('ConfirmDeleteModalComponent', () => {
|
||||
const data: ConfirmModalData = {
|
||||
content: 'Se eliminara el elemento seleccionado.',
|
||||
title: 'Se eliminara el elemento seleccionado.',
|
||||
confirmLabel: 'Eliminar',
|
||||
cancelLabel: 'Cancelar'
|
||||
};
|
||||
|
|
@ -58,7 +58,7 @@ describe('ConfirmDeleteModalComponent', () => {
|
|||
const element = fixture.nativeElement as HTMLElement;
|
||||
const buttons = element.querySelectorAll('button');
|
||||
|
||||
expect(element.textContent).toContain(data.content);
|
||||
expect(element.textContent).toContain(data.title);
|
||||
expect(element.textContent).toContain(data.confirmLabel);
|
||||
expect(buttons[1].className).toContain('btn-danger');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
<div class="confirm-modal">
|
||||
<p class="confirm-modal__content">{{ data.content }}</p>
|
||||
<h2 class="confirm-modal__content">{{ data.title }}</h2>
|
||||
@if (data.description) {
|
||||
<p class="confirm-modal__content">{{ data.description }}</p>
|
||||
}
|
||||
|
||||
<div class="confirm-modal__actions">
|
||||
<app-button variant="secondary" (click)="cancel()">
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { ConfirmModalComponent } from './confirm-modal.component';
|
|||
|
||||
describe('ConfirmModalComponent', () => {
|
||||
const data: ConfirmModalData = {
|
||||
content: 'Se confirmara la operacion seleccionada.',
|
||||
title: 'Se confirmara la operacion seleccionada.',
|
||||
confirmLabel: 'Aceptar',
|
||||
cancelLabel: 'Volver'
|
||||
};
|
||||
|
|
@ -57,7 +57,7 @@ describe('ConfirmModalComponent', () => {
|
|||
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
|
||||
expect(element.textContent).toContain(data.content);
|
||||
expect(element.textContent).toContain(data.title);
|
||||
expect(element.textContent).toContain(data.confirmLabel);
|
||||
expect(element.textContent).toContain(data.cancelLabel);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -88,7 +88,12 @@
|
|||
<td>
|
||||
<span class="event-schedule-value">
|
||||
<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>
|
||||
</td>
|
||||
<td>
|
||||
|
|
|
|||
|
|
@ -164,6 +164,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
.event-schedule-date--canceled {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.hero-banner-container {
|
||||
padding-bottom: 0;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ export class ProductTicketSelectorComponent {
|
|||
this.modalService
|
||||
.openConfirmDelete({
|
||||
title: 'Eliminar entrada',
|
||||
content: `Se eliminará esta entrada de “${this.title()}”. Si ya estaba reservada, se liberará del carrito.`,
|
||||
description: `Se eliminará esta entrada de “${this.title()}”. Si ya estaba reservada, se liberará del carrito.`,
|
||||
confirmLabel: 'Sí, eliminar',
|
||||
cancelLabel: 'Cancelar',
|
||||
size: 'md',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
<div class="simple-modal">
|
||||
<p class="simple-modal__content">{{ data.content }}</p>
|
||||
<h2 class="simple-modal__content">{{ data.title }}</h2>
|
||||
@if (data.description) {
|
||||
<p class="simple-modal__content">{{ data.description }}</p>
|
||||
}
|
||||
|
||||
<div class="simple-modal__actions">
|
||||
<app-button (click)="close()">
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { SimpleModalComponent } from './simple-modal.component';
|
|||
|
||||
describe('SimpleModalComponent', () => {
|
||||
const data: SimpleModalData = {
|
||||
content: 'Este es un mensaje simple.',
|
||||
title: 'Este es un mensaje simple.',
|
||||
buttonLabel: 'Entendido'
|
||||
};
|
||||
|
||||
|
|
@ -56,7 +56,7 @@ describe('SimpleModalComponent', () => {
|
|||
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
|
||||
expect(element.textContent).toContain(data.content);
|
||||
expect(element.textContent).toContain(data.title);
|
||||
expect(element.textContent).toContain(data.buttonLabel);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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()"
|
||||
>
|
||||
<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() }}
|
||||
</span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,4 +11,40 @@ let nextTooltipId = 0;
|
|||
export class TooltipComponent {
|
||||
readonly message = input.required<string>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue