Compare commits
21 Commits
fix/zoom_b
...
a7e1d8995f
| Author | SHA1 | Date | |
|---|---|---|---|
| a7e1d8995f | |||
| b5fae81795 | |||
| 8664c86915 | |||
| 9e72ec0c60 | |||
| e9aae7a4c3 | |||
| 6545be498f | |||
| 8c15ad7cc1 | |||
| f859eba8a8 | |||
| 8ba34c6c7a | |||
| d7c015e71e | |||
| d8d608c435 | |||
| 985316d3b4 | |||
| d601cc1984 | |||
| 70d47ac310 | |||
| 5d1d7086c7 | |||
| 9a6ec3d1f2 | |||
| 38ba5f4195 | |||
| 71c462aaba | |||
| 481eb30795 | |||
| afebc7f639 | |||
| 520397baa5 |
@@ -1,14 +1,12 @@
|
||||
import '@angular/compiler';
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed, getTestBed } from '@angular/core/testing';
|
||||
import {
|
||||
BrowserTestingModule,
|
||||
platformBrowserTesting
|
||||
} from '@angular/platform-browser/testing';
|
||||
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { App } from './app';
|
||||
import { EventDateNoticeService } from './core/services/event-date-notice.service';
|
||||
import { Tenant } from './core/services/tenant.interface';
|
||||
import { TenantService } from './core/services/tenant.service';
|
||||
|
||||
@@ -27,7 +25,7 @@ const tenant: Tenant = {
|
||||
footer_bg_color: '#313131',
|
||||
header_logo: 'https://example.com/header.png',
|
||||
footer_logo: 'https://example.com/footer.png',
|
||||
categories: []
|
||||
categories: [],
|
||||
};
|
||||
|
||||
function createTenantServiceStub(status: 'ready' | 'not-found', currentTenant: Tenant | null) {
|
||||
@@ -38,17 +36,14 @@ function createTenantServiceStub(status: 'ready' | 'not-found', currentTenant: T
|
||||
tenant: tenantState.asReadonly(),
|
||||
status: statusState.asReadonly(),
|
||||
getTenant: () => tenantState(),
|
||||
bootstrap: vi.fn().mockResolvedValue(undefined)
|
||||
bootstrap: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe('App', () => {
|
||||
beforeAll(() => {
|
||||
try {
|
||||
getTestBed().initTestEnvironment(
|
||||
BrowserTestingModule,
|
||||
platformBrowserTesting()
|
||||
);
|
||||
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
|
||||
} catch {
|
||||
// Test environment may already be initialized by another setup entrypoint.
|
||||
}
|
||||
@@ -65,9 +60,9 @@ describe('App', () => {
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: TenantService,
|
||||
useValue: createTenantServiceStub('ready', tenant)
|
||||
}
|
||||
]
|
||||
useValue: createTenantServiceStub('ready', tenant),
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(App);
|
||||
@@ -75,26 +70,27 @@ describe('App', () => {
|
||||
|
||||
expect(fixture.componentInstance).toBeTruthy();
|
||||
expect(fixture.nativeElement.style.getPropertyValue('--tenant-primary')).toBe(
|
||||
tenant.primary_color
|
||||
tenant.primary_color,
|
||||
);
|
||||
expect(fixture.nativeElement.style.getPropertyValue('--tenant-secondary')).toBe(
|
||||
tenant.secondary_color
|
||||
tenant.secondary_color,
|
||||
);
|
||||
expect(fixture.nativeElement.style.getPropertyValue('--tenant-danger')).toBe(
|
||||
tenant.danger_color
|
||||
tenant.danger_color,
|
||||
);
|
||||
expect(fixture.nativeElement.style.getPropertyValue('--tenant-primary-rgb')).toBe(
|
||||
'99, 118, 243'
|
||||
'99, 118, 243',
|
||||
);
|
||||
expect(fixture.nativeElement.style.getPropertyValue('--tenant-secondary-rgb')).toBe(
|
||||
'160, 160, 160'
|
||||
'160, 160, 160',
|
||||
);
|
||||
expect(fixture.nativeElement.style.getPropertyValue('--tenant-danger-rgb')).toBe(
|
||||
'255, 136, 136'
|
||||
'255, 136, 136',
|
||||
);
|
||||
expect(document.title).toBe(tenant.site_title);
|
||||
expect(document.head.querySelector<HTMLLinkElement>('link[rel~="icon"]')?.getAttribute('href'))
|
||||
.toBe(tenant.favicon);
|
||||
expect(
|
||||
document.head.querySelector<HTMLLinkElement>('link[rel~="icon"]')?.getAttribute('href'),
|
||||
).toBe(tenant.favicon);
|
||||
});
|
||||
|
||||
it('renders the tenant not found screen when the tenant is missing', async () => {
|
||||
@@ -104,17 +100,65 @@ describe('App', () => {
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: TenantService,
|
||||
useValue: createTenantServiceStub('not-found', null)
|
||||
}
|
||||
]
|
||||
useValue: createTenantServiceStub('not-found', null),
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('No encontramos una tienda para este dominio');
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'No encontramos una tienda para este dominio',
|
||||
);
|
||||
expect(document.title).toBe('ShopitFront');
|
||||
expect(document.head.querySelector<HTMLLinkElement>('link[rel~="icon"]')?.getAttribute('href'))
|
||||
.toBe("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E");
|
||||
expect(
|
||||
document.head.querySelector<HTMLLinkElement>('link[rel~="icon"]')?.getAttribute('href'),
|
||||
).toBe("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E");
|
||||
});
|
||||
|
||||
it('shows the date notices returned by the tenant bootstrap once', async () => {
|
||||
const show = vi.fn();
|
||||
const dateNotices = [
|
||||
{
|
||||
type: 'suspended' as const,
|
||||
title: 'FECHA CANCELADA!',
|
||||
message: [
|
||||
{ text: 'La fecha del ', bold: false },
|
||||
{ text: '09 de Octubre de 2026', bold: true },
|
||||
{ text: ' ha sido cancelada.', bold: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: TenantService,
|
||||
useValue: createTenantServiceStub('ready', {
|
||||
...tenant,
|
||||
event: {
|
||||
title: 'Festival',
|
||||
location: 'Predio',
|
||||
dates: [],
|
||||
date_notices: dateNotices,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
provide: EventDateNoticeService,
|
||||
useValue: { show },
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(show).toHaveBeenCalledOnce();
|
||||
expect(show).toHaveBeenCalledWith(dateNotices);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Component, computed, effect, inject } from '@angular/core';
|
||||
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
|
||||
import { Component, PLATFORM_ID, computed, effect, inject } from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
import { EventDateNoticeService } from './core/services/event-date-notice.service';
|
||||
import { TenantService } from './core/services/tenant.service';
|
||||
import { DEFAULT_TENANT_BRANDING } from './core/services/tenant-ssr-cache.store';
|
||||
import { GlobalLoadingComponent } from './shared/components/global-loading/global-loading.component';
|
||||
@@ -13,7 +14,9 @@ const EMPTY_FAVICON = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/s
|
||||
|
||||
function hexToRgb(hex: string): string {
|
||||
const cleanHex = hex.replace('#', '').trim();
|
||||
let r = 0, g = 0, b = 0;
|
||||
let r = 0,
|
||||
g = 0,
|
||||
b = 0;
|
||||
if (cleanHex.length === 3) {
|
||||
r = parseInt(cleanHex[0] + cleanHex[0], 16);
|
||||
g = parseInt(cleanHex[1] + cleanHex[1], 16);
|
||||
@@ -28,12 +31,7 @@ function hexToRgb(hex: string): string {
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [
|
||||
RouterOutlet,
|
||||
ToastContainerComponent,
|
||||
ModalHostComponent,
|
||||
GlobalLoadingComponent
|
||||
],
|
||||
imports: [RouterOutlet, ToastContainerComponent, ModalHostComponent, GlobalLoadingComponent],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss',
|
||||
host: {
|
||||
@@ -56,13 +54,16 @@ function hexToRgb(hex: string): string {
|
||||
'[style.--tenant-header-bg]': 'tenantHeaderBgColor()',
|
||||
'[style.--tenant-footer-bg]': 'tenantFooterBgColor()',
|
||||
'[style.--color-header-bg]': 'tenantHeaderBgColor()',
|
||||
'[style.--color-footer-bg]': 'tenantFooterBgColor()'
|
||||
}
|
||||
'[style.--color-footer-bg]': 'tenantFooterBgColor()',
|
||||
},
|
||||
})
|
||||
export class App {
|
||||
private readonly tenantService = inject(TenantService);
|
||||
private readonly eventDateNoticeService = inject(EventDateNoticeService);
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly title = inject(Title);
|
||||
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
||||
private notifiedTenantCode: string | null = null;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
@@ -81,43 +82,38 @@ export class App {
|
||||
}
|
||||
|
||||
favicon.setAttribute('href', faviconHref);
|
||||
|
||||
if (this.isBrowser && tenant && this.notifiedTenantCode !== tenant.codigo) {
|
||||
this.notifiedTenantCode = tenant.codigo;
|
||||
this.eventDateNoticeService.show(tenant.event?.date_notices ?? []);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected readonly status = this.tenantService.status;
|
||||
|
||||
protected readonly tenantPrimaryColor = computed(
|
||||
() => this.tenantService.tenant()?.primary_color ?? DEFAULT_TENANT_BRANDING.primaryColor
|
||||
() => this.tenantService.tenant()?.primary_color ?? DEFAULT_TENANT_BRANDING.primaryColor,
|
||||
);
|
||||
protected readonly tenantSecondaryColor = computed(
|
||||
() => this.tenantService.tenant()?.secondary_color ?? DEFAULT_TENANT_BRANDING.secondaryColor
|
||||
() => this.tenantService.tenant()?.secondary_color ?? DEFAULT_TENANT_BRANDING.secondaryColor,
|
||||
);
|
||||
protected readonly tenantDangerColor = computed(
|
||||
() => this.tenantService.tenant()?.danger_color ?? DEFAULT_TENANT_BRANDING.dangerColor
|
||||
() => this.tenantService.tenant()?.danger_color ?? DEFAULT_TENANT_BRANDING.dangerColor,
|
||||
);
|
||||
protected readonly tenantSuccessColor = computed(
|
||||
() => this.tenantService.tenant()?.success_color ?? DEFAULT_TENANT_BRANDING.successColor
|
||||
);
|
||||
protected readonly tenantPrimaryColorRgb = computed(() =>
|
||||
hexToRgb(this.tenantPrimaryColor())
|
||||
() => this.tenantService.tenant()?.success_color ?? DEFAULT_TENANT_BRANDING.successColor,
|
||||
);
|
||||
protected readonly tenantPrimaryColorRgb = computed(() => hexToRgb(this.tenantPrimaryColor()));
|
||||
protected readonly tenantSecondaryColorRgb = computed(() =>
|
||||
hexToRgb(this.tenantSecondaryColor())
|
||||
);
|
||||
protected readonly tenantDangerColorRgb = computed(() =>
|
||||
hexToRgb(this.tenantDangerColor())
|
||||
);
|
||||
protected readonly tenantSuccessColorRgb = computed(() =>
|
||||
hexToRgb(this.tenantSuccessColor())
|
||||
hexToRgb(this.tenantSecondaryColor()),
|
||||
);
|
||||
protected readonly tenantDangerColorRgb = computed(() => hexToRgb(this.tenantDangerColor()));
|
||||
protected readonly tenantSuccessColorRgb = computed(() => hexToRgb(this.tenantSuccessColor()));
|
||||
protected readonly tenantHeaderBgColor = computed(
|
||||
() =>
|
||||
this.tenantService.tenant()?.header_bg_color ??
|
||||
DEFAULT_TENANT_BRANDING.headerBgColor
|
||||
() => this.tenantService.tenant()?.header_bg_color ?? DEFAULT_TENANT_BRANDING.headerBgColor,
|
||||
);
|
||||
protected readonly tenantFooterBgColor = computed(
|
||||
() =>
|
||||
this.tenantService.tenant()?.footer_bg_color ??
|
||||
DEFAULT_TENANT_BRANDING.footerBgColor
|
||||
() => this.tenantService.tenant()?.footer_bg_color ?? DEFAULT_TENANT_BRANDING.footerBgColor,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
UrlSerializer,
|
||||
} from '@angular/router';
|
||||
|
||||
import { BehaviorSubject, of } from 'rxjs';
|
||||
import { BehaviorSubject, of, throwError } from 'rxjs';
|
||||
|
||||
import { Tenant } from '../../services/tenant.interface';
|
||||
import { TenantService } from '../../services/tenant.service';
|
||||
@@ -211,7 +211,7 @@ describe('StoreLayoutComponent', () => {
|
||||
useValue: {
|
||||
user: authUserState,
|
||||
isAuthenticated: isAuthenticatedState,
|
||||
logout: vi.fn().mockReturnValue(of(void 0)),
|
||||
logout: vi.fn().mockReturnValue(of({ message: 'Sesión cerrada correctamente.' })),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -574,6 +574,24 @@ describe('StoreLayoutComponent', () => {
|
||||
expect(authService.logout).toHaveBeenCalled();
|
||||
expect(cartService.clearCart).toHaveBeenCalled();
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/']);
|
||||
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 } })),
|
||||
);
|
||||
|
||||
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
await (fixture.componentInstance as any).onLogoutClick();
|
||||
|
||||
expect(TestBed.inject(ToastService).danger).toHaveBeenCalledWith(message);
|
||||
});
|
||||
|
||||
it('provides account actions from the footer', () => {
|
||||
|
||||
@@ -236,23 +236,39 @@ export class StoreLayoutComponent implements OnInit {
|
||||
const navigationSucceeded = await this.router.navigate(['/']);
|
||||
|
||||
if (!navigationSucceeded) {
|
||||
this.toastService.danger('No se pudo cerrar la sesión. Intentá nuevamente.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.authService.logout().subscribe({
|
||||
next: () => {
|
||||
next: ({ message }) => {
|
||||
this.cartService.clearCart();
|
||||
this.isCartOpen.set(false);
|
||||
this.toastService.info(message || 'Sesión cerrada correctamente.');
|
||||
|
||||
if (!isLeavingCheckout) {
|
||||
void this.router.navigate(['/']);
|
||||
}
|
||||
},
|
||||
error: (err) => console.error('Error logging out', err),
|
||||
error: (error: unknown) => {
|
||||
this.toastService.danger(this.resolveLogoutErrorMessage(error));
|
||||
console.error('Error logging out', error);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private resolveLogoutErrorMessage(error: unknown): string {
|
||||
const payload =
|
||||
typeof error === 'object' && error !== null && 'error' in error
|
||||
? (error as { error?: { message?: unknown } }).error
|
||||
: undefined;
|
||||
|
||||
return typeof payload?.message === 'string' && payload.message.trim()
|
||||
? payload.message
|
||||
: 'No se pudo cerrar la sesión. Intentá nuevamente.';
|
||||
}
|
||||
|
||||
protected async onCheckoutClick(): Promise<void> {
|
||||
if (this.isCreatingPurchase()) {
|
||||
return;
|
||||
|
||||
@@ -33,6 +33,10 @@ export interface LoginResponse {
|
||||
user: AuthUser;
|
||||
}
|
||||
|
||||
export interface LogoutResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RegisterResponse {
|
||||
message: string;
|
||||
data: AuthUser;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
AuthUser,
|
||||
LoginPayload,
|
||||
LoginResponse,
|
||||
LogoutResponse,
|
||||
RegisterPayload,
|
||||
RegisterResponse,
|
||||
ResetPasswordPayload,
|
||||
@@ -155,14 +156,14 @@ export class AuthService extends BaseApiService {
|
||||
.pipe(tap((user) => this.userState.set(user)));
|
||||
}
|
||||
|
||||
logout(): Observable<void> {
|
||||
logout(): Observable<LogoutResponse> {
|
||||
if (!this.tokenState()) {
|
||||
this.clearSession();
|
||||
return of(void 0);
|
||||
return of({ message: 'Sesión cerrada correctamente.' });
|
||||
}
|
||||
|
||||
return this.http
|
||||
.post<void>(`${environment.url}logout`, {})
|
||||
.post<LogoutResponse>(`${environment.url}logout`, {})
|
||||
.pipe(tap(() => this.clearSession()));
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ export interface CatalogItemVariant {
|
||||
maximum_use_date?: string | null;
|
||||
effective_minimum_use_date?: string | null;
|
||||
effective_maximum_use_date?: string | null;
|
||||
values: Record<string, string | string[]>;
|
||||
values: Record<string, CatalogVariantValue>;
|
||||
}
|
||||
|
||||
export interface SelectedCatalogItemVariant extends CatalogItemVariant {
|
||||
|
||||
85
src/app/core/services/event-date-notice.service.spec.ts
Normal file
85
src/app/core/services/event-date-notice.service.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import '@angular/compiler';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { getTestBed } from '@angular/core/testing';
|
||||
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
|
||||
import { Subject } from 'rxjs';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { EventDateNoticeModalComponent } from '../../shared/components/event-date-notice-modal/event-date-notice-modal.component';
|
||||
import { EventDateNoticeService } from './event-date-notice.service';
|
||||
import { ModalService } from './modal.service';
|
||||
import { TenantEventDateNotice } from './tenant.interface';
|
||||
|
||||
describe('EventDateNoticeService', () => {
|
||||
let service: EventDateNoticeService;
|
||||
let open: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeAll(() => {
|
||||
try {
|
||||
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
|
||||
} catch {
|
||||
// Test environment may already be initialized by another setup entrypoint.
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
open = vi.fn();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
EventDateNoticeService,
|
||||
{
|
||||
provide: ModalService,
|
||||
useValue: { open },
|
||||
},
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(EventDateNoticeService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
});
|
||||
|
||||
it('shows backend notices sequentially', () => {
|
||||
const firstClosed = new Subject<void | undefined>();
|
||||
const secondClosed = new Subject<void | undefined>();
|
||||
const notices: TenantEventDateNotice[] = [
|
||||
{
|
||||
type: 'suspended',
|
||||
title: 'FECHA CANCELADA!',
|
||||
message: [{ text: 'La fecha fue cancelada.', bold: false }],
|
||||
},
|
||||
{
|
||||
type: 'rescheduled',
|
||||
title: 'FECHA REPROGRAMADA!',
|
||||
message: [{ text: 'La fecha fue reprogramada.', bold: false }],
|
||||
},
|
||||
];
|
||||
|
||||
open
|
||||
.mockReturnValueOnce({ afterClosed$: firstClosed.asObservable() })
|
||||
.mockReturnValueOnce({ afterClosed$: secondClosed.asObservable() });
|
||||
|
||||
service.show(notices);
|
||||
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
expect(open).toHaveBeenNthCalledWith(1, EventDateNoticeModalComponent, {
|
||||
size: 'sm',
|
||||
data: notices[0],
|
||||
});
|
||||
|
||||
firstClosed.next(undefined);
|
||||
|
||||
expect(open).toHaveBeenCalledTimes(2);
|
||||
expect(open).toHaveBeenNthCalledWith(2, EventDateNoticeModalComponent, {
|
||||
size: 'sm',
|
||||
data: notices[1],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not open a modal without notices', () => {
|
||||
service.show([]);
|
||||
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
32
src/app/core/services/event-date-notice.service.ts
Normal file
32
src/app/core/services/event-date-notice.service.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
|
||||
import { EventDateNoticeModalComponent } from '../../shared/components/event-date-notice-modal/event-date-notice-modal.component';
|
||||
import { ModalService } from './modal.service';
|
||||
import { TenantEventDateNotice } from './tenant.interface';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class EventDateNoticeService {
|
||||
private readonly modalService = inject(ModalService);
|
||||
|
||||
show(notices: readonly TenantEventDateNotice[]): void {
|
||||
this.showNext(notices, 0);
|
||||
}
|
||||
|
||||
private showNext(notices: readonly TenantEventDateNotice[], index: number): void {
|
||||
const notice = notices[index];
|
||||
|
||||
if (!notice) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.modalService
|
||||
.open<EventDateNoticeModalComponent, void, TenantEventDateNotice>(
|
||||
EventDateNoticeModalComponent,
|
||||
{
|
||||
size: 'sm',
|
||||
data: notice,
|
||||
},
|
||||
)
|
||||
.afterClosed$.subscribe(() => this.showNext(notices, index + 1));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -69,10 +69,34 @@ export interface ActiveEventDate {
|
||||
time_end: string;
|
||||
}
|
||||
|
||||
export type TenantEventDateChangeType = 'rescheduled' | 'suspended';
|
||||
|
||||
export interface TenantEventDateChange {
|
||||
type: TenantEventDateChangeType;
|
||||
source_event_date_id: number | null;
|
||||
destination_event_date_id: number | null;
|
||||
previous_date: string;
|
||||
new_date: string | null;
|
||||
occurred_at: string;
|
||||
}
|
||||
|
||||
export interface TenantEventDateNoticeMessagePart {
|
||||
text: string;
|
||||
bold: boolean;
|
||||
}
|
||||
|
||||
export interface TenantEventDateNotice {
|
||||
type: TenantEventDateChangeType;
|
||||
title: string;
|
||||
message: TenantEventDateNoticeMessagePart[];
|
||||
}
|
||||
|
||||
export interface TenantEvent {
|
||||
title: string;
|
||||
location: string;
|
||||
dates: ActiveEventDate[];
|
||||
date_changes?: TenantEventDateChange[];
|
||||
date_notices?: TenantEventDateNotice[];
|
||||
}
|
||||
|
||||
export interface WebsiteExtras {
|
||||
@@ -113,6 +137,7 @@ export interface Tenant {
|
||||
dominio: string;
|
||||
base_path?: string;
|
||||
site_title?: string | null;
|
||||
asset_url?: string | null;
|
||||
address?: string | null;
|
||||
phone?: string | null;
|
||||
favicon?: string | null;
|
||||
@@ -142,6 +167,10 @@ export interface Tenant {
|
||||
cart_editing_policy?: CartEditingPolicy;
|
||||
checkout_editing_policy?: CartEditingPolicy;
|
||||
display_cart_item_images?: boolean;
|
||||
allow_ticket_refund?: boolean;
|
||||
allow_ticket_total_refund?: boolean;
|
||||
allow_ticket_partial_refund?: boolean;
|
||||
ticket_partial_refund_percentage?: string;
|
||||
social_media?: SocialMedia[];
|
||||
menues?: Menu[];
|
||||
categories: Category[];
|
||||
|
||||
@@ -15,6 +15,7 @@ const tenant: Tenant = {
|
||||
codigo: 'test',
|
||||
nombre: 'Test Tenant',
|
||||
dominio: 'localhost',
|
||||
asset_url: 'https://s3.example.com/assets',
|
||||
primary_color: '#6376F3',
|
||||
secondary_color: '#A0A0A0',
|
||||
danger_color: '#FF8888',
|
||||
@@ -44,6 +45,7 @@ const tenantResponse: TenantBootstrapResponse = {
|
||||
|
||||
describe('TenantService', () => {
|
||||
beforeEach(() => {
|
||||
document.head.querySelectorAll('link[rel="preconnect"]').forEach((link) => link.remove());
|
||||
try {
|
||||
window.history.replaceState({}, '', 'http://localhost:4200/');
|
||||
} catch (e) {
|
||||
@@ -72,6 +74,9 @@ describe('TenantService', () => {
|
||||
expect(service.status()).toBe('ready');
|
||||
expect(service.tenant()).toEqual(tenant);
|
||||
expect(service.getTenant()).toEqual(tenant);
|
||||
expect(
|
||||
document.head.querySelector('link[rel="preconnect"][href="https://s3.example.com/"]'),
|
||||
).not.toBeNull();
|
||||
|
||||
httpController.verify();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
|
||||
import { DOCUMENT, isPlatformBrowser, isPlatformServer } from '@angular/common';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import {
|
||||
inject,
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class TenantService extends BaseApiService {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly platformId = inject(PLATFORM_ID);
|
||||
private readonly request = inject(REQUEST, { optional: true });
|
||||
private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
|
||||
@@ -182,10 +183,40 @@ export class TenantService extends BaseApiService {
|
||||
}
|
||||
|
||||
private setReady(tenant: Tenant): void {
|
||||
this.ensureAssetPreconnect(tenant.asset_url);
|
||||
this.tenantState.set(tenant);
|
||||
this.statusState.set('ready');
|
||||
}
|
||||
|
||||
private ensureAssetPreconnect(assetUrl: string | null | undefined): void {
|
||||
if (!assetUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
let origin: string;
|
||||
|
||||
try {
|
||||
const url = new URL(assetUrl);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return;
|
||||
}
|
||||
origin = url.origin;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingPreconnects =
|
||||
this.document.head.querySelectorAll<HTMLLinkElement>('link[rel="preconnect"]');
|
||||
if ([...existingPreconnects].some((link) => link.href === `${origin}/`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const link = this.document.createElement('link');
|
||||
link.rel = 'preconnect';
|
||||
link.href = origin;
|
||||
this.document.head.append(link);
|
||||
}
|
||||
|
||||
private setNotFound(): void {
|
||||
this.tenantState.set(null);
|
||||
this.statusState.set('not-found');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { vi } from 'vitest';
|
||||
import { ToastService } from './toast.service';
|
||||
@@ -6,14 +7,16 @@ describe('ToastService', () => {
|
||||
let service: ToastService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [ToastService]
|
||||
});
|
||||
TestBed.inject(DOCUMENT).defaultView?.sessionStorage.removeItem('shopit.pending-toast');
|
||||
service = TestBed.inject(ToastService);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
TestBed.inject(DOCUMENT).defaultView?.sessionStorage.removeItem('shopit.pending-toast');
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -98,4 +101,23 @@ describe('ToastService', () => {
|
||||
|
||||
expect(service.toasts().length).toBe(1);
|
||||
});
|
||||
|
||||
it('should restore a toast queued for after a full page reload', () => {
|
||||
service.showAfterReload('Sesión iniciada correctamente.', 'success', 0);
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({ providers: [ToastService] });
|
||||
service = TestBed.inject(ToastService);
|
||||
|
||||
expect(service.toasts()).toEqual([
|
||||
expect.objectContaining({
|
||||
message: 'Sesión iniciada correctamente.',
|
||||
type: 'success',
|
||||
duration: 0,
|
||||
}),
|
||||
]);
|
||||
expect(
|
||||
TestBed.inject(DOCUMENT).defaultView?.sessionStorage.getItem('shopit.pending-toast'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
|
||||
import { inject, Injectable, PLATFORM_ID, signal } from '@angular/core';
|
||||
|
||||
const PENDING_TOAST_STORAGE_KEY = 'shopit.pending-toast';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
@@ -11,9 +14,15 @@ export interface Toast {
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ToastService {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly platformId = inject(PLATFORM_ID);
|
||||
private readonly toastsSignal = signal<Toast[]>([]);
|
||||
readonly toasts = this.toastsSignal.asReadonly();
|
||||
|
||||
constructor() {
|
||||
this.restorePendingToast();
|
||||
}
|
||||
|
||||
show(message: string, type: 'success' | 'danger' | 'info' = 'info', duration = 3000): string {
|
||||
const id = Math.random().toString(36).substring(2, 9);
|
||||
const newToast: Toast = { id, message, type, duration };
|
||||
@@ -41,7 +50,50 @@ export class ToastService {
|
||||
return this.show(message, 'success', duration);
|
||||
}
|
||||
|
||||
showAfterReload(
|
||||
message: string,
|
||||
type: Toast['type'] = 'info',
|
||||
duration = 3000,
|
||||
): void {
|
||||
if (!isPlatformBrowser(this.platformId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.document.defaultView?.sessionStorage.setItem(
|
||||
PENDING_TOAST_STORAGE_KEY,
|
||||
JSON.stringify({ message, type, duration }),
|
||||
);
|
||||
}
|
||||
|
||||
dismiss(id: string): void {
|
||||
this.toastsSignal.update((toasts) => toasts.filter((t) => t.id !== id));
|
||||
}
|
||||
|
||||
private restorePendingToast(): void {
|
||||
if (!isPlatformBrowser(this.platformId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storage = this.document.defaultView?.sessionStorage;
|
||||
const pendingToast = storage?.getItem(PENDING_TOAST_STORAGE_KEY);
|
||||
|
||||
if (!pendingToast) {
|
||||
return;
|
||||
}
|
||||
|
||||
storage?.removeItem(PENDING_TOAST_STORAGE_KEY);
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(pendingToast) as Partial<Toast>;
|
||||
|
||||
if (
|
||||
typeof parsed.message === 'string' &&
|
||||
(parsed.type === 'success' || parsed.type === 'danger' || parsed.type === 'info')
|
||||
) {
|
||||
this.show(parsed.message, parsed.type, parsed.duration);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed session data left by an older or interrupted client.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { ProductAttribute } from '../../../../core/services/catalog/catalog.interface';
|
||||
import {
|
||||
CatalogItemVariant,
|
||||
ProductAttribute,
|
||||
} from '../../../../core/services/catalog/catalog.interface';
|
||||
import { ProductAttributeSelectorComponent } from './product-attribute-selector.component';
|
||||
|
||||
describe('ProductAttributeSelectorComponent', () => {
|
||||
@@ -24,6 +27,63 @@ describe('ProductAttributeSelectorComponent', () => {
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it.each([{ size: { value: 'S', label: 'Small' } }, { size: [{ value: 'S', label: 'Small' }] }])(
|
||||
'initializes and matches structured variant values: %j',
|
||||
(values) => {
|
||||
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
|
||||
const variant: CatalogItemVariant = { id: 1, maximum_addable_quantity: null, values };
|
||||
const emittedIds: Array<number | null> = [];
|
||||
fixture.componentInstance.variantChange.subscribe((selected) =>
|
||||
emittedIds.push(selected?.id ?? null),
|
||||
);
|
||||
fixture.componentRef.setInput('attributes', [sizeAttribute]);
|
||||
fixture.componentRef.setInput('inventoryPolicy', 'unlimited');
|
||||
fixture.componentRef.setInput('variants', [variant]);
|
||||
fixture.componentRef.setInput('selectedVariant', variant);
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = fixture.nativeElement.querySelector(
|
||||
'.attribute-selector__text-option',
|
||||
) as HTMLButtonElement;
|
||||
expect(button.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(button.disabled).toBe(false);
|
||||
expect(emittedIds.at(-1)).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([0, 2, null])(
|
||||
'uses maximum quantity %s for alternatives to a preselected option',
|
||||
(maximum) => {
|
||||
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
|
||||
const variants: CatalogItemVariant[] = [
|
||||
{ id: 1, maximum_addable_quantity: 3, values: { size: { value: 'S', label: 'Small' } } },
|
||||
{
|
||||
id: 2,
|
||||
maximum_addable_quantity: maximum,
|
||||
values: { size: { value: 'M', label: 'Medium' } },
|
||||
},
|
||||
];
|
||||
const emittedIds: Array<number | null> = [];
|
||||
fixture.componentInstance.variantChange.subscribe((variant) =>
|
||||
emittedIds.push(variant?.id ?? null),
|
||||
);
|
||||
fixture.componentRef.setInput('attributes', [sizeAttribute]);
|
||||
fixture.componentRef.setInput('inventoryPolicy', maximum === null ? 'unlimited' : 'tracked');
|
||||
fixture.componentRef.setInput('variants', variants);
|
||||
fixture.componentRef.setInput('selectedVariant', variants[0]);
|
||||
fixture.detectChanges();
|
||||
|
||||
const buttons = fixture.nativeElement.querySelectorAll(
|
||||
'.attribute-selector__text-option',
|
||||
) as NodeListOf<HTMLButtonElement>;
|
||||
expect(buttons[1].disabled).toBe(maximum === 0);
|
||||
buttons[1].click();
|
||||
fixture.detectChanges();
|
||||
expect(emittedIds.at(-1)).toBe(maximum === 0 ? 1 : 2);
|
||||
expect(buttons[0].disabled).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps an unlimited option available when maximum quantity is null', () => {
|
||||
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
|
||||
fixture.componentRef.setInput('attributes', [sizeAttribute]);
|
||||
@@ -94,7 +154,16 @@ describe('ProductAttributeSelectorComponent', () => {
|
||||
fixture.componentRef.setInput('variants', [
|
||||
{ id: 1, maximum_addable_quantity: null, values: { event_date: '1' } },
|
||||
{ id: 2, maximum_addable_quantity: null, values: { event_date: '2' } },
|
||||
{ id: 3, maximum_addable_quantity: null, values: { event_date: ['1', '2'] } },
|
||||
{
|
||||
id: 3,
|
||||
maximum_addable_quantity: null,
|
||||
values: {
|
||||
event_date: [
|
||||
{ value: '1', label: '09/10/2026' },
|
||||
{ value: '2', label: '10/10/2026' },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
|
||||
|
||||
@@ -51,22 +51,15 @@ export class ProductAttributeSelectorComponent {
|
||||
const optionNormalized = this.normalizeText(option.value || option.label);
|
||||
const selectedForAttribute = selections[attribute.codigo] ?? [];
|
||||
|
||||
if (
|
||||
!attribute.allow_multi_select &&
|
||||
selectedForAttribute.length >= 1 &&
|
||||
!selectedForAttribute.includes(option.id)
|
||||
) {
|
||||
availability[attribute.codigo][option.id] = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const isAvailable = variants.some((variant) => {
|
||||
if (!this.isVariantAvailable(variant)) return false;
|
||||
|
||||
const variantAttrValues = this.getVariantAttributeValues(attribute, variant.values);
|
||||
const desiredOptionIds = selectedForAttribute.includes(option.id)
|
||||
? selectedForAttribute
|
||||
: [...selectedForAttribute, option.id];
|
||||
const desiredOptionIds = !attribute.allow_multi_select
|
||||
? [option.id]
|
||||
: selectedForAttribute.includes(option.id)
|
||||
? selectedForAttribute
|
||||
: [...selectedForAttribute, option.id];
|
||||
const desiredValues = desiredOptionIds
|
||||
.map((id) => attribute.options.find((candidate) => candidate.id === id))
|
||||
.filter((candidate): candidate is ProductAttributeOption => candidate !== undefined)
|
||||
@@ -202,7 +195,7 @@ export class ProductAttributeSelectorComponent {
|
||||
|
||||
private getVariantAttributeValues(
|
||||
attribute: ProductAttribute,
|
||||
variantAttributes: Record<string, string | string[]>,
|
||||
variantAttributes: CatalogItemVariant['values'],
|
||||
): string[] {
|
||||
const normalizedCodigo = this.normalizeText(attribute.codigo);
|
||||
const normalizedNombre = this.normalizeText(attribute.nombre);
|
||||
@@ -211,7 +204,9 @@ export class ProductAttributeSelectorComponent {
|
||||
const normalizedKey = this.normalizeText(key);
|
||||
|
||||
if (normalizedKey === normalizedCodigo || normalizedKey === normalizedNombre) {
|
||||
return (Array.isArray(value) ? value : [value]).map((item) => this.normalizeText(item));
|
||||
return (Array.isArray(value) ? value : [value]).map((item) =>
|
||||
this.normalizeText(typeof item === 'string' ? item : item.value || item.label),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
<!-- Thumbnails Row -->
|
||||
@if (images().length > 1) {
|
||||
<div class="product-carousel__thumbnails">
|
||||
@for (image of images(); track image; let idx = $index) {
|
||||
@for (image of images(); track $index; let idx = $index) {
|
||||
<button
|
||||
type="button"
|
||||
class="product-carousel__thumbnail border-0 p-0 overflow-hidden bg-light"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -3,11 +3,21 @@ import { provideRouter, Router } from '@angular/router';
|
||||
import { of, throwError } from 'rxjs';
|
||||
|
||||
import { AuthService } from '../../../../core/services/auth/auth.service';
|
||||
import { ToastService } from '../../../../core/services/toast.service';
|
||||
import { LoginPageComponent } from './login-page.component';
|
||||
|
||||
describe('LoginPageComponent', () => {
|
||||
let toastService: {
|
||||
danger: ReturnType<typeof vi.fn>;
|
||||
showAfterReload: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
toastService = {
|
||||
danger: vi.fn(),
|
||||
showAfterReload: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it('submits credentials and redirects to home with a full page reload on success', async () => {
|
||||
@@ -16,14 +26,18 @@ describe('LoginPageComponent', () => {
|
||||
of({
|
||||
id: 1,
|
||||
nombre_apellido: 'Ada Lovelace',
|
||||
email: 'ada@example.com'
|
||||
})
|
||||
)
|
||||
email: 'ada@example.com',
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [LoginPageComponent],
|
||||
providers: [provideRouter([]), { provide: AuthService, useValue: authService }]
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: AuthService, useValue: authService },
|
||||
{ provide: ToastService, useValue: toastService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(LoginPageComponent);
|
||||
@@ -34,17 +48,56 @@ describe('LoginPageComponent', () => {
|
||||
|
||||
component.form.setValue({
|
||||
email: 'ada@example.com',
|
||||
password: 'secret123'
|
||||
password: 'secret123',
|
||||
});
|
||||
|
||||
component.onSubmit();
|
||||
|
||||
expect(authService.login).toHaveBeenCalledWith({
|
||||
email: 'ada@example.com',
|
||||
password: 'secret123'
|
||||
password: 'secret123',
|
||||
});
|
||||
expect(redirectSpy).toHaveBeenCalled();
|
||||
expect(navigateSpy).not.toHaveBeenCalled();
|
||||
expect(toastService.showAfterReload).toHaveBeenCalledWith(
|
||||
'Sesión iniciada correctamente.',
|
||||
'success',
|
||||
);
|
||||
});
|
||||
|
||||
it('serializes a stored return URL before reloading so tenant base paths are restored', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [LoginPageComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: AuthService, useValue: {} },
|
||||
{ provide: ToastService, useValue: toastService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(LoginPageComponent);
|
||||
const component = fixture.componentInstance as any;
|
||||
const router = TestBed.inject(Router);
|
||||
const assign = vi.fn();
|
||||
const parsedUrl = router.parseUrl('/');
|
||||
|
||||
component.document = {
|
||||
defaultView: {
|
||||
sessionStorage: {
|
||||
getItem: vi.fn().mockReturnValue('/'),
|
||||
removeItem: vi.fn(),
|
||||
},
|
||||
},
|
||||
location: { assign },
|
||||
};
|
||||
const parseUrlSpy = vi.spyOn(router, 'parseUrl').mockReturnValue(parsedUrl);
|
||||
const serializeUrlSpy = vi.spyOn(router, 'serializeUrl').mockReturnValue('/sonder');
|
||||
|
||||
component.redirectToHome();
|
||||
|
||||
expect(parseUrlSpy).toHaveBeenCalledWith('/');
|
||||
expect(serializeUrlSpy).toHaveBeenCalledWith(parsedUrl);
|
||||
expect(assign).toHaveBeenCalledWith('/sonder');
|
||||
});
|
||||
|
||||
it('surfaces backend login errors', async () => {
|
||||
@@ -53,16 +106,20 @@ describe('LoginPageComponent', () => {
|
||||
throwError(() => ({
|
||||
error: {
|
||||
errors: {
|
||||
email: ['Las credenciales son invalidas.']
|
||||
}
|
||||
}
|
||||
}))
|
||||
)
|
||||
email: ['Las credenciales son invalidas.'],
|
||||
},
|
||||
},
|
||||
})),
|
||||
),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [LoginPageComponent],
|
||||
providers: [provideRouter([]), { provide: AuthService, useValue: authService }]
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: AuthService, useValue: authService },
|
||||
{ provide: ToastService, useValue: toastService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(LoginPageComponent);
|
||||
@@ -70,22 +127,27 @@ describe('LoginPageComponent', () => {
|
||||
|
||||
component.form.setValue({
|
||||
email: 'ada@example.com',
|
||||
password: 'wrong-password'
|
||||
password: 'wrong-password',
|
||||
});
|
||||
|
||||
component.onSubmit();
|
||||
|
||||
expect(component.serverError()).toBe('Las credenciales son invalidas.');
|
||||
expect(toastService.danger).toHaveBeenCalledWith('Las credenciales son invalidas.');
|
||||
});
|
||||
|
||||
it('validates email length and password minimum length before submit', async () => {
|
||||
const authService = {
|
||||
login: vi.fn()
|
||||
login: vi.fn(),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [LoginPageComponent],
|
||||
providers: [provideRouter([]), { provide: AuthService, useValue: authService }]
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: AuthService, useValue: authService },
|
||||
{ provide: ToastService, useValue: toastService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(LoginPageComponent);
|
||||
@@ -93,12 +155,15 @@ describe('LoginPageComponent', () => {
|
||||
|
||||
component.form.setValue({
|
||||
email: `${'a'.repeat(250)}@example.com`,
|
||||
password: '1234567'
|
||||
password: '1234567',
|
||||
});
|
||||
component.onSubmit();
|
||||
|
||||
expect(authService.login).not.toHaveBeenCalled();
|
||||
expect(component.getControlError('email')).toBe('No puede superar los 255 caracteres.');
|
||||
expect(component.getControlError('password')).toBe('Debe tener al menos 8 caracteres.');
|
||||
expect(toastService.danger).toHaveBeenCalledWith(
|
||||
'Revisá los datos ingresados para iniciar sesión.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
|
||||
import { AuthService } from '../../../../core/services/auth/auth.service';
|
||||
import { ToastService } from '../../../../core/services/toast.service';
|
||||
import { ButtonComponent } from '../../../../shared/components/button/button.component';
|
||||
import { InputComponent } from '../../../../shared/components/input/input.component';
|
||||
|
||||
@@ -16,13 +17,14 @@ const POST_LOGIN_RETURN_URL_KEY = 'shopit.auth.return-url';
|
||||
imports: [ReactiveFormsModule, InputComponent, ButtonComponent],
|
||||
templateUrl: './login-page.component.html',
|
||||
styleUrl: './login-page.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class LoginPageComponent {
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly router = inject(Router);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly toastService = inject(ToastService);
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly platformId = inject(PLATFORM_ID);
|
||||
|
||||
@@ -32,7 +34,7 @@ export class LoginPageComponent {
|
||||
|
||||
protected readonly form = this.formBuilder.nonNullable.group({
|
||||
email: ['', [Validators.required, Validators.email, Validators.maxLength(EMAIL_MAX_LENGTH)]],
|
||||
password: ['', [Validators.required, Validators.minLength(PASSWORD_MIN_LENGTH)]]
|
||||
password: ['', [Validators.required, Validators.minLength(PASSWORD_MIN_LENGTH)]],
|
||||
});
|
||||
protected readonly submitted = this.submittedState.asReadonly();
|
||||
protected readonly serverError = this.serverErrorState.asReadonly();
|
||||
@@ -64,6 +66,7 @@ export class LoginPageComponent {
|
||||
|
||||
if (this.form.invalid) {
|
||||
this.form.markAllAsTouched();
|
||||
this.toastService.danger('Revisá los datos ingresados para iniciar sesión.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -72,12 +75,13 @@ export class LoginPageComponent {
|
||||
this.authService.login(this.form.getRawValue()).subscribe({
|
||||
next: () => {
|
||||
this.isSubmittingState.set(false);
|
||||
this.toastService.showAfterReload('Sesión iniciada correctamente.', 'success');
|
||||
this.redirectToHome();
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
this.isSubmittingState.set(false);
|
||||
this.serverErrorState.set(this.resolveErrorMessage(error));
|
||||
}
|
||||
this.showLoginError(error);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -91,7 +95,7 @@ export class LoginPageComponent {
|
||||
}
|
||||
this.authService.loginWithGoogle();
|
||||
} catch (error: unknown) {
|
||||
this.serverErrorState.set(this.resolveErrorMessage(error));
|
||||
this.showLoginError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,10 +143,9 @@ export class LoginPageComponent {
|
||||
const requestedUrl =
|
||||
this.route.snapshot.queryParamMap.get('returnUrl') ??
|
||||
this.document.defaultView?.sessionStorage.getItem(POST_LOGIN_RETURN_URL_KEY);
|
||||
const destination =
|
||||
requestedUrl?.startsWith('/') && !requestedUrl.startsWith('//')
|
||||
? requestedUrl
|
||||
: this.router.serializeUrl(this.router.createUrlTree(['/']));
|
||||
const internalDestination =
|
||||
requestedUrl?.startsWith('/') && !requestedUrl.startsWith('//') ? requestedUrl : '/';
|
||||
const destination = this.router.serializeUrl(this.router.parseUrl(internalDestination));
|
||||
|
||||
this.document.defaultView?.sessionStorage.removeItem(POST_LOGIN_RETURN_URL_KEY);
|
||||
this.document.location.assign(destination);
|
||||
@@ -155,15 +158,22 @@ export class LoginPageComponent {
|
||||
this.authService.completeGoogleLogin(oauthCode).subscribe({
|
||||
next: () => {
|
||||
this.isSubmittingState.set(false);
|
||||
this.toastService.showAfterReload('Sesión iniciada correctamente.', 'success');
|
||||
this.redirectToHome();
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
this.isSubmittingState.set(false);
|
||||
this.serverErrorState.set(this.resolveErrorMessage(error));
|
||||
}
|
||||
this.showLoginError(error);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private showLoginError(error: unknown): void {
|
||||
const message = this.resolveErrorMessage(error);
|
||||
this.serverErrorState.set(message);
|
||||
this.toastService.danger(message);
|
||||
}
|
||||
|
||||
private resolveErrorMessage(error: unknown): string {
|
||||
const errorPayload =
|
||||
typeof error === 'object' && error !== null && 'error' in error
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
No hay productos disponibles en este momento.
|
||||
</p>
|
||||
} @else {
|
||||
@for (group of catalog(); track group.id) {
|
||||
@for (group of catalog(); track group.id; let first = $first) {
|
||||
<app-store-section [attr.id]="group.code" [title]="group.title">
|
||||
<app-product-list
|
||||
[layout]="group.layout"
|
||||
@@ -30,6 +30,7 @@
|
||||
[items]="group.items"
|
||||
[loading]="isGroupLoading(group.id)"
|
||||
[loadImages]="!hasMainCarouselImages() || mainCarouselReady()"
|
||||
[prioritizeFirstImage]="first && !hasMainCarouselImages()"
|
||||
[unavailableVariantIds]="unavailableVariantIds()"
|
||||
[savingProductIds]="savingProductIds()"
|
||||
(buy)="onBuyProduct($event)"
|
||||
|
||||
@@ -115,7 +115,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',
|
||||
});
|
||||
|
||||
@@ -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,17 @@
|
||||
<article class="event-date-notice">
|
||||
<h2 class="event-date-notice__title">{{ data.title }}</h2>
|
||||
|
||||
<p class="event-date-notice__description">
|
||||
@for (part of data.message; track $index) {
|
||||
@if (part.bold) {
|
||||
<strong>{{ part.text }}</strong>
|
||||
} @else {
|
||||
{{ part.text }}
|
||||
}
|
||||
}
|
||||
</p>
|
||||
|
||||
<div class="event-date-notice__actions">
|
||||
<app-button (click)="close()">Cerrar</app-button>
|
||||
</div>
|
||||
</article>
|
||||
@@ -0,0 +1,33 @@
|
||||
.event-date-notice {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.event-date-notice__title,
|
||||
.event-date-notice__description {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.event-date-notice__title {
|
||||
color: var(--color-danger, #dc3545);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.event-date-notice__description {
|
||||
color: #666666;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.event-date-notice__description strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.event-date-notice__actions {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import '@angular/compiler';
|
||||
import { TestBed, getTestBed } from '@angular/core/testing';
|
||||
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MODAL_DATA, ModalRef } from '../../../core/services/modal.service';
|
||||
import { TenantEventDateNotice } from '../../../core/services/tenant.interface';
|
||||
import { EventDateNoticeModalComponent } from './event-date-notice-modal.component';
|
||||
|
||||
describe('EventDateNoticeModalComponent', () => {
|
||||
beforeAll(() => {
|
||||
try {
|
||||
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
|
||||
} catch {
|
||||
// Test environment may already be initialized by another setup entrypoint.
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
});
|
||||
|
||||
it('renders the precalculated message and emphasizes its marked parts', async () => {
|
||||
const close = vi.fn();
|
||||
const data: TenantEventDateNotice = {
|
||||
type: 'rescheduled',
|
||||
title: 'FECHAS REPROGRAMADAS!',
|
||||
message: [
|
||||
{ text: 'Las fechas del ', bold: false },
|
||||
{ text: '09 y 10 de Octubre de 2026', bold: true },
|
||||
{ text: ' han sido reprogramadas, ', bold: false },
|
||||
{ text: 'respectivamente', bold: true },
|
||||
{ text: '.', bold: false },
|
||||
],
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [EventDateNoticeModalComponent],
|
||||
providers: [
|
||||
{ provide: MODAL_DATA, useValue: data },
|
||||
{ provide: ModalRef, useValue: { close } },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(EventDateNoticeModalComponent);
|
||||
fixture.detectChanges();
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
|
||||
expect(element.querySelector('h2')?.textContent).toContain(data.title);
|
||||
expect(
|
||||
[...element.querySelectorAll('strong')].map((strong) => strong.textContent?.trim()),
|
||||
).toEqual(['09 y 10 de Octubre de 2026', 'respectivamente']);
|
||||
|
||||
element.querySelector('button')?.click();
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
|
||||
import { MODAL_DATA, ModalRef } from '../../../core/services/modal.service';
|
||||
import { TenantEventDateNotice } from '../../../core/services/tenant.interface';
|
||||
import { ButtonComponent } from '../button/button.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-event-date-notice-modal',
|
||||
imports: [ButtonComponent],
|
||||
templateUrl: './event-date-notice-modal.component.html',
|
||||
styleUrl: './event-date-notice-modal.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EventDateNoticeModalComponent {
|
||||
protected readonly data = inject<TenantEventDateNotice>(MODAL_DATA);
|
||||
private readonly modalRef = inject<ModalRef<void>>(ModalRef);
|
||||
|
||||
protected close(): void {
|
||||
this.modalRef.close();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
<div class="hero-banner-container">
|
||||
<div class="hero-banner position-relative rounded">
|
||||
<div
|
||||
class="hero-banner position-relative rounded"
|
||||
[class.hero-banner--with-media]="desktopImageUrl"
|
||||
>
|
||||
@if (desktopImageUrl) {
|
||||
<picture class="hero-media" aria-hidden="true">
|
||||
@if (mobileImageUrl) {
|
||||
|
||||
@@ -7,23 +7,23 @@
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&--with-media {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-media {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
border-radius: inherit;
|
||||
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
&::after {
|
||||
@@ -52,6 +52,11 @@
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.hero-banner--with-media .hero-content {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
::ng-deep .hero-title,
|
||||
.hero-title {
|
||||
color: #666666;
|
||||
@@ -179,24 +184,29 @@
|
||||
inset: auto;
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
height: clamp(8.75rem, 44vw, 211px);
|
||||
background-position: center center;
|
||||
background-size: 140% auto;
|
||||
overflow: visible;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.hero-media::after {
|
||||
top: auto;
|
||||
height: 48%;
|
||||
bottom: -2px;
|
||||
height: calc(48% + 2px);
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(245, 245, 245, 0) 0%,
|
||||
rgba(245, 245, 245, 0.78) 55%,
|
||||
#f5f5f5 96%,
|
||||
#f5f5f5 100%
|
||||
);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.hero-banner--with-media .hero-content {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
justify-content: center !important;
|
||||
height: auto !important;
|
||||
|
||||
@@ -22,6 +22,21 @@ 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',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the fallback banner sizing when there is no image', async () => {
|
||||
await TestBed.configureTestingModule({ imports: [HeroBannerComponent] }).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(HeroBannerComponent);
|
||||
fixture.componentRef.setInput('heroConfig', { title_html: 'Banner sin imagen' });
|
||||
fixture.detectChanges();
|
||||
|
||||
const banner = (fixture.nativeElement as HTMLElement).querySelector('.hero-banner');
|
||||
|
||||
expect(banner?.classList).not.toContain('hero-banner--with-media');
|
||||
});
|
||||
|
||||
it('expands and collapses the event schedules', async () => {
|
||||
|
||||
@@ -51,7 +51,9 @@
|
||||
[title]="item.nombre"
|
||||
[originalPrice]="price(item)"
|
||||
[unavailableMessage]="item.unavailable_message ?? null"
|
||||
[imagePriority]="loadImages() && index < 4"
|
||||
[imagePriority]="
|
||||
loadImages() && prioritizeFirstImage() && groupLayout() !== 'carousel' && index === 0
|
||||
"
|
||||
(buy)="emitProductDetailBuy(item)"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -143,6 +143,14 @@ describe('ProductListComponent', () => {
|
||||
|
||||
expect(element.querySelector('.product-list--column')).not.toBeNull();
|
||||
expect(element.querySelectorAll('app-product-column-with-image')).toHaveLength(2);
|
||||
expect(element.querySelectorAll('img[fetchpriority="high"]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not prioritize images rendered in a circular carousel', async () => {
|
||||
const fixture = await render('column_with_image', items, 'carousel');
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
|
||||
expect(element.querySelectorAll('img[fetchpriority="high"]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders cart products next to each other in the column grid', async () => {
|
||||
|
||||
@@ -68,6 +68,7 @@ export class ProductListComponent {
|
||||
readonly items = input.required<CatalogFeaturedItems>();
|
||||
readonly loading = input(false);
|
||||
readonly loadImages = input(true);
|
||||
readonly prioritizeFirstImage = input(true);
|
||||
readonly unavailableVariantIds = input<ReadonlySet<number>>(new Set<number>());
|
||||
readonly savingProductIds = input<ReadonlySet<number>>(new Set<number>());
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user