Compare commits
4 Commits
a7e1d8995f
...
feature/re
| Author | SHA1 | Date | |
|---|---|---|---|
| de10de2376 | |||
| 4923010afb | |||
| f87f7f55a9 | |||
| 17dbf52181 |
@@ -6,7 +6,6 @@ import { provideRouter } from '@angular/router';
|
|||||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { App } from './app';
|
import { App } from './app';
|
||||||
import { EventDateNoticeService } from './core/services/event-date-notice.service';
|
|
||||||
import { Tenant } from './core/services/tenant.interface';
|
import { Tenant } from './core/services/tenant.interface';
|
||||||
import { TenantService } from './core/services/tenant.service';
|
import { TenantService } from './core/services/tenant.service';
|
||||||
|
|
||||||
@@ -117,48 +116,4 @@ describe('App', () => {
|
|||||||
).toBe("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E");
|
).toBe("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the date notices returned by the tenant bootstrap once', async () => {
|
|
||||||
const show = vi.fn();
|
|
||||||
const dateNotices = [
|
|
||||||
{
|
|
||||||
type: 'suspended' as const,
|
|
||||||
title: 'FECHA CANCELADA!',
|
|
||||||
message: [
|
|
||||||
{ text: 'La fecha del ', bold: false },
|
|
||||||
{ text: '09 de Octubre de 2026', bold: true },
|
|
||||||
{ text: ' ha sido cancelada.', bold: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [App],
|
|
||||||
providers: [
|
|
||||||
provideRouter([]),
|
|
||||||
{
|
|
||||||
provide: TenantService,
|
|
||||||
useValue: createTenantServiceStub('ready', {
|
|
||||||
...tenant,
|
|
||||||
event: {
|
|
||||||
title: 'Festival',
|
|
||||||
location: 'Predio',
|
|
||||||
dates: [],
|
|
||||||
date_notices: dateNotices,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: EventDateNoticeService,
|
|
||||||
useValue: { show },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).compileComponents();
|
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(App);
|
|
||||||
fixture.detectChanges();
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
expect(show).toHaveBeenCalledOnce();
|
|
||||||
expect(show).toHaveBeenCalledWith(dateNotices);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
|
import { DOCUMENT } from '@angular/common';
|
||||||
import { Component, PLATFORM_ID, computed, effect, inject } from '@angular/core';
|
import { Component, computed, effect, inject } from '@angular/core';
|
||||||
import { Title } from '@angular/platform-browser';
|
import { Title } from '@angular/platform-browser';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
|
||||||
import { EventDateNoticeService } from './core/services/event-date-notice.service';
|
|
||||||
import { TenantService } from './core/services/tenant.service';
|
import { TenantService } from './core/services/tenant.service';
|
||||||
import { DEFAULT_TENANT_BRANDING } from './core/services/tenant-ssr-cache.store';
|
import { DEFAULT_TENANT_BRANDING } from './core/services/tenant-ssr-cache.store';
|
||||||
import { GlobalLoadingComponent } from './shared/components/global-loading/global-loading.component';
|
import { GlobalLoadingComponent } from './shared/components/global-loading/global-loading.component';
|
||||||
@@ -59,11 +58,8 @@ function hexToRgb(hex: string): string {
|
|||||||
})
|
})
|
||||||
export class App {
|
export class App {
|
||||||
private readonly tenantService = inject(TenantService);
|
private readonly tenantService = inject(TenantService);
|
||||||
private readonly eventDateNoticeService = inject(EventDateNoticeService);
|
|
||||||
private readonly document = inject(DOCUMENT);
|
private readonly document = inject(DOCUMENT);
|
||||||
private readonly title = inject(Title);
|
private readonly title = inject(Title);
|
||||||
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
||||||
private notifiedTenantCode: string | null = null;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
@@ -82,11 +78,6 @@ export class App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
favicon.setAttribute('href', faviconHref);
|
favicon.setAttribute('href', faviconHref);
|
||||||
|
|
||||||
if (this.isBrowser && tenant && this.notifiedTenantCode !== tenant.codigo) {
|
|
||||||
this.notifiedTenantCode = tenant.codigo;
|
|
||||||
this.eventDateNoticeService.show(tenant.event?.date_notices ?? []);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { StoreHeaderComponent } from './store-header/store-header.component';
|
|||||||
import { CheckoutService } from '../../services/checkout.service';
|
import { CheckoutService } from '../../services/checkout.service';
|
||||||
import { CheckoutCountdownService } from '../../services/checkout-countdown.service';
|
import { CheckoutCountdownService } from '../../services/checkout-countdown.service';
|
||||||
import { TenantUrlSerializer } from '../../services/tenant-url.serializer';
|
import { TenantUrlSerializer } from '../../services/tenant-url.serializer';
|
||||||
|
import { EventDateNoticeService } from '../../services/event-date-notice.service';
|
||||||
|
|
||||||
const tenant: Tenant = {
|
const tenant: Tenant = {
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -224,6 +225,12 @@ describe('StoreLayoutComponent', () => {
|
|||||||
remainingSeconds: checkoutRemainingSecondsState.asReadonly(),
|
remainingSeconds: checkoutRemainingSecondsState.asReadonly(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: EventDateNoticeService,
|
||||||
|
useValue: {
|
||||||
|
claim: vi.fn().mockReturnValue(of({ data: [] })),
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
});
|
});
|
||||||
@@ -574,17 +581,13 @@ describe('StoreLayoutComponent', () => {
|
|||||||
expect(authService.logout).toHaveBeenCalled();
|
expect(authService.logout).toHaveBeenCalled();
|
||||||
expect(cartService.clearCart).toHaveBeenCalled();
|
expect(cartService.clearCart).toHaveBeenCalled();
|
||||||
expect(router.navigate).toHaveBeenCalledWith(['/']);
|
expect(router.navigate).toHaveBeenCalledWith(['/']);
|
||||||
expect(TestBed.inject(ToastService).info).toHaveBeenCalledWith(
|
expect(TestBed.inject(ToastService).info).toHaveBeenCalledWith('Sesión cerrada correctamente.');
|
||||||
'Sesión cerrada correctamente.',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows a danger toast when logout fails', async () => {
|
it('shows a danger toast when logout fails', async () => {
|
||||||
const authService = TestBed.inject(AuthService);
|
const authService = TestBed.inject(AuthService);
|
||||||
const message = 'La sesión no pudo cerrarse en el servidor.';
|
const message = 'La sesión no pudo cerrarse en el servidor.';
|
||||||
(authService.logout as any).mockReturnValue(
|
(authService.logout as any).mockReturnValue(throwError(() => ({ error: { message } })));
|
||||||
throwError(() => ({ error: { message } })),
|
|
||||||
);
|
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -1,85 +1,59 @@
|
|||||||
import '@angular/compiler';
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { getTestBed } from '@angular/core/testing';
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
|
|
||||||
import { Subject } from 'rxjs';
|
|
||||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { EventDateNoticeModalComponent } from '../../shared/components/event-date-notice-modal/event-date-notice-modal.component';
|
import { environment } from '../../../environments/environment';
|
||||||
import { EventDateNoticeService } from './event-date-notice.service';
|
import { EventDateNotice, EventDateNoticeService } from './event-date-notice.service';
|
||||||
import { ModalService } from './modal.service';
|
import { ApiResponse } from './api-response.interface';
|
||||||
import { TenantEventDateNotice } from './tenant.interface';
|
import { TenantService } from './tenant.service';
|
||||||
|
|
||||||
describe('EventDateNoticeService', () => {
|
describe('EventDateNoticeService', () => {
|
||||||
let service: EventDateNoticeService;
|
let service: EventDateNoticeService;
|
||||||
let open: ReturnType<typeof vi.fn>;
|
let httpMock: HttpTestingController;
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
try {
|
|
||||||
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
|
|
||||||
} catch {
|
|
||||||
// Test environment may already be initialized by another setup entrypoint.
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
open = vi.fn();
|
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
EventDateNoticeService,
|
EventDateNoticeService,
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
{
|
{
|
||||||
provide: ModalService,
|
provide: TenantService,
|
||||||
useValue: { open },
|
useValue: { getTenant: () => ({ codigo: 'festival' }) },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
service = TestBed.inject(EventDateNoticeService);
|
service = TestBed.inject(EventDateNoticeService);
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => httpMock.verify());
|
||||||
TestBed.resetTestingModule();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows backend notices sequentially', () => {
|
it('claims pending notices for the active tenant', () => {
|
||||||
const firstClosed = new Subject<void | undefined>();
|
const response = {
|
||||||
const secondClosed = new Subject<void | undefined>();
|
data: [
|
||||||
const notices: TenantEventDateNotice[] = [
|
|
||||||
{
|
{
|
||||||
type: 'suspended',
|
type: 'suspended' as const,
|
||||||
|
change_ids: [10],
|
||||||
title: 'FECHA CANCELADA!',
|
title: 'FECHA CANCELADA!',
|
||||||
message: [{ text: 'La fecha fue cancelada.', bold: false }],
|
message: [{ text: 'La fecha fue cancelada.', bold: false }],
|
||||||
},
|
},
|
||||||
{
|
],
|
||||||
type: 'rescheduled',
|
};
|
||||||
title: 'FECHA REPROGRAMADA!',
|
let result: ApiResponse<EventDateNotice[]> | undefined;
|
||||||
message: [{ text: 'La fecha fue reprogramada.', bold: false }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
open
|
service.claim().subscribe((value) => (result = value));
|
||||||
.mockReturnValueOnce({ afterClosed$: firstClosed.asObservable() })
|
|
||||||
.mockReturnValueOnce({ afterClosed$: secondClosed.asObservable() });
|
|
||||||
|
|
||||||
service.show(notices);
|
const request = httpMock.expectOne(
|
||||||
|
`${environment.url}tenants/festival/event-date-notices/claim`,
|
||||||
|
);
|
||||||
|
expect(request.request.method).toBe('POST');
|
||||||
|
expect(request.request.body).toEqual({});
|
||||||
|
|
||||||
expect(open).toHaveBeenCalledTimes(1);
|
request.flush(response);
|
||||||
expect(open).toHaveBeenNthCalledWith(1, EventDateNoticeModalComponent, {
|
|
||||||
size: 'sm',
|
|
||||||
data: notices[0],
|
|
||||||
});
|
|
||||||
|
|
||||||
firstClosed.next(undefined);
|
expect(result).toEqual(response);
|
||||||
|
|
||||||
expect(open).toHaveBeenCalledTimes(2);
|
|
||||||
expect(open).toHaveBeenNthCalledWith(2, EventDateNoticeModalComponent, {
|
|
||||||
size: 'sm',
|
|
||||||
data: notices[1],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not open a modal without notices', () => {
|
|
||||||
service.show([]);
|
|
||||||
|
|
||||||
expect(open).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,32 +1,39 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { inject, Injectable } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
import { EventDateNoticeModalComponent } from '../../shared/components/event-date-notice-modal/event-date-notice-modal.component';
|
import { environment } from '../../../environments/environment';
|
||||||
import { ModalService } from './modal.service';
|
import { ApiResponse } from './api-response.interface';
|
||||||
import { TenantEventDateNotice } from './tenant.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' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class EventDateNoticeService {
|
export class EventDateNoticeService extends BaseApiService {
|
||||||
private readonly modalService = inject(ModalService);
|
private readonly tenantService = inject(TenantService);
|
||||||
|
|
||||||
show(notices: readonly TenantEventDateNotice[]): void {
|
claim(): Observable<ApiResponse<EventDateNotice[]>> {
|
||||||
this.showNext(notices, 0);
|
const tenant = this.tenantService.getTenant();
|
||||||
|
|
||||||
|
if (!tenant) {
|
||||||
|
throw new Error('No se pudo resolver el tenant activo.');
|
||||||
}
|
}
|
||||||
|
|
||||||
private showNext(notices: readonly TenantEventDateNotice[], index: number): void {
|
return this.withoutLoading().http.post<ApiResponse<EventDateNotice[]>>(
|
||||||
const notice = notices[index];
|
`${environment.url}tenants/${tenant.codigo}/event-date-notices/claim`,
|
||||||
|
{},
|
||||||
if (!notice) {
|
);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.modalService
|
|
||||||
.open<EventDateNoticeModalComponent, void, TenantEventDateNotice>(
|
|
||||||
EventDateNoticeModalComponent,
|
|
||||||
{
|
|
||||||
size: 'sm',
|
|
||||||
data: notice,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.afterClosed$.subscribe(() => this.showNext(notices, index + 1));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,36 +69,14 @@ 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 type TenantEventDateChangeType = 'rescheduled' | 'suspended';
|
|
||||||
|
|
||||||
export interface TenantEventDateChange {
|
|
||||||
type: TenantEventDateChangeType;
|
|
||||||
source_event_date_id: number | null;
|
|
||||||
destination_event_date_id: number | null;
|
|
||||||
previous_date: string;
|
|
||||||
new_date: string | null;
|
|
||||||
occurred_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TenantEventDateNoticeMessagePart {
|
|
||||||
text: string;
|
|
||||||
bold: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TenantEventDateNotice {
|
|
||||||
type: TenantEventDateChangeType;
|
|
||||||
title: string;
|
|
||||||
message: TenantEventDateNoticeMessagePart[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TenantEvent {
|
export interface TenantEvent {
|
||||||
title: string;
|
title: string;
|
||||||
location: string;
|
location: string;
|
||||||
dates: ActiveEventDate[];
|
dates: ActiveEventDate[];
|
||||||
date_changes?: TenantEventDateChange[];
|
|
||||||
date_notices?: TenantEventDateNotice[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WebsiteExtras {
|
export interface WebsiteExtras {
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
<article class="event-date-notice">
|
<div class="event-date-notice-modal">
|
||||||
<h2 class="event-date-notice__title">{{ data.title }}</h2>
|
<h2>{{ notice.title }}</h2>
|
||||||
|
|
||||||
<p class="event-date-notice__description">
|
<p>
|
||||||
@for (part of data.message; track $index) {
|
@for (segment of notice.message; track $index) {
|
||||||
@if (part.bold) {
|
@if (segment.bold) {
|
||||||
<strong>{{ part.text }}</strong>
|
<b>{{ segment.text }}</b>
|
||||||
} @else {
|
} @else {
|
||||||
{{ part.text }}
|
<span>{{ segment.text }}</span>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="event-date-notice__actions">
|
|
||||||
<app-button (click)="close()">Cerrar</app-button>
|
<app-button (click)="close()">Cerrar</app-button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
|
||||||
|
|||||||
@@ -1,33 +1,28 @@
|
|||||||
.event-date-notice {
|
.event-date-notice-modal {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 20px;
|
gap: 1.5rem;
|
||||||
justify-items: center;
|
justify-items: stretch;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-date-notice__title,
|
h2,
|
||||||
.event-date-notice__description {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-date-notice__title {
|
h2 {
|
||||||
color: var(--color-danger, #dc3545);
|
color: var(--tenant-danger);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-date-notice__description {
|
p {
|
||||||
color: #666666;
|
color: #666666;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 400;
|
line-height: 1.35;
|
||||||
line-height: 1.4;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-date-notice__description strong {
|
b {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-date-notice__actions {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { TestBed, getTestBed } from '@angular/core/testing';
|
|||||||
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
|
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
|
||||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
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 { MODAL_DATA, ModalRef } from '../../../core/services/modal.service';
|
||||||
import { TenantEventDateNotice } from '../../../core/services/tenant.interface';
|
|
||||||
import { EventDateNoticeModalComponent } from './event-date-notice-modal.component';
|
import { EventDateNoticeModalComponent } from './event-date-notice-modal.component';
|
||||||
|
|
||||||
describe('EventDateNoticeModalComponent', () => {
|
describe('EventDateNoticeModalComponent', () => {
|
||||||
@@ -22,8 +22,9 @@ describe('EventDateNoticeModalComponent', () => {
|
|||||||
|
|
||||||
it('renders the precalculated message and emphasizes its marked parts', async () => {
|
it('renders the precalculated message and emphasizes its marked parts', async () => {
|
||||||
const close = vi.fn();
|
const close = vi.fn();
|
||||||
const data: TenantEventDateNotice = {
|
const data: EventDateNotice = {
|
||||||
type: 'rescheduled',
|
type: 'rescheduled',
|
||||||
|
change_ids: [1, 2],
|
||||||
title: 'FECHAS REPROGRAMADAS!',
|
title: 'FECHAS REPROGRAMADAS!',
|
||||||
message: [
|
message: [
|
||||||
{ text: 'Las fechas del ', bold: false },
|
{ text: 'Las fechas del ', bold: false },
|
||||||
@@ -48,7 +49,7 @@ describe('EventDateNoticeModalComponent', () => {
|
|||||||
|
|
||||||
expect(element.querySelector('h2')?.textContent).toContain(data.title);
|
expect(element.querySelector('h2')?.textContent).toContain(data.title);
|
||||||
expect(
|
expect(
|
||||||
[...element.querySelectorAll('strong')].map((strong) => strong.textContent?.trim()),
|
[...element.querySelectorAll('b')].map((strong) => strong.textContent?.trim()),
|
||||||
).toEqual(['09 y 10 de Octubre de 2026', 'respectivamente']);
|
).toEqual(['09 y 10 de Octubre de 2026', 'respectivamente']);
|
||||||
|
|
||||||
element.querySelector('button')?.click();
|
element.querySelector('button')?.click();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
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 { MODAL_DATA, ModalRef } from '../../../core/services/modal.service';
|
||||||
import { TenantEventDateNotice } from '../../../core/services/tenant.interface';
|
|
||||||
import { ButtonComponent } from '../button/button.component';
|
import { ButtonComponent } from '../button/button.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -12,7 +12,7 @@ import { ButtonComponent } from '../button/button.component';
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class EventDateNoticeModalComponent {
|
export class EventDateNoticeModalComponent {
|
||||||
protected readonly data = inject<TenantEventDateNotice>(MODAL_DATA);
|
protected readonly notice = inject<EventDateNotice>(MODAL_DATA);
|
||||||
private readonly modalRef = inject<ModalRef<void>>(ModalRef);
|
private readonly modalRef = inject<ModalRef<void>>(ModalRef);
|
||||||
|
|
||||||
protected close(): void {
|
protected close(): void {
|
||||||
|
|||||||
@@ -88,7 +88,12 @@
|
|||||||
<td>
|
<td>
|
||||||
<span class="event-schedule-value">
|
<span class="event-schedule-value">
|
||||||
<i class="fa-regular fa-calendar" aria-hidden="true"></i>
|
<i class="fa-regular fa-calendar" aria-hidden="true"></i>
|
||||||
<span>{{ formatScheduleDate(eventDate.date) }}</span>
|
<span [class.event-schedule-date--canceled]="eventDate.isCanceled">
|
||||||
|
{{ formatScheduleDate(eventDate.date) }}
|
||||||
|
</span>
|
||||||
|
@if (eventDate.info_text) {
|
||||||
|
<app-tooltip [message]="eventDate.info_text" />
|
||||||
|
}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -164,6 +164,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.event-schedule-date--canceled {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 767.98px) {
|
@media (max-width: 767.98px) {
|
||||||
.hero-banner-container {
|
.hero-banner-container {
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
|
|||||||
@@ -22,9 +22,7 @@ describe('HeroBannerComponent', () => {
|
|||||||
expect(element.querySelector('img')?.getAttribute('src')).toBe(
|
expect(element.querySelector('img')?.getAttribute('src')).toBe(
|
||||||
'https://example.com/desktop.jpg',
|
'https://example.com/desktop.jpg',
|
||||||
);
|
);
|
||||||
expect(element.querySelector('.hero-banner')?.classList).toContain(
|
expect(element.querySelector('.hero-banner')?.classList).toContain('hero-banner--with-media');
|
||||||
'hero-banner--with-media',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the fallback banner sizing when there is no image', async () => {
|
it('keeps the fallback banner sizing when there is no image', async () => {
|
||||||
@@ -52,12 +50,16 @@ describe('HeroBannerComponent', () => {
|
|||||||
date: '2026-10-09',
|
date: '2026-10-09',
|
||||||
start_time: '10:00:00',
|
start_time: '10:00:00',
|
||||||
end_time: '20:30:00',
|
end_time: '20:30:00',
|
||||||
|
info_text: 'Esta fecha fue cancelada.',
|
||||||
|
isCanceled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
date: '2026-10-10',
|
date: '2026-10-10',
|
||||||
start_time: '10:00:00',
|
start_time: '10:00:00',
|
||||||
end_time: '22:00:00',
|
end_time: '22:00:00',
|
||||||
|
info_text: null,
|
||||||
|
isCanceled: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -78,6 +80,10 @@ describe('HeroBannerComponent', () => {
|
|||||||
expect(element.querySelectorAll('.event-schedules tbody tr')).toHaveLength(2);
|
expect(element.querySelectorAll('.event-schedules tbody tr')).toHaveLength(2);
|
||||||
expect(element.querySelector('.event-schedules')?.textContent).toContain('9 de Octubre 2026');
|
expect(element.querySelector('.event-schedules')?.textContent).toContain('9 de Octubre 2026');
|
||||||
expect(element.querySelector('.event-schedules')?.textContent).toContain('10:00 - 20:30');
|
expect(element.querySelector('.event-schedules')?.textContent).toContain('10:00 - 20:30');
|
||||||
|
expect(element.querySelector('.event-schedule-date--canceled')).not.toBeNull();
|
||||||
|
expect(element.querySelector('app-tooltip')?.textContent).toContain(
|
||||||
|
'Esta fecha fue cancelada.',
|
||||||
|
);
|
||||||
|
|
||||||
toggle?.click();
|
toggle?.click();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user