feat(event): display pending date change notices

This commit is contained in:
ncoronel 2026-09-15 17:06:29 -03:00
parent 17dbf52181
commit f87f7f55a9
6 changed files with 122 additions and 59 deletions

View File

@ -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();

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 },

View File

@ -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();
}
private showNext(notices: readonly TenantEventDateNotice[], index: number): void { if (!tenant) {
const notice = notices[index]; throw new Error('No se pudo resolver el tenant activo.');
if (!notice) {
return;
} }
this.modalService return this.withoutLoading().http.post<ApiResponse<EventDateNotice[]>>(
.open<EventDateNoticeModalComponent, void, TenantEventDateNotice>( `${environment.url}tenants/${tenant.codigo}/event-date-notices/claim`,
EventDateNoticeModalComponent, {},
{ );
size: 'sm',
data: notice,
},
)
.afterClosed$.subscribe(() => this.showNext(notices, index + 1));
} }
} }

View File

@ -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>

View File

@ -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%;
}

View File

@ -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 {