Compare commits

..

25 Commits

Author SHA1 Message Date
de10de2376 feat(cart): prevent automatic variant replacement and handle selection errors 2026-09-17 11:39:43 -03:00
4923010afb refactor(event): replace bootstrap notices with claimed notices 2026-09-16 08:19:28 -03:00
f87f7f55a9 feat(event): display pending date change notices 2026-09-15 17:10:27 -03:00
17dbf52181 feat(event): show date status information in storefront 2026-09-15 17:10:00 -03:00
a7e1d8995f fix(modal): preserve sequential dialogs 2026-09-14 14:50:15 -03:00
b5fae81795 feat(storefront): show event date notices 2026-09-14 14:50:11 -03:00
8664c86915 refactor(purchases): remove duplicated refund amount 2026-09-14 12:29:52 -03:00
9e72ec0c60 feat(tenant): add ticket refund configuration options 2026-09-11 16:27:23 -03:00
e9aae7a4c3 feat(tickets): handle refunded ticket states 2026-09-10 16:10:54 -03:00
6545be498f feat(tenant): add refund configuration fields 2026-09-10 15:29:47 -03:00
8c15ad7cc1 refactor(modal): replace 'content' with 'description' and update related tests 2026-09-09 08:50:05 -03:00
f859eba8a8 fix(logout): enhance logout handling with success and error toasts 2026-09-07 10:14:11 -03:00
8ba34c6c7a fix(auth): update logout response to include success message and adjust types
fix(tenant): add asset_url to Tenant interface and implement preconnect logic
2026-09-07 09:30:06 -03:00
d7c015e71e fix(product-list): prioritize first image in non-carousel layouts and update tests 2026-09-07 09:04:03 -03:00
d8d608c435 fix(product-carousel): correct track variable in thumbnail loop 2026-09-07 09:03:34 -03:00
985316d3b4 fix(product-attribute-selector): improve variant availability logic and add tests for maximum quantity handling 2026-09-04 11:36:21 -03:00
d601cc1984 fix(product-attribute-selector): update variant values type and normalize value extraction 2026-09-04 11:36:13 -03:00
70d47ac310 Merge pull request 'homologacion' (#4) from homologacion into main
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-front/pulls/4
2026-09-04 12:01:45 +00:00
5d1d7086c7 Merge branch 'develop' into homologacion 2026-09-03 13:39:41 -03:00
9a6ec3d1f2 feat(hero-banner): add conditional class for media presence and adjust styles 2026-09-03 13:39:23 -03:00
38ba5f4195 Merge branch 'develop' into homologacion 2026-09-03 08:55:50 -03:00
71c462aaba Merge pull request 'homologacion' (#3) from homologacion into main
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-front/pulls/3
2026-08-31 11:36:15 +00:00
481eb30795 Merge branch 'main' into homologacion 2026-08-31 11:36:09 +00:00
afebc7f639 Merge pull request 'homo_experimental' (#2) from homo_experimental into homologacion
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-front/pulls/2
2026-08-31 11:25:31 +00:00
520397baa5 Merge pull request 'homologacion' (#1) from homologacion into main
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-front/pulls/1
2026-08-26 18:10:12 +00:00
57 changed files with 968 additions and 194 deletions

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ import {
UrlSerializer, UrlSerializer,
} from '@angular/router'; } from '@angular/router';
import { BehaviorSubject, of } from 'rxjs'; import { BehaviorSubject, of, throwError } from 'rxjs';
import { Tenant } from '../../services/tenant.interface'; import { Tenant } from '../../services/tenant.interface';
import { TenantService } from '../../services/tenant.service'; import { TenantService } from '../../services/tenant.service';
@@ -26,6 +26,7 @@ import { StoreHeaderComponent } from './store-header/store-header.component';
import { CheckoutService } from '../../services/checkout.service'; import { CheckoutService } from '../../services/checkout.service';
import { CheckoutCountdownService } from '../../services/checkout-countdown.service'; import { CheckoutCountdownService } from '../../services/checkout-countdown.service';
import { TenantUrlSerializer } from '../../services/tenant-url.serializer'; import { TenantUrlSerializer } from '../../services/tenant-url.serializer';
import { EventDateNoticeService } from '../../services/event-date-notice.service';
const tenant: Tenant = { const tenant: Tenant = {
id: 1, id: 1,
@@ -211,7 +212,7 @@ describe('StoreLayoutComponent', () => {
useValue: { useValue: {
user: authUserState, user: authUserState,
isAuthenticated: isAuthenticatedState, isAuthenticated: isAuthenticatedState,
logout: vi.fn().mockReturnValue(of(void 0)), logout: vi.fn().mockReturnValue(of({ message: 'Sesión cerrada correctamente.' })),
}, },
}, },
{ {
@@ -224,6 +225,12 @@ describe('StoreLayoutComponent', () => {
remainingSeconds: checkoutRemainingSecondsState.asReadonly(), remainingSeconds: checkoutRemainingSecondsState.asReadonly(),
}, },
}, },
{
provide: EventDateNoticeService,
useValue: {
claim: vi.fn().mockReturnValue(of({ data: [] })),
},
},
], ],
}).compileComponents(); }).compileComponents();
}); });
@@ -574,6 +581,20 @@ describe('StoreLayoutComponent', () => {
expect(authService.logout).toHaveBeenCalled(); expect(authService.logout).toHaveBeenCalled();
expect(cartService.clearCart).toHaveBeenCalled(); expect(cartService.clearCart).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(['/']); expect(router.navigate).toHaveBeenCalledWith(['/']);
expect(TestBed.inject(ToastService).info).toHaveBeenCalledWith('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', () => { it('provides account actions from the footer', () => {

View File

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

View File

@@ -33,6 +33,10 @@ export interface LoginResponse {
user: AuthUser; user: AuthUser;
} }
export interface LogoutResponse {
message: string;
}
export interface RegisterResponse { export interface RegisterResponse {
message: string; message: string;
data: AuthUser; data: AuthUser;

View File

@@ -10,6 +10,7 @@ import {
AuthUser, AuthUser,
LoginPayload, LoginPayload,
LoginResponse, LoginResponse,
LogoutResponse,
RegisterPayload, RegisterPayload,
RegisterResponse, RegisterResponse,
ResetPasswordPayload, ResetPasswordPayload,
@@ -155,14 +156,14 @@ export class AuthService extends BaseApiService {
.pipe(tap((user) => this.userState.set(user))); .pipe(tap((user) => this.userState.set(user)));
} }
logout(): Observable<void> { logout(): Observable<LogoutResponse> {
if (!this.tokenState()) { if (!this.tokenState()) {
this.clearSession(); this.clearSession();
return of(void 0); return of({ message: 'Sesión cerrada correctamente.' });
} }
return this.http return this.http
.post<void>(`${environment.url}logout`, {}) .post<LogoutResponse>(`${environment.url}logout`, {})
.pipe(tap(() => this.clearSession())); .pipe(tap(() => this.clearSession()));
} }

View File

@@ -72,7 +72,7 @@ export interface CatalogItemVariant {
maximum_use_date?: string | null; maximum_use_date?: string | null;
effective_minimum_use_date?: string | null; effective_minimum_use_date?: string | null;
effective_maximum_use_date?: string | null; effective_maximum_use_date?: string | null;
values: Record<string, string | string[]>; values: Record<string, CatalogVariantValue>;
} }
export interface SelectedCatalogItemVariant extends CatalogItemVariant { export interface SelectedCatalogItemVariant extends CatalogItemVariant {

View File

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

View File

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

View File

@@ -80,6 +80,21 @@ describe('ModalService', () => {
expect(ref.dismissReason()).toBeNull(); expect(ref.dismissReason()).toBeNull();
}); });
it('keeps a modal opened synchronously from an afterClosed subscriber', () => {
const firstRef = service.open(FirstTestModalComponent);
let secondRef: ModalRef | undefined;
firstRef.afterClosed$.subscribe(() => {
secondRef = service.open(SecondTestModalComponent);
});
firstRef.close();
expect(secondRef).toBeDefined();
expect(service.activeModal()?.ref).toBe(secondRef);
expect(service.activeModal()?.component).toBe(SecondTestModalComponent);
});
it('tracks dismiss reasons when closing programmatically', () => { it('tracks dismiss reasons when closing programmatically', () => {
const ref = service.open(FirstTestModalComponent); const ref = service.open(FirstTestModalComponent);
const closedSpy = vi.fn(); const closedSpy = vi.fn();
@@ -122,7 +137,7 @@ describe('ModalService', () => {
it('opens the standard confirm modal with default labels', () => { it('opens the standard confirm modal with default labels', () => {
const result$ = service.openConfirm({ const result$ = service.openConfirm({
title: 'Confirmar compra', title: 'Confirmar compra',
content: 'Esto confirmara la compra actual.', description: 'Esto confirmara la compra actual.',
}); });
const activeModal = service.activeModal(); const activeModal = service.activeModal();
@@ -130,9 +145,9 @@ describe('ModalService', () => {
expect(activeModal?.component).toBe(ConfirmModalComponent); expect(activeModal?.component).toBe(ConfirmModalComponent);
expect(result$).toBeDefined(); expect(result$).toBeDefined();
expect(activeModal?.config).toEqual({ expect(activeModal?.config).toEqual({
title: 'Confirmar compra',
data: { data: {
content: 'Esto confirmara la compra actual.', title: 'Confirmar compra',
description: 'Esto confirmara la compra actual.',
confirmLabel: 'Confirmar', confirmLabel: 'Confirmar',
cancelLabel: 'Cancelar', cancelLabel: 'Cancelar',
}, },
@@ -146,7 +161,7 @@ describe('ModalService', () => {
it('maps the confirm modal close result to true', async () => { it('maps the confirm modal close result to true', async () => {
const result$ = service.openConfirm({ const result$ = service.openConfirm({
title: 'Confirmar compra', title: 'Confirmar compra',
content: 'Esto confirmara la compra actual.', description: 'Esto confirmara la compra actual.',
}); });
const activeModal = service.activeModal(); const activeModal = service.activeModal();
const resultPromise = firstValueFrom(result$); const resultPromise = firstValueFrom(result$);
@@ -159,7 +174,7 @@ describe('ModalService', () => {
it('maps dismissing a confirm modal to false', async () => { it('maps dismissing a confirm modal to false', async () => {
const result$ = service.openConfirmDelete({ const result$ = service.openConfirmDelete({
title: 'Eliminar producto', title: 'Eliminar producto',
content: 'Se eliminara el producto.', description: 'Se eliminara el producto.',
}); });
const activeModal = service.activeModal(); const activeModal = service.activeModal();
const resultPromise = firstValueFrom(result$); const resultPromise = firstValueFrom(result$);
@@ -174,7 +189,7 @@ describe('ModalService', () => {
it('opens the delete confirm modal preserving modal overrides', () => { it('opens the delete confirm modal preserving modal overrides', () => {
service.openConfirmDelete({ service.openConfirmDelete({
title: 'Eliminar producto', title: 'Eliminar producto',
content: 'Se eliminara el producto.', description: 'Se eliminara el producto.',
confirmLabel: 'Eliminar', confirmLabel: 'Eliminar',
cancelLabel: 'Conservar', cancelLabel: 'Conservar',
size: 'lg', size: 'lg',
@@ -187,9 +202,9 @@ describe('ModalService', () => {
expect(activeModal?.component).toBe(ConfirmDeleteModalComponent); expect(activeModal?.component).toBe(ConfirmDeleteModalComponent);
expect(activeModal?.config).toEqual({ expect(activeModal?.config).toEqual({
title: 'Eliminar producto',
data: { data: {
content: 'Se eliminara el producto.', title: 'Eliminar producto',
description: 'Se eliminara el producto.',
confirmLabel: 'Eliminar', confirmLabel: 'Eliminar',
cancelLabel: 'Conservar', cancelLabel: 'Conservar',
}, },
@@ -203,16 +218,16 @@ describe('ModalService', () => {
it('opens the simple modal with default button label', () => { it('opens the simple modal with default button label', () => {
service.openSimple({ service.openSimple({
title: 'Aviso', title: 'Aviso',
content: 'Este es un aviso simple.', description: 'Este es un aviso simple.',
}); });
const activeModal = service.activeModal(); const activeModal = service.activeModal();
expect(activeModal?.component).toBe(SimpleModalComponent); expect(activeModal?.component).toBe(SimpleModalComponent);
expect(activeModal?.config).toEqual({ expect(activeModal?.config).toEqual({
title: 'Aviso',
data: { data: {
content: 'Este es un aviso simple.', title: 'Aviso',
description: 'Este es un aviso simple.',
buttonLabel: 'Entendido', buttonLabel: 'Entendido',
}, },
size: 'md', size: 'md',
@@ -225,7 +240,7 @@ describe('ModalService', () => {
it('maps the simple modal close result to undefined', async () => { it('maps the simple modal close result to undefined', async () => {
const result$ = service.openSimple({ const result$ = service.openSimple({
title: 'Aviso', title: 'Aviso',
content: 'Este es un aviso simple.', description: 'Este es un aviso simple.',
}); });
const activeModal = service.activeModal(); const activeModal = service.activeModal();
const resultPromise = firstValueFrom(result$); const resultPromise = firstValueFrom(result$);

View File

@@ -31,24 +31,28 @@ export interface NormalizedModalConfig<TData = unknown> extends Omit<
} }
export interface ConfirmModalData { export interface ConfirmModalData {
content: string; title: string;
description?: string;
confirmLabel: string; confirmLabel: string;
cancelLabel: string; cancelLabel: string;
} }
export interface ConfirmModalConfig extends Omit<ModalConfig<ConfirmModalData>, 'data'> { export interface ConfirmModalConfig extends Omit<ModalConfig<ConfirmModalData>, 'data'> {
content: string; title: string;
description?: string;
confirmLabel?: string; confirmLabel?: string;
cancelLabel?: string; cancelLabel?: string;
} }
export interface SimpleModalData { export interface SimpleModalData {
content: string; title: string;
description?: string;
buttonLabel: string; buttonLabel: string;
} }
export interface SimpleModalConfig extends Omit<ModalConfig<SimpleModalData>, 'data'> { export interface SimpleModalConfig extends Omit<ModalConfig<SimpleModalData>, 'data'> {
content: string; title: string;
description?: string;
buttonLabel?: string; buttonLabel?: string;
} }
@@ -249,8 +253,8 @@ export class ModalService {
return; return;
} }
ref.finalize(result);
this.activeModalState.set(null); this.activeModalState.set(null);
ref.finalize(result);
} }
private dismiss<TResult>( private dismiss<TResult>(
@@ -261,8 +265,8 @@ export class ModalService {
return; return;
} }
ref.finalize(undefined, reason);
this.activeModalState.set(null); this.activeModalState.set(null);
ref.finalize(undefined, reason);
} }
private normalizeConfig<TData>(config: ModalConfig<TData>): NormalizedModalConfig<TData> { private normalizeConfig<TData>(config: ModalConfig<TData>): NormalizedModalConfig<TData> {
@@ -274,7 +278,8 @@ export class ModalService {
private buildConfirmModalConfig(config: ConfirmModalConfig): ModalConfig<ConfirmModalData> { private buildConfirmModalConfig(config: ConfirmModalConfig): ModalConfig<ConfirmModalData> {
const { const {
content, title,
description,
confirmLabel = DEFAULT_CONFIRM_MODAL_LABELS.confirmLabel, confirmLabel = DEFAULT_CONFIRM_MODAL_LABELS.confirmLabel,
cancelLabel = DEFAULT_CONFIRM_MODAL_LABELS.cancelLabel, cancelLabel = DEFAULT_CONFIRM_MODAL_LABELS.cancelLabel,
...modalConfig ...modalConfig
@@ -283,7 +288,8 @@ export class ModalService {
return { return {
...modalConfig, ...modalConfig,
data: { data: {
content, title,
description,
confirmLabel, confirmLabel,
cancelLabel, cancelLabel,
}, },
@@ -291,12 +297,13 @@ export class ModalService {
} }
private buildSimpleModalConfig(config: SimpleModalConfig): ModalConfig<SimpleModalData> { private buildSimpleModalConfig(config: SimpleModalConfig): ModalConfig<SimpleModalData> {
const { content, buttonLabel = 'Entendido', ...modalConfig } = config; const { title, description, buttonLabel = 'Entendido', ...modalConfig } = config;
return { return {
...modalConfig, ...modalConfig,
data: { data: {
content, title,
description,
buttonLabel, buttonLabel,
}, },
}; };

View File

@@ -51,6 +51,8 @@ export interface EventDate {
date: string; date: string;
start_time: string; start_time: string;
end_time: string; end_time: string;
info_text: string | null;
isCanceled: boolean;
} }
export interface EventConfig { export interface EventConfig {
@@ -67,6 +69,8 @@ export interface ActiveEventDate {
date: string; date: string;
time_start: string; time_start: string;
time_end: string; time_end: string;
info_text: string | null;
isCanceled: boolean;
} }
export interface TenantEvent { export interface TenantEvent {
@@ -113,6 +117,7 @@ export interface Tenant {
dominio: string; dominio: string;
base_path?: string; base_path?: string;
site_title?: string | null; site_title?: string | null;
asset_url?: string | null;
address?: string | null; address?: string | null;
phone?: string | null; phone?: string | null;
favicon?: string | null; favicon?: string | null;
@@ -142,6 +147,10 @@ export interface Tenant {
cart_editing_policy?: CartEditingPolicy; cart_editing_policy?: CartEditingPolicy;
checkout_editing_policy?: CartEditingPolicy; checkout_editing_policy?: CartEditingPolicy;
display_cart_item_images?: boolean; display_cart_item_images?: boolean;
allow_ticket_refund?: boolean;
allow_ticket_total_refund?: boolean;
allow_ticket_partial_refund?: boolean;
ticket_partial_refund_percentage?: string;
social_media?: SocialMedia[]; social_media?: SocialMedia[];
menues?: Menu[]; menues?: Menu[];
categories: Category[]; categories: Category[];

View File

@@ -15,6 +15,7 @@ const tenant: Tenant = {
codigo: 'test', codigo: 'test',
nombre: 'Test Tenant', nombre: 'Test Tenant',
dominio: 'localhost', dominio: 'localhost',
asset_url: 'https://s3.example.com/assets',
primary_color: '#6376F3', primary_color: '#6376F3',
secondary_color: '#A0A0A0', secondary_color: '#A0A0A0',
danger_color: '#FF8888', danger_color: '#FF8888',
@@ -44,6 +45,7 @@ const tenantResponse: TenantBootstrapResponse = {
describe('TenantService', () => { describe('TenantService', () => {
beforeEach(() => { beforeEach(() => {
document.head.querySelectorAll('link[rel="preconnect"]').forEach((link) => link.remove());
try { try {
window.history.replaceState({}, '', 'http://localhost:4200/'); window.history.replaceState({}, '', 'http://localhost:4200/');
} catch (e) { } catch (e) {
@@ -72,6 +74,9 @@ describe('TenantService', () => {
expect(service.status()).toBe('ready'); expect(service.status()).toBe('ready');
expect(service.tenant()).toEqual(tenant); expect(service.tenant()).toEqual(tenant);
expect(service.getTenant()).toEqual(tenant); expect(service.getTenant()).toEqual(tenant);
expect(
document.head.querySelector('link[rel="preconnect"][href="https://s3.example.com/"]'),
).not.toBeNull();
httpController.verify(); httpController.verify();
}); });

View File

@@ -1,4 +1,4 @@
import { isPlatformBrowser, isPlatformServer } from '@angular/common'; import { DOCUMENT, isPlatformBrowser, isPlatformServer } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
import { import {
inject, inject,
@@ -26,6 +26,7 @@ import {
providedIn: 'root', providedIn: 'root',
}) })
export class TenantService extends BaseApiService { export class TenantService extends BaseApiService {
private readonly document = inject(DOCUMENT);
private readonly platformId = inject(PLATFORM_ID); private readonly platformId = inject(PLATFORM_ID);
private readonly request = inject(REQUEST, { optional: true }); private readonly request = inject(REQUEST, { optional: true });
private readonly responseInit = inject(RESPONSE_INIT, { optional: true }); private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
@@ -182,10 +183,40 @@ export class TenantService extends BaseApiService {
} }
private setReady(tenant: Tenant): void { private setReady(tenant: Tenant): void {
this.ensureAssetPreconnect(tenant.asset_url);
this.tenantState.set(tenant); this.tenantState.set(tenant);
this.statusState.set('ready'); 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 { private setNotFound(): void {
this.tenantState.set(null); this.tenantState.set(null);
this.statusState.set('not-found'); this.statusState.set('not-found');

View File

@@ -1,3 +1,4 @@
import { DOCUMENT } from '@angular/common';
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest'; import { vi } from 'vitest';
import { ToastService } from './toast.service'; import { ToastService } from './toast.service';
@@ -6,14 +7,16 @@ describe('ToastService', () => {
let service: ToastService; let service: ToastService;
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers();
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ToastService] providers: [ToastService]
}); });
TestBed.inject(DOCUMENT).defaultView?.sessionStorage.removeItem('shopit.pending-toast');
service = TestBed.inject(ToastService); service = TestBed.inject(ToastService);
vi.useFakeTimers();
}); });
afterEach(() => { afterEach(() => {
TestBed.inject(DOCUMENT).defaultView?.sessionStorage.removeItem('shopit.pending-toast');
vi.useRealTimers(); vi.useRealTimers();
}); });
@@ -98,4 +101,23 @@ describe('ToastService', () => {
expect(service.toasts().length).toBe(1); 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();
});
}); });

View File

@@ -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 { export interface Toast {
id: string; id: string;
@@ -11,9 +14,15 @@ export interface Toast {
providedIn: 'root' providedIn: 'root'
}) })
export class ToastService { export class ToastService {
private readonly document = inject(DOCUMENT);
private readonly platformId = inject(PLATFORM_ID);
private readonly toastsSignal = signal<Toast[]>([]); private readonly toastsSignal = signal<Toast[]>([]);
readonly toasts = this.toastsSignal.asReadonly(); readonly toasts = this.toastsSignal.asReadonly();
constructor() {
this.restorePendingToast();
}
show(message: string, type: 'success' | 'danger' | 'info' = 'info', duration = 3000): string { show(message: string, type: 'success' | 'danger' | 'info' = 'info', duration = 3000): string {
const id = Math.random().toString(36).substring(2, 9); const id = Math.random().toString(36).substring(2, 9);
const newToast: Toast = { id, message, type, duration }; const newToast: Toast = { id, message, type, duration };
@@ -41,7 +50,50 @@ export class ToastService {
return this.show(message, 'success', duration); 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 { dismiss(id: string): void {
this.toastsSignal.update((toasts) => toasts.filter((t) => t.id !== id)); 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.
}
}
} }

View File

@@ -299,12 +299,12 @@ describe('ReutilizablesTestPageComponent', () => {
expect(modalServiceStub.openConfirm).toHaveBeenCalledWith({ expect(modalServiceStub.openConfirm).toHaveBeenCalledWith({
title: 'Confirmar accion', 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', confirmLabel: 'Confirmar',
}); });
expect(modalServiceStub.openConfirmDelete).toHaveBeenCalledWith({ expect(modalServiceStub.openConfirmDelete).toHaveBeenCalledWith({
title: 'Eliminar producto', title: 'Eliminar producto',
content: description:
'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.', 'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.',
confirmLabel: 'Eliminar', confirmLabel: 'Eliminar',
}); });

View File

@@ -448,7 +448,7 @@ export class ReutilizablesTestPageComponent {
protected openBasicModal(): void { protected openBasicModal(): void {
this.openConfirmModal({ this.openConfirmModal({
title: 'Confirmar accion', 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', confirmLabel: 'Confirmar',
}); });
} }
@@ -456,7 +456,7 @@ export class ReutilizablesTestPageComponent {
protected openConfirmDeleteModal(): void { protected openConfirmDeleteModal(): void {
this.openConfirmDelete({ this.openConfirmDelete({
title: 'Eliminar producto', title: 'Eliminar producto',
content: description:
'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.', 'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.',
confirmLabel: 'Eliminar', confirmLabel: 'Eliminar',
}); });
@@ -465,7 +465,7 @@ export class ReutilizablesTestPageComponent {
protected openLockedModal(): void { protected openLockedModal(): void {
this.openConfirmModal({ this.openConfirmModal({
title: 'Modal bloqueado', 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', confirmLabel: 'Entendido',
closeOnBackdrop: false, closeOnBackdrop: false,
closeOnEscape: false, closeOnEscape: false,
@@ -475,7 +475,7 @@ export class ReutilizablesTestPageComponent {
protected openWideModal(): void { protected openWideModal(): void {
this.openConfirmModal({ this.openConfirmModal({
title: 'Modal ancho', 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', confirmLabel: 'Seguir',
size: 'xl', size: 'xl',
}); });
@@ -485,7 +485,7 @@ export class ReutilizablesTestPageComponent {
this.modalService this.modalService
.openSimple({ .openSimple({
title: 'Mensaje del sistema', 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', buttonLabel: 'Entendido',
}) })
.subscribe(() => { .subscribe(() => {

View File

@@ -1,7 +1,10 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it } from 'vitest'; 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'; import { ProductAttributeSelectorComponent } from './product-attribute-selector.component';
describe('ProductAttributeSelectorComponent', () => { describe('ProductAttributeSelectorComponent', () => {
@@ -24,6 +27,63 @@ describe('ProductAttributeSelectorComponent', () => {
}).compileComponents(); }).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', () => { it('keeps an unlimited option available when maximum quantity is null', () => {
const fixture = TestBed.createComponent(ProductAttributeSelectorComponent); const fixture = TestBed.createComponent(ProductAttributeSelectorComponent);
fixture.componentRef.setInput('attributes', [sizeAttribute]); fixture.componentRef.setInput('attributes', [sizeAttribute]);
@@ -94,7 +154,16 @@ describe('ProductAttributeSelectorComponent', () => {
fixture.componentRef.setInput('variants', [ fixture.componentRef.setInput('variants', [
{ id: 1, maximum_addable_quantity: null, values: { event_date: '1' } }, { id: 1, maximum_addable_quantity: null, values: { event_date: '1' } },
{ id: 2, maximum_addable_quantity: null, values: { event_date: '2' } }, { 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(); fixture.detectChanges();

View File

@@ -51,22 +51,15 @@ export class ProductAttributeSelectorComponent {
const optionNormalized = this.normalizeText(option.value || option.label); const optionNormalized = this.normalizeText(option.value || option.label);
const selectedForAttribute = selections[attribute.codigo] ?? []; 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) => { const isAvailable = variants.some((variant) => {
if (!this.isVariantAvailable(variant)) return false; if (!this.isVariantAvailable(variant)) return false;
const variantAttrValues = this.getVariantAttributeValues(attribute, variant.values); const variantAttrValues = this.getVariantAttributeValues(attribute, variant.values);
const desiredOptionIds = selectedForAttribute.includes(option.id) const desiredOptionIds = !attribute.allow_multi_select
? selectedForAttribute ? [option.id]
: [...selectedForAttribute, option.id]; : selectedForAttribute.includes(option.id)
? selectedForAttribute
: [...selectedForAttribute, option.id];
const desiredValues = desiredOptionIds const desiredValues = desiredOptionIds
.map((id) => attribute.options.find((candidate) => candidate.id === id)) .map((id) => attribute.options.find((candidate) => candidate.id === id))
.filter((candidate): candidate is ProductAttributeOption => candidate !== undefined) .filter((candidate): candidate is ProductAttributeOption => candidate !== undefined)
@@ -202,7 +195,7 @@ export class ProductAttributeSelectorComponent {
private getVariantAttributeValues( private getVariantAttributeValues(
attribute: ProductAttribute, attribute: ProductAttribute,
variantAttributes: Record<string, string | string[]>, variantAttributes: CatalogItemVariant['values'],
): string[] { ): string[] {
const normalizedCodigo = this.normalizeText(attribute.codigo); const normalizedCodigo = this.normalizeText(attribute.codigo);
const normalizedNombre = this.normalizeText(attribute.nombre); const normalizedNombre = this.normalizeText(attribute.nombre);
@@ -211,7 +204,9 @@ export class ProductAttributeSelectorComponent {
const normalizedKey = this.normalizeText(key); const normalizedKey = this.normalizeText(key);
if (normalizedKey === normalizedCodigo || normalizedKey === normalizedNombre) { 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),
);
} }
} }

View File

@@ -54,7 +54,7 @@
<!-- Thumbnails Row --> <!-- Thumbnails Row -->
@if (images().length > 1) { @if (images().length > 1) {
<div class="product-carousel__thumbnails"> <div class="product-carousel__thumbnails">
@for (image of images(); track image; let idx = $index) { @for (image of images(); track $index; let idx = $index) {
<button <button
type="button" type="button"
class="product-carousel__thumbnail border-0 p-0 overflow-hidden bg-light" class="product-carousel__thumbnail border-0 p-0 overflow-hidden bg-light"

View File

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

View File

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

View File

@@ -3,11 +3,21 @@ import { provideRouter, Router } from '@angular/router';
import { of, throwError } from 'rxjs'; import { of, throwError } from 'rxjs';
import { AuthService } from '../../../../core/services/auth/auth.service'; import { AuthService } from '../../../../core/services/auth/auth.service';
import { ToastService } from '../../../../core/services/toast.service';
import { LoginPageComponent } from './login-page.component'; import { LoginPageComponent } from './login-page.component';
describe('LoginPageComponent', () => { describe('LoginPageComponent', () => {
let toastService: {
danger: ReturnType<typeof vi.fn>;
showAfterReload: ReturnType<typeof vi.fn>;
};
beforeEach(() => { beforeEach(() => {
TestBed.resetTestingModule(); TestBed.resetTestingModule();
toastService = {
danger: vi.fn(),
showAfterReload: vi.fn(),
};
}); });
it('submits credentials and redirects to home with a full page reload on success', async () => { it('submits credentials and redirects to home with a full page reload on success', async () => {
@@ -16,14 +26,18 @@ describe('LoginPageComponent', () => {
of({ of({
id: 1, id: 1,
nombre_apellido: 'Ada Lovelace', nombre_apellido: 'Ada Lovelace',
email: 'ada@example.com' email: 'ada@example.com',
}) }),
) ),
}; };
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [LoginPageComponent], imports: [LoginPageComponent],
providers: [provideRouter([]), { provide: AuthService, useValue: authService }] providers: [
provideRouter([]),
{ provide: AuthService, useValue: authService },
{ provide: ToastService, useValue: toastService },
],
}).compileComponents(); }).compileComponents();
const fixture = TestBed.createComponent(LoginPageComponent); const fixture = TestBed.createComponent(LoginPageComponent);
@@ -34,17 +48,56 @@ describe('LoginPageComponent', () => {
component.form.setValue({ component.form.setValue({
email: 'ada@example.com', email: 'ada@example.com',
password: 'secret123' password: 'secret123',
}); });
component.onSubmit(); component.onSubmit();
expect(authService.login).toHaveBeenCalledWith({ expect(authService.login).toHaveBeenCalledWith({
email: 'ada@example.com', email: 'ada@example.com',
password: 'secret123' password: 'secret123',
}); });
expect(redirectSpy).toHaveBeenCalled(); expect(redirectSpy).toHaveBeenCalled();
expect(navigateSpy).not.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 () => { it('surfaces backend login errors', async () => {
@@ -53,16 +106,20 @@ describe('LoginPageComponent', () => {
throwError(() => ({ throwError(() => ({
error: { error: {
errors: { errors: {
email: ['Las credenciales son invalidas.'] email: ['Las credenciales son invalidas.'],
} },
} },
})) })),
) ),
}; };
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [LoginPageComponent], imports: [LoginPageComponent],
providers: [provideRouter([]), { provide: AuthService, useValue: authService }] providers: [
provideRouter([]),
{ provide: AuthService, useValue: authService },
{ provide: ToastService, useValue: toastService },
],
}).compileComponents(); }).compileComponents();
const fixture = TestBed.createComponent(LoginPageComponent); const fixture = TestBed.createComponent(LoginPageComponent);
@@ -70,22 +127,27 @@ describe('LoginPageComponent', () => {
component.form.setValue({ component.form.setValue({
email: 'ada@example.com', email: 'ada@example.com',
password: 'wrong-password' password: 'wrong-password',
}); });
component.onSubmit(); component.onSubmit();
expect(component.serverError()).toBe('Las credenciales son invalidas.'); 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 () => { it('validates email length and password minimum length before submit', async () => {
const authService = { const authService = {
login: vi.fn() login: vi.fn(),
}; };
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [LoginPageComponent], imports: [LoginPageComponent],
providers: [provideRouter([]), { provide: AuthService, useValue: authService }] providers: [
provideRouter([]),
{ provide: AuthService, useValue: authService },
{ provide: ToastService, useValue: toastService },
],
}).compileComponents(); }).compileComponents();
const fixture = TestBed.createComponent(LoginPageComponent); const fixture = TestBed.createComponent(LoginPageComponent);
@@ -93,12 +155,15 @@ describe('LoginPageComponent', () => {
component.form.setValue({ component.form.setValue({
email: `${'a'.repeat(250)}@example.com`, email: `${'a'.repeat(250)}@example.com`,
password: '1234567' password: '1234567',
}); });
component.onSubmit(); component.onSubmit();
expect(authService.login).not.toHaveBeenCalled(); expect(authService.login).not.toHaveBeenCalled();
expect(component.getControlError('email')).toBe('No puede superar los 255 caracteres.'); expect(component.getControlError('email')).toBe('No puede superar los 255 caracteres.');
expect(component.getControlError('password')).toBe('Debe tener al menos 8 caracteres.'); expect(component.getControlError('password')).toBe('Debe tener al menos 8 caracteres.');
expect(toastService.danger).toHaveBeenCalledWith(
'Revisá los datos ingresados para iniciar sesión.',
);
}); });
}); });

View File

@@ -4,6 +4,7 @@ import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { AuthService } from '../../../../core/services/auth/auth.service'; import { AuthService } from '../../../../core/services/auth/auth.service';
import { ToastService } from '../../../../core/services/toast.service';
import { ButtonComponent } from '../../../../shared/components/button/button.component'; import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { InputComponent } from '../../../../shared/components/input/input.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], imports: [ReactiveFormsModule, InputComponent, ButtonComponent],
templateUrl: './login-page.component.html', templateUrl: './login-page.component.html',
styleUrl: './login-page.component.scss', styleUrl: './login-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class LoginPageComponent { export class LoginPageComponent {
private readonly formBuilder = inject(FormBuilder); private readonly formBuilder = inject(FormBuilder);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly authService = inject(AuthService); private readonly authService = inject(AuthService);
private readonly toastService = inject(ToastService);
private readonly document = inject(DOCUMENT); private readonly document = inject(DOCUMENT);
private readonly platformId = inject(PLATFORM_ID); private readonly platformId = inject(PLATFORM_ID);
@@ -32,7 +34,7 @@ export class LoginPageComponent {
protected readonly form = this.formBuilder.nonNullable.group({ protected readonly form = this.formBuilder.nonNullable.group({
email: ['', [Validators.required, Validators.email, Validators.maxLength(EMAIL_MAX_LENGTH)]], 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 submitted = this.submittedState.asReadonly();
protected readonly serverError = this.serverErrorState.asReadonly(); protected readonly serverError = this.serverErrorState.asReadonly();
@@ -64,6 +66,7 @@ export class LoginPageComponent {
if (this.form.invalid) { if (this.form.invalid) {
this.form.markAllAsTouched(); this.form.markAllAsTouched();
this.toastService.danger('Revisá los datos ingresados para iniciar sesión.');
return; return;
} }
@@ -72,12 +75,13 @@ export class LoginPageComponent {
this.authService.login(this.form.getRawValue()).subscribe({ this.authService.login(this.form.getRawValue()).subscribe({
next: () => { next: () => {
this.isSubmittingState.set(false); this.isSubmittingState.set(false);
this.toastService.showAfterReload('Sesión iniciada correctamente.', 'success');
this.redirectToHome(); this.redirectToHome();
}, },
error: (error: unknown) => { error: (error: unknown) => {
this.isSubmittingState.set(false); this.isSubmittingState.set(false);
this.serverErrorState.set(this.resolveErrorMessage(error)); this.showLoginError(error);
} },
}); });
} }
@@ -91,7 +95,7 @@ export class LoginPageComponent {
} }
this.authService.loginWithGoogle(); this.authService.loginWithGoogle();
} catch (error: unknown) { } catch (error: unknown) {
this.serverErrorState.set(this.resolveErrorMessage(error)); this.showLoginError(error);
} }
} }
@@ -139,10 +143,9 @@ export class LoginPageComponent {
const requestedUrl = const requestedUrl =
this.route.snapshot.queryParamMap.get('returnUrl') ?? this.route.snapshot.queryParamMap.get('returnUrl') ??
this.document.defaultView?.sessionStorage.getItem(POST_LOGIN_RETURN_URL_KEY); this.document.defaultView?.sessionStorage.getItem(POST_LOGIN_RETURN_URL_KEY);
const destination = const internalDestination =
requestedUrl?.startsWith('/') && !requestedUrl.startsWith('//') requestedUrl?.startsWith('/') && !requestedUrl.startsWith('//') ? requestedUrl : '/';
? requestedUrl const destination = this.router.serializeUrl(this.router.parseUrl(internalDestination));
: this.router.serializeUrl(this.router.createUrlTree(['/']));
this.document.defaultView?.sessionStorage.removeItem(POST_LOGIN_RETURN_URL_KEY); this.document.defaultView?.sessionStorage.removeItem(POST_LOGIN_RETURN_URL_KEY);
this.document.location.assign(destination); this.document.location.assign(destination);
@@ -155,15 +158,22 @@ export class LoginPageComponent {
this.authService.completeGoogleLogin(oauthCode).subscribe({ this.authService.completeGoogleLogin(oauthCode).subscribe({
next: () => { next: () => {
this.isSubmittingState.set(false); this.isSubmittingState.set(false);
this.toastService.showAfterReload('Sesión iniciada correctamente.', 'success');
this.redirectToHome(); this.redirectToHome();
}, },
error: (error: unknown) => { error: (error: unknown) => {
this.isSubmittingState.set(false); 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 { private resolveErrorMessage(error: unknown): string {
const errorPayload = const errorPayload =
typeof error === 'object' && error !== null && 'error' in error typeof error === 'object' && error !== null && 'error' in error

View File

@@ -105,7 +105,7 @@ describe('RegisterPageComponent', () => {
password_confirmation: 'Secret!123' password_confirmation: 'Secret!123'
}); });
expect(modalService.openSimple).toHaveBeenCalledWith({ expect(modalService.openSimple).toHaveBeenCalledWith({
content: 'Tu cuenta fue creada correctamente', title: 'Tu cuenta fue creada correctamente',
buttonLabel: 'Cerrar' buttonLabel: 'Cerrar'
}); });
expect(navigateSpy).toHaveBeenCalledWith(['/login']); expect(navigateSpy).toHaveBeenCalledWith(['/login']);

View File

@@ -93,7 +93,7 @@ export class RegisterPageComponent {
next: () => { next: () => {
this.isSubmittingState.set(false); this.isSubmittingState.set(false);
this.modalService.openSimple({ this.modalService.openSimple({
content: 'Tu cuenta fue creada correctamente', title: 'Tu cuenta fue creada correctamente',
buttonLabel: 'Cerrar' buttonLabel: 'Cerrar'
}).subscribe(() => { }).subscribe(() => {
void this.router.navigate(['/login']); void this.router.navigate(['/login']);

View File

@@ -144,7 +144,7 @@ describe('ResetPasswordPageComponent', () => {
password_confirmation: 'Secret!123', password_confirmation: 'Secret!123',
}); });
expect(modalService.openSimple).toHaveBeenCalledWith({ expect(modalService.openSimple).toHaveBeenCalledWith({
content: 'Contraseña modificada correctamente', title: 'Contraseña modificada correctamente',
buttonLabel: 'Cerrar', buttonLabel: 'Cerrar',
}); });
expect(navigateSpy).toHaveBeenCalledWith(['/login']); expect(navigateSpy).toHaveBeenCalledWith(['/login']);

View File

@@ -155,7 +155,7 @@ export class ResetPasswordPageComponent {
this.modalService this.modalService
.openSimple({ .openSimple({
content: 'Contraseña modificada correctamente', title: 'Contraseña modificada correctamente',
buttonLabel: 'Cerrar', buttonLabel: 'Cerrar',
}) })
.subscribe(() => { .subscribe(() => {

View File

@@ -22,7 +22,7 @@
No hay productos disponibles en este momento. No hay productos disponibles en este momento.
</p> </p>
} @else { } @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-store-section [attr.id]="group.code" [title]="group.title">
<app-product-list <app-product-list
[layout]="group.layout" [layout]="group.layout"
@@ -30,6 +30,7 @@
[items]="group.items" [items]="group.items"
[loading]="isGroupLoading(group.id)" [loading]="isGroupLoading(group.id)"
[loadImages]="!hasMainCarouselImages() || mainCarouselReady()" [loadImages]="!hasMainCarouselImages() || mainCarouselReady()"
[prioritizeFirstImage]="first && !hasMainCarouselImages()"
[unavailableVariantIds]="unavailableVariantIds()" [unavailableVariantIds]="unavailableVariantIds()"
[savingProductIds]="savingProductIds()" [savingProductIds]="savingProductIds()"
(buy)="onBuyProduct($event)" (buy)="onBuyProduct($event)"

View File

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

View File

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

View File

@@ -31,8 +31,9 @@
class="cart-item-variant-selector" class="cart-item-variant-selector"
[variants]="variants()" [variants]="variants()"
[selectedVariant]="selectedVariant()" [selectedVariant]="selectedVariant()"
[autoSelectFirst]="false"
[compact]="true" [compact]="true"
(selectedVariantChange)="onVariantChange($event)" (selectionValuesChange)="onVariantChange($event.selectedVariant)"
/> />
} @else { } @else {
<div class="d-grid cart-item-attributes"> <div class="d-grid cart-item-attributes">

View File

@@ -3,13 +3,14 @@ import { signal } from '@angular/core';
import { TestBed, getTestBed } from '@angular/core/testing'; import { TestBed, getTestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; 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 { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { CartService } from '../../../core/services/cart/cart.service'; import { CartService } from '../../../core/services/cart/cart.service';
import { ModalService } from '../../../core/services/modal.service'; import { ModalService } from '../../../core/services/modal.service';
import { ToastService } from '../../../core/services/toast.service'; import { ToastService } from '../../../core/services/toast.service';
import { CartComponent } from './cart.component'; import { CartComponent } from './cart.component';
import { VariantSelectorComponent } from '../variant-selector/variant-selector.component';
describe('CartComponent', () => { describe('CartComponent', () => {
beforeAll(() => { beforeAll(() => {
@@ -22,6 +23,7 @@ describe('CartComponent', () => {
afterEach(() => { afterEach(() => {
TestBed.resetTestingModule(); TestBed.resetTestingModule();
vi.restoreAllMocks();
}); });
it('shows an empty cart message when there are no items', async () => { it('shows an empty cart message when there are no items', async () => {
@@ -115,7 +117,7 @@ describe('CartComponent', () => {
expect(openConfirmDelete).toHaveBeenCalledWith({ expect(openConfirmDelete).toHaveBeenCalledWith({
title: 'Eliminar producto', 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', confirmLabel: 'Eliminar',
cancelLabel: 'Cancelar', cancelLabel: 'Cancelar',
}); });
@@ -565,6 +567,57 @@ describe('CartComponent', () => {
expect(quantityChange).not.toHaveBeenCalled(); 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 () => { it('persists a variant selected from a cart row', async () => {
const updateItemVariant = vi.fn().mockReturnValue( const updateItemVariant = vi.fn().mockReturnValue(
of({ of({

View File

@@ -236,7 +236,7 @@ export class CartComponent {
this.modalService this.modalService
.openConfirmDelete({ .openConfirmDelete({
title: 'Eliminar producto', 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', confirmLabel: 'Eliminar',
cancelLabel: 'Cancelar', cancelLabel: 'Cancelar',
}) })

View File

@@ -1,5 +1,8 @@
<div class="confirm-modal"> <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"> <div class="confirm-modal__actions">
<app-button variant="danger-secondary" (click)="cancel()"> <app-button variant="danger-secondary" (click)="cancel()">

View File

@@ -15,7 +15,7 @@ import { ConfirmDeleteModalComponent } from './confirm-delete-modal.component';
describe('ConfirmDeleteModalComponent', () => { describe('ConfirmDeleteModalComponent', () => {
const data: ConfirmModalData = { const data: ConfirmModalData = {
content: 'Se eliminara el elemento seleccionado.', title: 'Se eliminara el elemento seleccionado.',
confirmLabel: 'Eliminar', confirmLabel: 'Eliminar',
cancelLabel: 'Cancelar' cancelLabel: 'Cancelar'
}; };
@@ -58,7 +58,7 @@ describe('ConfirmDeleteModalComponent', () => {
const element = fixture.nativeElement as HTMLElement; const element = fixture.nativeElement as HTMLElement;
const buttons = element.querySelectorAll('button'); const buttons = element.querySelectorAll('button');
expect(element.textContent).toContain(data.content); expect(element.textContent).toContain(data.title);
expect(element.textContent).toContain(data.confirmLabel); expect(element.textContent).toContain(data.confirmLabel);
expect(buttons[1].className).toContain('btn-danger'); expect(buttons[1].className).toContain('btn-danger');
}); });

View File

@@ -1,5 +1,8 @@
<div class="confirm-modal"> <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"> <div class="confirm-modal__actions">
<app-button variant="secondary" (click)="cancel()"> <app-button variant="secondary" (click)="cancel()">

View File

@@ -15,7 +15,7 @@ import { ConfirmModalComponent } from './confirm-modal.component';
describe('ConfirmModalComponent', () => { describe('ConfirmModalComponent', () => {
const data: ConfirmModalData = { const data: ConfirmModalData = {
content: 'Se confirmara la operacion seleccionada.', title: 'Se confirmara la operacion seleccionada.',
confirmLabel: 'Aceptar', confirmLabel: 'Aceptar',
cancelLabel: 'Volver' cancelLabel: 'Volver'
}; };
@@ -57,7 +57,7 @@ describe('ConfirmModalComponent', () => {
const element = fixture.nativeElement as HTMLElement; 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.confirmLabel);
expect(element.textContent).toContain(data.cancelLabel); expect(element.textContent).toContain(data.cancelLabel);
}); });

View File

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

View File

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

View File

@@ -0,0 +1,58 @@
import '@angular/compiler';
import { TestBed, getTestBed } from '@angular/core/testing';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { EventDateNotice } from '../../../core/services/event-date-notice.service';
import { MODAL_DATA, ModalRef } from '../../../core/services/modal.service';
import { EventDateNoticeModalComponent } from './event-date-notice-modal.component';
describe('EventDateNoticeModalComponent', () => {
beforeAll(() => {
try {
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
});
afterEach(() => {
TestBed.resetTestingModule();
});
it('renders the precalculated message and emphasizes its marked parts', async () => {
const close = vi.fn();
const data: EventDateNotice = {
type: 'rescheduled',
change_ids: [1, 2],
title: 'FECHAS REPROGRAMADAS!',
message: [
{ text: 'Las fechas del ', bold: false },
{ text: '09 y 10 de Octubre de 2026', bold: true },
{ text: ' han sido reprogramadas, ', bold: false },
{ text: 'respectivamente', bold: true },
{ text: '.', bold: false },
],
};
await TestBed.configureTestingModule({
imports: [EventDateNoticeModalComponent],
providers: [
{ provide: MODAL_DATA, useValue: data },
{ provide: ModalRef, useValue: { close } },
],
}).compileComponents();
const fixture = TestBed.createComponent(EventDateNoticeModalComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('h2')?.textContent).toContain(data.title);
expect(
[...element.querySelectorAll('b')].map((strong) => strong.textContent?.trim()),
).toEqual(['09 y 10 de Octubre de 2026', 'respectivamente']);
element.querySelector('button')?.click();
expect(close).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,21 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { EventDateNotice } from '../../../core/services/event-date-notice.service';
import { MODAL_DATA, ModalRef } from '../../../core/services/modal.service';
import { ButtonComponent } from '../button/button.component';
@Component({
selector: 'app-event-date-notice-modal',
imports: [ButtonComponent],
templateUrl: './event-date-notice-modal.component.html',
styleUrl: './event-date-notice-modal.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EventDateNoticeModalComponent {
protected readonly notice = inject<EventDateNotice>(MODAL_DATA);
private readonly modalRef = inject<ModalRef<void>>(ModalRef);
protected close(): void {
this.modalRef.close();
}
}

View File

@@ -1,5 +1,8 @@
<div class="hero-banner-container"> <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) { @if (desktopImageUrl) {
<picture class="hero-media" aria-hidden="true"> <picture class="hero-media" aria-hidden="true">
@if (mobileImageUrl) { @if (mobileImageUrl) {
@@ -85,7 +88,12 @@
<td> <td>
<span class="event-schedule-value"> <span class="event-schedule-value">
<i class="fa-regular fa-calendar" aria-hidden="true"></i> <i class="fa-regular fa-calendar" aria-hidden="true"></i>
<span>{{ formatScheduleDate(eventDate.date) }}</span> <span [class.event-schedule-date--canceled]="eventDate.isCanceled">
{{ formatScheduleDate(eventDate.date) }}
</span>
@if (eventDate.info_text) {
<app-tooltip [message]="eventDate.info_text" />
}
</span> </span>
</td> </td>
<td> <td>

View File

@@ -7,23 +7,23 @@
min-height: 400px; min-height: 400px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
&--with-media {
min-height: 0;
}
} }
.hero-media { .hero-media {
position: absolute; position: relative;
inset: 0;
display: block; display: block;
width: 100%;
overflow: hidden; overflow: hidden;
background-position: center;
background-repeat: no-repeat;
background-size: cover;
border-radius: inherit; border-radius: inherit;
img { img {
display: block; display: block;
width: 100%; width: 100%;
height: 100%; height: auto;
object-fit: cover;
} }
&::after { &::after {
@@ -52,6 +52,11 @@
flex-grow: 1; flex-grow: 1;
} }
.hero-banner--with-media .hero-content {
position: absolute;
inset: 0;
}
::ng-deep .hero-title, ::ng-deep .hero-title,
.hero-title { .hero-title {
color: #666666; color: #666666;
@@ -159,6 +164,10 @@
} }
} }
.event-schedule-date--canceled {
text-decoration: line-through;
}
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
.hero-banner-container { .hero-banner-container {
padding-bottom: 0; padding-bottom: 0;
@@ -179,24 +188,29 @@
inset: auto; inset: auto;
flex: 0 0 auto; flex: 0 0 auto;
width: 100%; width: 100%;
height: clamp(8.75rem, 44vw, 211px); overflow: visible;
background-position: center center;
background-size: 140% auto;
border-radius: 0; border-radius: 0;
} }
.hero-media::after { .hero-media::after {
top: auto; top: auto;
height: 48%; bottom: -2px;
height: calc(48% + 2px);
background: linear-gradient( background: linear-gradient(
to bottom, to bottom,
rgba(245, 245, 245, 0) 0%, rgba(245, 245, 245, 0) 0%,
rgba(245, 245, 245, 0.78) 55%, rgba(245, 245, 245, 0.78) 55%,
#f5f5f5 96%,
#f5f5f5 100% #f5f5f5 100%
); );
border-radius: 0; border-radius: 0;
} }
.hero-banner--with-media .hero-content {
position: relative;
inset: auto;
}
.hero-content { .hero-content {
justify-content: center !important; justify-content: center !important;
height: auto !important; height: auto !important;

View File

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

View File

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

View File

@@ -51,7 +51,9 @@
[title]="item.nombre" [title]="item.nombre"
[originalPrice]="price(item)" [originalPrice]="price(item)"
[unavailableMessage]="item.unavailable_message ?? null" [unavailableMessage]="item.unavailable_message ?? null"
[imagePriority]="loadImages() && index < 4" [imagePriority]="
loadImages() && prioritizeFirstImage() && groupLayout() !== 'carousel' && index === 0
"
(buy)="emitProductDetailBuy(item)" (buy)="emitProductDetailBuy(item)"
/> />
} }

View File

@@ -143,6 +143,14 @@ describe('ProductListComponent', () => {
expect(element.querySelector('.product-list--column')).not.toBeNull(); expect(element.querySelector('.product-list--column')).not.toBeNull();
expect(element.querySelectorAll('app-product-column-with-image')).toHaveLength(2); 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 () => { it('renders cart products next to each other in the column grid', async () => {

View File

@@ -68,6 +68,7 @@ export class ProductListComponent {
readonly items = input.required<CatalogFeaturedItems>(); readonly items = input.required<CatalogFeaturedItems>();
readonly loading = input(false); readonly loading = input(false);
readonly loadImages = input(true); readonly loadImages = input(true);
readonly prioritizeFirstImage = input(true);
readonly unavailableVariantIds = input<ReadonlySet<number>>(new Set<number>()); readonly unavailableVariantIds = input<ReadonlySet<number>>(new Set<number>());
readonly savingProductIds = input<ReadonlySet<number>>(new Set<number>()); readonly savingProductIds = input<ReadonlySet<number>>(new Set<number>());

View File

@@ -176,7 +176,7 @@ export class ProductTicketSelectorComponent {
this.modalService this.modalService
.openConfirmDelete({ .openConfirmDelete({
title: 'Eliminar entrada', 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', confirmLabel: 'Sí, eliminar',
cancelLabel: 'Cancelar', cancelLabel: 'Cancelar',
size: 'md', size: 'md',

View File

@@ -1,5 +1,8 @@
<div class="simple-modal"> <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"> <div class="simple-modal__actions">
<app-button (click)="close()"> <app-button (click)="close()">

View File

@@ -15,7 +15,7 @@ import { SimpleModalComponent } from './simple-modal.component';
describe('SimpleModalComponent', () => { describe('SimpleModalComponent', () => {
const data: SimpleModalData = { const data: SimpleModalData = {
content: 'Este es un mensaje simple.', title: 'Este es un mensaje simple.',
buttonLabel: 'Entendido' buttonLabel: 'Entendido'
}; };
@@ -56,7 +56,7 @@ describe('SimpleModalComponent', () => {
const element = fixture.nativeElement as HTMLElement; const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain(data.content); expect(element.textContent).toContain(data.title);
expect(element.textContent).toContain(data.buttonLabel); expect(element.textContent).toContain(data.buttonLabel);
}); });

View File

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

View File

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

View File

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

View File

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