Compare commits

...

5 Commits

32 changed files with 1029 additions and 254 deletions

View File

@@ -1,4 +1,4 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { computed, signal } from '@angular/core'; import { computed, signal } from '@angular/core';
@@ -69,7 +69,7 @@ describe('authInterceptor', () => {
httpController.verify(); httpController.verify();
}); });
it('clears the local session when an authenticated request returns 401', () => { it('propagates a 401 without clearing the local session', () => {
const authService = createAuthServiceStub('expired-token'); const authService = createAuthServiceStub('expired-token');
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -83,14 +83,21 @@ describe('authInterceptor', () => {
const client = TestBed.inject(HttpClient); const client = TestBed.inject(HttpClient);
const httpController = TestBed.inject(HttpTestingController); const httpController = TestBed.inject(HttpTestingController);
let receivedError: unknown;
let receivedStatus: number | null = null;
client.get(`${environment.url}me`).subscribe({ client.get(`${environment.url}me`).subscribe({
error: () => undefined error: (error: HttpErrorResponse) => {
receivedError = error;
receivedStatus = error.status;
}
}); });
const request = httpController.expectOne(`${environment.url}me`); const request = httpController.expectOne(`${environment.url}me`);
request.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' }); request.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
expect(authService.clearSession).toHaveBeenCalledTimes(1); expect(receivedError).toBeInstanceOf(HttpErrorResponse);
expect(receivedStatus).toBe(401);
expect(authService.clearSession).not.toHaveBeenCalled();
httpController.verify(); httpController.verify();
}); });
}); });

View File

@@ -1,12 +1,6 @@
import { import { HttpEvent, HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
HttpErrorResponse,
HttpEvent,
HttpHandlerFn,
HttpInterceptorFn,
HttpRequest
} from '@angular/common/http';
import { inject } from '@angular/core'; import { inject } from '@angular/core';
import { Observable, catchError, throwError } from 'rxjs'; import { Observable } from 'rxjs';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
@@ -25,13 +19,5 @@ export const authInterceptor: HttpInterceptorFn = (
}) })
: request; : request;
return next(authenticatedRequest).pipe( return next(authenticatedRequest);
catchError((error: unknown) => {
if (error instanceof HttpErrorResponse && error.status === 401 && token) {
authService.clearSession();
}
return throwError(() => error);
})
);
}; };

View File

@@ -95,7 +95,7 @@ describe('AuthService', () => {
httpController.verify(); httpController.verify();
}); });
it('clears the session when bootstrap receives 401 from /me', async () => { it('propagates a 401 from /me without clearing the session', async () => {
cookieStore['shopit.auth.token'] = 'expired-token'; cookieStore['shopit.auth.token'] = 'expired-token';
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -116,11 +116,11 @@ describe('AuthService', () => {
request.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' }); request.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
await expect(bootstrapPromise).resolves.toBeUndefined(); await expect(bootstrapPromise).rejects.toMatchObject({ status: 401 });
expect(service.user()).toBeNull(); expect(service.user()).toBeNull();
expect(service.token()).toBeNull(); expect(service.token()).toBe('expired-token');
expect(service.isAuthenticated()).toBe(false); expect(service.isAuthenticated()).toBe(true);
expect(cookieStore['shopit.auth.token']).toBeUndefined(); expect(cookieStore['shopit.auth.token']).toBe('expired-token');
httpController.verify(); httpController.verify();
}); });
@@ -168,7 +168,7 @@ describe('AuthService', () => {
httpController.verify(); httpController.verify();
}); });
it('clears local session even when logout request fails', () => { it('propagates logout errors without clearing the local session', () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [
provideHttpClient(), provideHttpClient(),
@@ -202,9 +202,9 @@ describe('AuthService', () => {
expect(logoutRequest.request.method).toBe('POST'); expect(logoutRequest.request.method).toBe('POST');
logoutRequest.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' }); logoutRequest.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
expect(service.token()).toBeNull(); expect(service.token()).toBe('plain-text-token');
expect(service.user()).toBeNull(); expect(service.user()?.email).toBe('ada@example.com');
expect(cookieStore['shopit.auth.token']).toBeUndefined(); expect(cookieStore['shopit.auth.token']).toBe('plain-text-token');
httpController.verify(); httpController.verify();
}); });

View File

@@ -1,4 +1,4 @@
import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { isPlatformBrowser, isPlatformServer } from '@angular/common'; import { isPlatformBrowser, isPlatformServer } from '@angular/common';
import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core'; import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core';
import { firstValueFrom, map, Observable, of, tap } from 'rxjs'; import { firstValueFrom, map, Observable, of, tap } from 'rxjs';
@@ -57,10 +57,7 @@ export class AuthService {
} }
return this.http.post<void>(`${environment.url}logout`, {}).pipe( return this.http.post<void>(`${environment.url}logout`, {}).pipe(
tap({ tap(() => this.clearSession())
next: () => this.clearSession(),
error: () => this.clearSession()
})
); );
} }
@@ -78,19 +75,10 @@ export class AuthService {
return; return;
} }
try {
const user = await firstValueFrom(this.loadCurrentUser()); const user = await firstValueFrom(this.loadCurrentUser());
if (isPlatformServer(this.platformId)) { if (isPlatformServer(this.platformId)) {
this.transferState.set(AUTH_USER_SSR_STATE_KEY, user); this.transferState.set(AUTH_USER_SSR_STATE_KEY, user);
} }
} catch (error) {
if (this.isUnauthorizedError(error)) {
this.clearSession();
return;
}
throw error;
}
} }
loadCurrentUser(): Observable<AuthUser> { loadCurrentUser(): Observable<AuthUser> {
@@ -115,8 +103,4 @@ export class AuthService {
this.userState.set(user); this.userState.set(user);
this.cookieService.set(AUTH_TOKEN_COOKIE_KEY, token); this.cookieService.set(AUTH_TOKEN_COOKIE_KEY, token);
} }
private isUnauthorizedError(error: unknown): error is HttpErrorResponse {
return error instanceof HttpErrorResponse && error.status === 401;
}
} }

View File

@@ -24,7 +24,7 @@ export interface PurchaseSummaryResponse extends PurchaseStatusResponse {
export interface PurchaseDetailAttributeResponse { export interface PurchaseDetailAttributeResponse {
name: string; name: string;
value: string | null; value: string | number | boolean | null;
} }
export interface PurchaseDetailItemResponse { export interface PurchaseDetailItemResponse {
@@ -32,16 +32,15 @@ export interface PurchaseDetailItemResponse {
quantity: number; quantity: number;
unit_price: string; unit_price: string;
line_total: string; line_total: string;
product: { source_catalog_item_id: number | null;
id: number; source_variant_id: number | null;
item_details: {
nombre: string; nombre: string;
descripcion: string | null;
slug: string; slug: string;
imagen: string | null; imagen: string | null;
} | null;
variant: {
id: number;
attributes: PurchaseDetailAttributeResponse[]; attributes: PurchaseDetailAttributeResponse[];
} | null; };
} }
export interface PurchaseDetailResponse extends PurchaseStatusResponse { export interface PurchaseDetailResponse extends PurchaseStatusResponse {
@@ -52,10 +51,11 @@ export interface PurchaseDetailResponse extends PurchaseStatusResponse {
created_at: string | null; created_at: string | null;
payment_method: string | null; payment_method: string | null;
dni: string | null; dni: string | null;
transfer_payer_dni: string | null;
telefono: string | null; telefono: string | null;
nombre_apellido: string | null; nombre_apellido: string | null;
email: string | null; email: string | null;
items_source: 'purchase' | 'cart' | null; items_source: 'purchase';
items: PurchaseDetailItemResponse[]; items: PurchaseDetailItemResponse[];
subtotal: string; subtotal: string;
total: string; total: string;

View File

@@ -23,12 +23,9 @@ export class CookieService {
return null; return null;
} }
set(name: string, value: string, days = 7): void { set(name: string, value: string): void {
if (isPlatformBrowser(this.platformId)) { if (isPlatformBrowser(this.platformId)) {
const date = new Date(); this.document.cookie = `${name}=${value};path=/;SameSite=Lax`;
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
const expires = `expires=${date.toUTCString()}`;
this.document.cookie = `${name}=${value};${expires};path=/;SameSite=Lax`;
} }
} }

View File

@@ -3,9 +3,7 @@
<div class="card-body"> <div class="card-body">
<span class="eyebrow">Componentes reutilizables</span> <span class="eyebrow">Componentes reutilizables</span>
<h1>Button + Input</h1> <h1>Button + Input</h1>
<p class="lead mb-4"> <p class="lead mb-4">Pagina dedicada a probar variantes de los componentes compartidos.</p>
Pagina dedicada a probar variantes de los componentes compartidos.
</p>
<div class="button-showcase"> <div class="button-showcase">
<div class="button-group"> <div class="button-group">
@@ -26,9 +24,7 @@
<app-button variant="secondary" [disabled]="true">Secondary</app-button> <app-button variant="secondary" [disabled]="true">Secondary</app-button>
<app-button variant="danger" [disabled]="true">Danger</app-button> <app-button variant="danger" [disabled]="true">Danger</app-button>
<app-button variant="danger-secondary" [disabled]="true"> <app-button variant="danger-secondary" [disabled]="true"> Danger secondary </app-button>
Danger secondary
</app-button>
<app-button variant="cancel" [disabled]="true">Cancel</app-button> <app-button variant="cancel" [disabled]="true">Cancel</app-button>
</div> </div>
</div> </div>
@@ -60,13 +56,8 @@
<div class="button-group"> <div class="button-group">
<h2>Toasts Persistentes (No se van)</h2> <h2>Toasts Persistentes (No se van)</h2>
<div class="button-grid"> <div class="button-grid">
<app-button (click)="triggerPersistentToast('info')"> <app-button (click)="triggerPersistentToast('info')"> Info Persistente </app-button>
Info Persistente <app-button variant="secondary" (click)="triggerPersistentToast('success')">
</app-button>
<app-button
variant="secondary"
(click)="triggerPersistentToast('success')"
>
Success Persistente Success Persistente
</app-button> </app-button>
<app-button variant="danger" (click)="triggerPersistentToast('danger')"> <app-button variant="danger" (click)="triggerPersistentToast('danger')">
@@ -91,9 +82,7 @@
<app-button variant="danger-secondary" (click)="openWideModal()"> <app-button variant="danger-secondary" (click)="openWideModal()">
Abrir modal ancho Abrir modal ancho
</app-button> </app-button>
<app-button (click)="openSimpleModal()"> <app-button (click)="openSimpleModal()"> Abrir simple modal </app-button>
Abrir simple modal
</app-button>
</div> </div>
<div class="modal-showcase__result" data-testid="modal-last-result"> <div class="modal-showcase__result" data-testid="modal-last-result">
{{ lastModalResult }} {{ lastModalResult }}
@@ -106,9 +95,9 @@
<div class="icon-button-showcase"> <div class="icon-button-showcase">
<h2>Icon Buttons</h2> <h2>Icon Buttons</h2>
<p class="text-muted mb-4"> <p class="text-muted mb-4">
Botones de icono pequenos con color base <code>#666666</code>, Botones de icono pequenos con color base <code>#666666</code>, deshabilitados con
deshabilitados con <code>#A0A0A0</code> y estados hover/active con color <code>#A0A0A0</code> y estados hover/active con color de marca (a excepcion de trash que
de marca (a excepcion de trash que usa danger). usa danger).
</p> </p>
<div class="icon-button-showcase__grid"> <div class="icon-button-showcase__grid">
@@ -155,9 +144,7 @@
<div class="icon-button-showcase mt-4"> <div class="icon-button-showcase mt-4">
<h2>Cart Icons</h2> <h2>Cart Icons</h2>
<p class="text-muted mb-4"> <p class="text-muted mb-4">Icono de carrito con badge opcional indicando cantidad.</p>
Icono de carrito con badge opcional indicando cantidad.
</p>
<div class="icon-button-showcase__grid"> <div class="icon-button-showcase__grid">
<div class="icon-button-showcase__row"> <div class="icon-button-showcase__row">
@@ -200,12 +187,7 @@
<div class="input-field"> <div class="input-field">
<label for="number-input">Numero</label> <label for="number-input">Numero</label>
<app-input <app-input id="number-input" type="number" placeholder="0" [(value)]="numberValue" />
id="number-input"
type="number"
placeholder="0"
[(value)]="numberValue"
/>
</div> </div>
<div class="input-field"> <div class="input-field">
@@ -220,12 +202,7 @@
<div class="input-field"> <div class="input-field">
<label for="user-input">Usuario</label> <label for="user-input">Usuario</label>
<app-input <app-input id="user-input" type="user" placeholder="Nombre de usuario" [(value)]="user" />
id="user-input"
type="user"
placeholder="Nombre de usuario"
[(value)]="user"
/>
</div> </div>
<div class="input-field"> <div class="input-field">
@@ -250,22 +227,12 @@
<div class="input-field"> <div class="input-field">
<label for="amount-input">Moneda</label> <label for="amount-input">Moneda</label>
<app-input <app-input id="amount-input" type="currency" placeholder="0" [(value)]="amount" />
id="amount-input"
type="currency"
placeholder="0"
[(value)]="amount"
/>
</div> </div>
<div class="input-field"> <div class="input-field">
<label for="percent-input">Porcentaje</label> <label for="percent-input">Porcentaje</label>
<app-input <app-input id="percent-input" type="percent" placeholder="0" [(value)]="percent" />
id="percent-input"
type="percent"
placeholder="0"
[(value)]="percent"
/>
</div> </div>
<div class="input-field"> <div class="input-field">
@@ -313,9 +280,7 @@
/> />
</div> </div>
<app-button variant="secondary" (click)="clearFile()"> <app-button variant="secondary" (click)="clearFile()"> Limpiar archivo </app-button>
Limpiar archivo
</app-button>
</div> </div>
</div> </div>
@@ -324,8 +289,7 @@
<div class="paginator-showcase"> <div class="paginator-showcase">
<h2>Paginator</h2> <h2>Paginator</h2>
<p class="text-muted mb-0"> <p class="text-muted mb-0">
Componente shared para navegar por paginas con primera, anterior, Componente shared para navegar por paginas con primera, anterior, siguiente y ultima.
siguiente y ultima.
</p> </p>
<app-paginator <app-paginator
[page]="currentPage" [page]="currentPage"
@@ -339,11 +303,11 @@
<div class="stepper-showcase"> <div class="stepper-showcase">
<h2>Form Stepper</h2> <h2>Form Stepper</h2>
<p class="text-muted mb-4"> <p class="text-muted mb-4">
Stepper vertical reutilizable. Cada paso maneja sus propios botones de navegacion. No se puede avanzar si el paso no es valido. Stepper vertical reutilizable. Cada paso maneja sus propios botones de navegacion. No se
puede avanzar si el paso no es valido.
</p> </p>
<app-stepper #demoStepper> <app-stepper #demoStepper>
<!-- Paso 1: Siempre valido --> <!-- Paso 1: Siempre valido -->
<app-step label="Informacion Personal" [isValid]="true"> <app-step label="Informacion Personal" [isValid]="true">
<div class="row g-3"> <div class="row g-3">
@@ -353,7 +317,12 @@
</div> </div>
<div class="col-md-6 input-field"> <div class="col-md-6 input-field">
<label for="stepper-email">Email</label> <label for="stepper-email">Email</label>
<app-input id="stepper-email" type="email" placeholder="mail@empresa.com" [(value)]="email" /> <app-input
id="stepper-email"
type="email"
placeholder="mail@empresa.com"
[(value)]="email"
/>
</div> </div>
</div> </div>
<div class="d-flex justify-content-end mt-4"> <div class="d-flex justify-content-end mt-4">
@@ -368,7 +337,12 @@
<div class="row g-3"> <div class="row g-3">
<div class="col-md-6 input-field"> <div class="col-md-6 input-field">
<label for="stepper-password">Contraseña</label> <label for="stepper-password">Contraseña</label>
<app-input id="stepper-password" type="password-toggle" placeholder="********" [(value)]="password" /> <app-input
id="stepper-password"
type="password-toggle"
placeholder="********"
[(value)]="password"
/>
</div> </div>
<div class="col-md-6 input-field"> <div class="col-md-6 input-field">
<label for="stepper-amount">Presupuesto mensual</label> <label for="stepper-amount">Presupuesto mensual</label>
@@ -377,9 +351,7 @@
</div> </div>
<div class="stepper-validity-control mt-3 d-flex align-items-center gap-3"> <div class="stepper-validity-control mt-3 d-flex align-items-center gap-3">
<span class="text-muted small"> <span class="text-muted small"> Simular validez del paso: </span>
Simular validez del paso:
</span>
<app-button <app-button
[variant]="testStep2Valid ? 'secondary' : 'primary'" [variant]="testStep2Valid ? 'secondary' : 'primary'"
(click)="testStep2Valid = !testStep2Valid" (click)="testStep2Valid = !testStep2Valid"
@@ -418,7 +390,6 @@
</app-button> </app-button>
</div> </div>
</app-step> </app-step>
</app-stepper> </app-stepper>
</div> </div>
@@ -454,7 +425,6 @@
</div> </div>
} }
</div> </div>
</div> </div>
</section> </section>
@@ -604,14 +574,32 @@
</div> </div>
</section> </section>
<section class="component-demo card shadow-sm mt-4">
<div class="card-body">
<span class="eyebrow">Reusable Component</span>
<h2 class="mb-4">Ticket</h2>
<p class="text-muted mb-4">Ticket responsive que ocupa el 100% del ancho disponible.</p>
<app-ticket
title="ENTRADA GENERAL"
ticketId="0000000000"
date="09 de Octubre"
(viewQr)="triggerToast('info')"
(download)="triggerToast('success')"
(share)="triggerToast('info')"
/>
</div>
</section>
<section class="component-demo card shadow-sm mt-4"> <section class="component-demo card shadow-sm mt-4">
<div class="card-body"> <div class="card-body">
<span class="eyebrow">Reusable Component</span> <span class="eyebrow">Reusable Component</span>
<h2 class="mb-4">Tarjetas de Producto (Column With Image)</h2> <h2 class="mb-4">Tarjetas de Producto (Column With Image)</h2>
<p class="text-muted mb-4"> <p class="text-muted mb-4">
Las tarjetas se adaptan al tamano del contenedor padre. A continuacion se Las tarjetas se adaptan al tamano del contenedor padre. A continuacion se presentan dentro
presentan dentro de una grilla de Bootstrap. de una grilla de Bootstrap.
</p> </p>
<div class="row"> <div class="row">
@@ -636,9 +624,7 @@
<span class="eyebrow">Reusable Component</span> <span class="eyebrow">Reusable Component</span>
<h2 class="mb-4">Tarjetas de Producto (Row Layout)</h2> <h2 class="mb-4">Tarjetas de Producto (Row Layout)</h2>
<p class="text-muted mb-4"> <p class="text-muted mb-4">Diseño horizontal que ocupa el 100% del ancho disponible.</p>
Diseño horizontal que ocupa el 100% del ancho disponible.
</p>
<div class="row"> <div class="row">
<div class="col-12 mb-4 d-flex align-items-stretch"> <div class="col-12 mb-4 d-flex align-items-stretch">
@@ -678,10 +664,7 @@
</div> </div>
</section> </section>
<app-store-section <app-store-section title="PRODUCTOS" class="component-demo store-section-demo mt-5">
title="PRODUCTOS"
class="component-demo store-section-demo mt-5"
>
<div class="row"> <div class="row">
@for (product of testProducts; track product.title) { @for (product of testProducts; track product.title) {
<div <div

View File

@@ -1,24 +1,20 @@
import { Component, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { ButtonComponent } from '../../../../shared/components/button/button.component'; import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { import { CartComponent, CartItemMock } from '../../../../shared/components/cart/cart.component';
CartComponent,
CartItemMock
} from '../../../../shared/components/cart/cart.component';
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component'; import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component'; import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
import { InputComponent } from '../../../../shared/components/input/input.component'; import { InputComponent } from '../../../../shared/components/input/input.component';
import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component'; import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component';
import { ProductColumnWithImageComponent } from '../../../../shared/components/product-column-with-image/product-column-with-image.component'; import { ProductColumnWithImageComponent } from '../../../../shared/components/product-column-with-image/product-column-with-image.component';
import { ProductRowCardComponent } from '../../../../shared/components/product-row-card/product-row-card.component'; import { ProductRowCardComponent } from '../../../../shared/components/product-row-card/product-row-card.component';
import { import { ProductVerticalWithCartCardComponent } from '../../../../shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component';
ProductVerticalWithCartCardComponent
} from '../../../../shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component';
import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component'; import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component';
import { ModalService } from '../../../../core/services/modal.service'; import { ModalService } from '../../../../core/services/modal.service';
import { TenantService } from '../../../../core/services/tenant.service'; import { TenantService } from '../../../../core/services/tenant.service';
import { ToastService } from '../../../../core/services/toast.service'; import { ToastService } from '../../../../core/services/toast.service';
import { StepperComponent } from '../../../../shared/components/stepper/stepper.component'; import { StepperComponent } from '../../../../shared/components/stepper/stepper.component';
import { StepComponent } from '../../../../shared/components/stepper/step.component'; import { StepComponent } from '../../../../shared/components/stepper/step.component';
import { TicketComponent } from '../../../../shared/components/ticket/ticket.component';
@Component({ @Component({
selector: 'app-reutilizables-test-page', selector: 'app-reutilizables-test-page',
@@ -34,10 +30,11 @@ import { StepComponent } from '../../../../shared/components/stepper/step.compon
IconButtonComponent, IconButtonComponent,
CartIconComponent, CartIconComponent,
StepperComponent, StepperComponent,
StepComponent StepComponent,
TicketComponent,
], ],
templateUrl: './reutilizables-test-page.component.html', templateUrl: './reutilizables-test-page.component.html',
styleUrl: './reutilizables-test-page.component.scss' styleUrl: './reutilizables-test-page.component.scss',
}) })
export class ReutilizablesTestPageComponent { export class ReutilizablesTestPageComponent {
private readonly tenantService = inject(TenantService); private readonly tenantService = inject(TenantService);
@@ -74,38 +71,39 @@ export class ReutilizablesTestPageComponent {
originalPrice: 95000, originalPrice: 95000,
discount: 20, discount: 20,
transferPrice: 74800, transferPrice: 74800,
imageUrl: '/images/pantalon-scuba.png' imageUrl: '/images/pantalon-scuba.png',
}, },
{ {
title: 'Zapatillas de running azul', title: 'Zapatillas de running azul',
originalPrice: 120000, originalPrice: 120000,
discount: 15, discount: 15,
transferPrice: 98000, transferPrice: 98000,
imageUrl: '/images/zapatillas-running.png' imageUrl: '/images/zapatillas-running.png',
}, },
{ {
title: 'Remera basica blanca (Sin Descuento)', title: 'Remera basica blanca (Sin Descuento)',
originalPrice: 32000, originalPrice: 32000,
discount: null, discount: null,
transferPrice: 30000, transferPrice: 30000,
imageUrl: null imageUrl: null,
} },
]; ];
protected readonly testRowProduct = { protected readonly testRowProduct = {
title: 'ENTRADA GENERAL', title: 'ENTRADA GENERAL',
description: 'Acceso total al predio. No incluye acceso a estacionamiento. Niños menores de 5 años ingresan gratis.', description:
'Acceso total al predio. No incluye acceso a estacionamiento. Niños menores de 5 años ingresan gratis.',
price: 10000, price: 10000,
variants: [ variants: [
{ label: '10 de Octubre', value: '10-oct' }, { label: '10 de Octubre', value: '10-oct' },
{ label: '11 de Octubre', value: '11-oct' } { label: '11 de Octubre', value: '11-oct' },
] ],
}; };
protected readonly testVerticalWithCartProduct = { protected readonly testVerticalWithCartProduct = {
title: 'PANCHO', title: 'PANCHO',
description: 'Pancho con aderezo a elección.', description: 'Pancho con aderezo a elección.',
price: 11500 price: 11500,
}; };
protected readonly cartMockItems: CartItemMock[] = [ protected readonly cartMockItems: CartItemMock[] = [
@@ -118,8 +116,8 @@ export class ReutilizablesTestPageComponent {
quantity: 1, quantity: 1,
attributes: [ attributes: [
{ label: 'Color', value: 'Beige' }, { label: 'Color', value: 'Beige' },
{ label: 'Talle', value: 'S' } { label: 'Talle', value: 'S' },
] ],
}, },
{ {
imageUrl: null, imageUrl: null,
@@ -130,8 +128,8 @@ export class ReutilizablesTestPageComponent {
quantity: 1, quantity: 1,
attributes: [ attributes: [
{ label: 'Color', value: 'Beige' }, { label: 'Color', value: 'Beige' },
{ label: 'Talle', value: 'S' } { label: 'Talle', value: 'S' },
] ],
}, },
{ {
imageUrl: null, imageUrl: null,
@@ -142,8 +140,8 @@ export class ReutilizablesTestPageComponent {
quantity: 1, quantity: 1,
attributes: [ attributes: [
{ label: 'Color', value: 'Negro' }, { label: 'Color', value: 'Negro' },
{ label: 'Talle', value: 'XL' } { label: 'Talle', value: 'XL' },
] ],
}, },
{ {
imageUrl: null, imageUrl: null,
@@ -154,9 +152,9 @@ export class ReutilizablesTestPageComponent {
quantity: 2, quantity: 2,
attributes: [ attributes: [
{ label: 'Color', value: 'Gris' }, { label: 'Color', value: 'Gris' },
{ label: 'Talle', value: 'M' } { label: 'Talle', value: 'M' },
] ],
} },
]; ];
protected onProductBuy(productTitle: string): void { protected onProductBuy(productTitle: string): void {
@@ -184,45 +182,32 @@ export class ReutilizablesTestPageComponent {
protected triggerToast(type: 'success' | 'danger' | 'info'): void { protected triggerToast(type: 'success' | 'danger' | 'info'): void {
if (type === 'success') { if (type === 'success') {
this.toastService.success( this.toastService.success('Operacion exitosa. El producto se ha guardado correctamente.');
'Operacion exitosa. El producto se ha guardado correctamente.'
);
} else if (type === 'danger') { } else if (type === 'danger') {
this.toastService.danger( this.toastService.danger('Alerta de error. Hubo un problema al procesar la solicitud.');
'Alerta de error. Hubo un problema al procesar la solicitud.'
);
} else if (type === 'info') { } else if (type === 'info') {
this.toastService.info( this.toastService.info('Informacion del sistema: Hay una nueva actualizacion disponible.');
'Informacion del sistema: Hay una nueva actualizacion disponible.'
);
} }
} }
protected triggerPersistentToast(type: 'success' | 'danger' | 'info'): void { protected triggerPersistentToast(type: 'success' | 'danger' | 'info'): void {
if (type === 'success') { if (type === 'success') {
this.toastService.success( this.toastService.success('Exito persistente. Esta alerta no se cerrara sola.', 0);
'Exito persistente. Esta alerta no se cerrara sola.',
0
);
} else if (type === 'danger') { } else if (type === 'danger') {
this.toastService.danger( this.toastService.danger(
'Error persistente. Por favor, atienda este problema y cierrelo manualmente.', 'Error persistente. Por favor, atienda este problema y cierrelo manualmente.',
0 0,
); );
} else if (type === 'info') { } else if (type === 'info') {
this.toastService.info( this.toastService.info('Informacion persistente: Mantendremos este aviso en pantalla.', 0);
'Informacion persistente: Mantendremos este aviso en pantalla.',
0
);
} }
} }
protected openBasicModal(): void { protected openBasicModal(): void {
this.openConfirmModal({ this.openConfirmModal({
title: 'Confirmar accion', title: 'Confirmar accion',
content: content: 'Caso base para verificar apertura, cierre y devolucion de resultado.',
'Caso base para verificar apertura, cierre y devolucion de resultado.', confirmLabel: 'Confirmar',
confirmLabel: 'Confirmar'
}); });
} }
@@ -231,18 +216,17 @@ export class ReutilizablesTestPageComponent {
title: 'Eliminar producto', title: 'Eliminar producto',
content: content:
'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',
}); });
} }
protected openLockedModal(): void { protected openLockedModal(): void {
this.openConfirmModal({ this.openConfirmModal({
title: 'Modal bloqueado', title: 'Modal bloqueado',
content: content: 'Este modal no se cierra tocando el backdrop ni con la tecla Escape.',
'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,
}); });
} }
@@ -251,16 +235,18 @@ export class ReutilizablesTestPageComponent {
title: 'Modal ancho', title: 'Modal ancho',
content: 'Demuestra una variante visual mas amplia para contenido mas pesado.', content: 'Demuestra una variante visual mas amplia para contenido mas pesado.',
confirmLabel: 'Seguir', confirmLabel: 'Seguir',
size: 'xl' size: 'xl',
}); });
} }
protected openSimpleModal(): void { protected openSimpleModal(): void {
this.modalService.openSimple({ this.modalService
.openSimple({
title: 'Mensaje del sistema', title: 'Mensaje del sistema',
content: 'Este es un mensaje simple del sistema que no requiere confirmación.', content: 'Este es un mensaje simple del sistema que no requiere confirmación.',
buttonLabel: 'Entendido' buttonLabel: 'Entendido',
}).subscribe(() => { })
.subscribe(() => {
this.lastModalResult = 'Simple modal cerrado'; this.lastModalResult = 'Simple modal cerrado';
}); });
} }
@@ -271,12 +257,9 @@ export class ReutilizablesTestPageComponent {
}); });
} }
private openConfirmDelete( private openConfirmDelete(config: Parameters<ModalService['openConfirmDelete']>[0]): void {
config: Parameters<ModalService['openConfirmDelete']>[0]
): void {
this.modalService.openConfirmDelete(config).subscribe((confirmed) => { this.modalService.openConfirmDelete(config).subscribe((confirmed) => {
this.lastModalResult = `Resultado: ${confirmed}`; this.lastModalResult = `Resultado: ${confirmed}`;
}); });
} }
} }

View File

@@ -1,15 +1,15 @@
<article class="w-100 py-3 rounded-0 cart-item"> <article class="w-100 py-3 rounded-0 cart-item" [class.cart-item--without-image]="!item.imageUrl">
@if (item.imageUrl) {
<div class="position-relative overflow-hidden cart-item-media"> <div class="position-relative overflow-hidden cart-item-media">
@if (item.discountPercentage) { @if (item.discountPercentage) {
<span class="position-absolute top-0 start-0 z-1 rounded-0 cart-item-discount-badge">-{{ item.discountPercentage }}%</span> <span class="position-absolute top-0 start-0 z-1 rounded-0 cart-item-discount-badge"
>-{{ item.discountPercentage }}%</span
>
} }
@if (item.imageUrl) {
<img class="w-100 h-100 object-fit-cover d-block" [src]="item.imageUrl" [alt]="item.title" /> <img class="w-100 h-100 object-fit-cover d-block" [src]="item.imageUrl" [alt]="item.title" />
} @else {
<div class="w-100 h-100 cart-item-placeholder" aria-hidden="true"></div>
}
</div> </div>
}
<div class="d-flex flex-column justify-content-between w-100 h-100 py-1 cart-item-content"> <div class="d-flex flex-column justify-content-between w-100 h-100 py-1 cart-item-content">
<div class="d-grid cart-item-top-row align-items-start"> <div class="d-grid cart-item-top-row align-items-start">
@@ -17,19 +17,24 @@
<div class="cart-item-prices"> <div class="cart-item-prices">
@if (item.originalPrice) { @if (item.originalPrice) {
<span class="text-decoration-line-through cart-item-original-price">$ {{ item.originalPrice }}</span> <span class="text-decoration-line-through cart-item-original-price"
>$ {{ item.originalPrice }}</span
>
} }
<span class="fw-bold cart-item-discounted-price">$ {{ item.price }}</span> <span class="fw-bold cart-item-discounted-price">$ {{ item.price }}</span>
</div> </div>
</div> </div>
@if (item.imageUrl) {
<div <div
class="d-grid cart-item-attributes" class="d-grid cart-item-attributes"
[class.cart-item-attributes--quantity-only]="item.attributes.length === 0" [class.cart-item-attributes--quantity-only]="item.attributes.length === 0"
> >
<div class="cart-item-attribute"> <div class="cart-item-attribute">
<span class="cart-item-attribute-label">Cantidad: </span> <span class="cart-item-attribute-label">Cantidad: </span>
<span class="fw-bold">{{ item.quantity }} unidad{{ item.quantity === 1 ? '' : 'es' }}</span> <span class="fw-bold"
>{{ item.quantity }} unidad{{ item.quantity === 1 ? '' : 'es' }}</span
>
</div> </div>
@for (attribute of item.attributes; track attribute.label + attribute.value) { @for (attribute of item.attributes; track attribute.label + attribute.value) {
@@ -46,5 +51,24 @@
<span class="fw-bold">${{ item.transferPrice }}</span> <span class="fw-bold">${{ item.transferPrice }}</span>
</div> </div>
} }
} @else {
<p class="m-0 cart-item-description">{{ item.description }}</p>
<div class="d-grid cart-item-bottom-details">
@for (attribute of item.attributes; track attribute.label + attribute.value) {
<div class="cart-item-attribute">
<span class="cart-item-attribute-label">{{ attribute.label }}: </span>
<span class="fw-bold">{{ attribute.value }}</span>
</div>
}
<div class="cart-item-attribute">
<span class="cart-item-attribute-label">Cantidad: </span>
<span class="fw-bold"
>{{ item.quantity }} unidad{{ item.quantity === 1 ? '' : 'es' }}</span
>
</div>
</div>
}
</div> </div>
</article> </article>

View File

@@ -13,6 +13,10 @@
gap: 0.75rem; gap: 0.75rem;
background-color: transparent; background-color: transparent;
position: relative; position: relative;
&--without-image {
grid-template-columns: minmax(0, 1fr);
}
} }
.cart-item-media { .cart-item-media {
@@ -44,12 +48,6 @@
line-height: 1; line-height: 1;
} }
.cart-item-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #e0e0e0, #f5f5f5);
}
.cart-item-content { .cart-item-content {
gap: 0.5rem; gap: 0.5rem;
} }
@@ -116,3 +114,16 @@
display: grid; display: grid;
justify-items: end; justify-items: end;
} }
.cart-item-description {
color: #666666;
font-size: 9px;
font-weight: 325;
line-height: 1.35;
white-space: pre-line;
}
.cart-item-bottom-details {
gap: 0.125rem;
justify-items: end;
}

View File

@@ -0,0 +1,68 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import { PurchaseItem, PurchaseItemViewModel } from './purchase-item';
describe('PurchaseItem', () => {
let fixture: ComponentFixture<PurchaseItem>;
const item: PurchaseItemViewModel = {
id: 1,
title: 'Entrada general',
description: 'Acceso al evento durante toda la jornada.',
imageUrl: null,
quantity: 2,
attributes: [{ label: 'Turno', value: 'Tarde' }],
price: '20000.00',
transferPrice: '18000.00',
};
beforeEach(async () => {
await TestBed.configureTestingModule({ imports: [PurchaseItem] }).compileComponents();
fixture = TestBed.createComponent(PurchaseItem);
});
it('uses the image-free layout and moves description and item details', () => {
fixture.componentInstance.item = item;
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(
element.querySelector('.cart-item')?.classList.contains('cart-item--without-image'),
).toBe(true);
expect(element.querySelector('.cart-item-media')).toBeNull();
expect(element.querySelector('.cart-item-description')?.textContent?.trim()).toBe(
item.description,
);
expect(element.querySelector('.cart-item-bottom-details')?.textContent).toContain(
'Turno: Tarde',
);
expect(element.querySelector('.cart-item-bottom-details')?.textContent).toContain(
'Cantidad: 2 unidades',
);
expect(element.querySelector('.cart-item-transfer-price')).toBeNull();
});
it('keeps the existing layout when the product has an image', () => {
fixture.componentInstance.item = {
...item,
imageUrl: 'https://example.com/product.png',
};
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(
element.querySelector('.cart-item')?.classList.contains('cart-item--without-image'),
).toBe(false);
expect(element.querySelector('.cart-item-media img')).not.toBeNull();
expect(element.querySelector('.cart-item-description')).toBeNull();
expect(element.querySelector('.cart-item-attributes')?.textContent).toContain(
'Cantidad: 2 unidades',
);
expect(element.querySelector('.cart-item-transfer-price')?.textContent).toContain(
'Precio por transferencia',
);
});
});

View File

@@ -3,6 +3,7 @@ import { Component, Input } from '@angular/core';
export type PurchaseItemViewModel = { export type PurchaseItemViewModel = {
id: number; id: number;
title: string; title: string;
description: string | null;
imageUrl: string | null; imageUrl: string | null;
quantity: number; quantity: number;
attributes: Array<{ attributes: Array<{

View File

@@ -2,7 +2,10 @@ import { CommonModule } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core'; import { Component, OnInit, inject, signal } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { CheckoutService, PurchaseDetailResponse } from '../../../../../../core/services/checkout.service'; import {
CheckoutService,
PurchaseDetailResponse,
} from '../../../../../../core/services/checkout.service';
import { TenantService } from '../../../../../../core/services/tenant.service'; import { TenantService } from '../../../../../../core/services/tenant.service';
import { ToastService } from '../../../../../../core/services/toast.service'; import { ToastService } from '../../../../../../core/services/toast.service';
import { PurchaseItem, PurchaseItemViewModel } from '../../components/purchase-item/purchase-item'; import { PurchaseItem, PurchaseItemViewModel } from '../../components/purchase-item/purchase-item';
@@ -79,12 +82,13 @@ export class PurchaseDetailPage implements OnInit {
private mapItems(purchase: PurchaseDetailResponse): PurchaseItemViewModel[] { private mapItems(purchase: PurchaseDetailResponse): PurchaseItemViewModel[] {
return purchase.items.map((item) => ({ return purchase.items.map((item) => ({
id: item.id, id: item.id,
title: item.product?.nombre ?? 'Producto sin nombre', title: item.item_details.nombre || 'Producto sin nombre',
imageUrl: item.product?.imagen ?? null, description: item.item_details.descripcion,
imageUrl: item.item_details.imagen,
quantity: item.quantity, quantity: item.quantity,
attributes: (item.variant?.attributes ?? []).map((attribute) => ({ attributes: item.item_details.attributes.map((attribute) => ({
label: attribute.name, label: attribute.name,
value: attribute.value, value: attribute.value === null ? null : String(attribute.value),
})), })),
price: item.line_total, price: item.line_total,
originalPrice: null, originalPrice: null,

View File

@@ -19,11 +19,15 @@
[transferDni]="transferDni()" [transferDni]="transferDni()"
[isGeneratingIntent]="isPaymentLoading()" [isGeneratingIntent]="isPaymentLoading()"
[qrData]="qrData()" [qrData]="qrData()"
[qrPaymentStatus]="qrPaymentStatus()"
[isCheckingQrPayment]="isCheckingQrPayment()"
[transferValidationStatus]="transferValidationStatus()"
(paymentMethodChange)="selectPaymentMethod($event)" (paymentMethodChange)="selectPaymentMethod($event)"
(copyTransferValue)="copyTransferValue($event.field, $event.value)" (copyTransferValue)="copyTransferValue($event.field, $event.value)"
(cancelStep)="onCancel()" (cancelStep)="onCancel()"
(generateTransferIntent)="generateTransferIntent($event)" (generateTransferIntent)="generateTransferIntent($event)"
(complete)="onComplete()" (complete)="onComplete()"
(retryQrPolling)="retryQrPolling()"
/> />
</app-step> </app-step>
</app-stepper> </app-stepper>

View File

@@ -0,0 +1,200 @@
import { signal } from '@angular/core';
import { getTestBed, TestBed } from '@angular/core/testing';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { Router } from '@angular/router';
import { of } from 'rxjs';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthService } from '../../../../core/services/auth/auth.service';
import { CartService } from '../../../../core/services/cart/cart.service';
import { CheckoutService } from '../../../../core/services/checkout.service';
import { TenantService } from '../../../../core/services/tenant.service';
import { CheckoutPageComponent } from './checkout-page.component';
describe('CheckoutPageComponent payment validation', () => {
let checkoutServiceStub: {
generatePaymentIntent: ReturnType<typeof vi.fn>;
getPurchase: ReturnType<typeof vi.fn>;
};
let cartServiceStub: {
cart: ReturnType<typeof signal>;
isUpdating: ReturnType<typeof signal<boolean>>;
loadCart: ReturnType<typeof vi.fn>;
clearCart: ReturnType<typeof vi.fn>;
};
let routerStub: { navigate: ReturnType<typeof vi.fn> };
beforeAll(() => {
try {
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
});
beforeEach(async () => {
vi.useFakeTimers();
checkoutServiceStub = {
generatePaymentIntent: vi.fn().mockResolvedValue({
qr_data: { qr_code: 'qr-value' },
}),
getPurchase: vi.fn().mockResolvedValue({ status: 'pending_payment' }),
};
cartServiceStub = {
cart: signal({
id: 10,
tenant_codigo: 'tenant-test',
status: 'active',
items: [],
subtotal: '0.00',
}),
isUpdating: signal(false),
loadCart: vi.fn().mockReturnValue(of({})),
clearCart: vi.fn(),
};
cartServiceStub.clearCart.mockImplementation(() => {
cartServiceStub.cart.set({
id: null,
tenant_codigo: 'tenant-test',
status: 'active',
items: [],
subtotal: '0.00',
});
});
routerStub = { navigate: vi.fn() };
await TestBed.configureTestingModule({
imports: [CheckoutPageComponent],
providers: [
{ provide: CheckoutService, useValue: checkoutServiceStub },
{ provide: CartService, useValue: cartServiceStub },
{ provide: TenantService, useValue: { tenant: signal({ codigo: 'tenant-test' }) } },
{ provide: AuthService, useValue: { user: signal(null) } },
{ provide: Router, useValue: routerStub },
],
})
.overrideComponent(CheckoutPageComponent, { set: { template: '' } })
.compileComponents();
});
afterEach(() => {
vi.useRealTimers();
TestBed.resetTestingModule();
});
function createComponent(): any {
const fixture = TestBed.createComponent(CheckoutPageComponent);
fixture.detectChanges();
fixture.componentInstance['createdPurchaseId'].set(25);
return { fixture, component: fixture.componentInstance as any };
}
it('polls QR after five seconds and navigates only when payment is paid', async () => {
checkoutServiceStub.getPurchase
.mockResolvedValueOnce({ status: 'pending_payment' })
.mockResolvedValueOnce({ status: 'paid' });
const { component } = createComponent();
await component.selectPaymentMethod('qr');
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(5_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(1);
expect(routerStub.navigate).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(5_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(2);
expect(cartServiceStub.clearCart).toHaveBeenCalledOnce();
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
expect(checkoutServiceStub.generatePaymentIntent).toHaveBeenCalledTimes(1);
});
it('shows the QR verification state while each status request is in progress', async () => {
let resolvePurchase!: (purchase: { status: string }) => void;
checkoutServiceStub.getPurchase.mockReturnValue(
new Promise((resolve) => {
resolvePurchase = resolve;
}),
);
const { fixture, component } = createComponent();
const statusRequest = component.checkQrPayment(component.qrPollingRunId);
expect(component.isCheckingQrPayment()).toBe(true);
resolvePurchase({ status: 'pending_payment' });
await statusRequest;
expect(component.isCheckingQrPayment()).toBe(false);
fixture.destroy();
});
it('stops QR polling after five minutes and can restart it', async () => {
const { component } = createComponent();
await component.selectPaymentMethod('qr');
await vi.advanceTimersByTimeAsync(300_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(60);
expect(component.qrPaymentStatus()).toBe('timed_out');
component.retryQrPolling();
expect(component.qrPaymentStatus()).toBe('waiting');
await vi.advanceTimersByTimeAsync(5_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(61);
});
it('cancels QR polling when payment method changes or component is destroyed', async () => {
const first = createComponent();
await first.component.selectPaymentMethod('qr');
await first.component.selectPaymentMethod('transfer');
await vi.advanceTimersByTimeAsync(10_000);
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
const second = createComponent();
await second.component.selectPaymentMethod('qr');
second.fixture.destroy();
await vi.advanceTimersByTimeAsync(10_000);
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
});
it('checks a transfer once and redirects to purchase status while pending', async () => {
const { component } = createComponent();
component.selectedPaymentMethod.set('transfer');
await component.onComplete();
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(1);
expect(component.transferValidationStatus()).toBe('pending');
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
await vi.advanceTimersByTimeAsync(30_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(1);
await component.onComplete();
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(1);
});
it('navigates after a transfer is confirmed as paid', async () => {
checkoutServiceStub.getPurchase.mockResolvedValue({ status: 'paid' });
const { component } = createComponent();
component.selectedPaymentMethod.set('transfer');
await component.onComplete();
expect(cartServiceStub.clearCart).toHaveBeenCalledOnce();
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
});
it('shows a retryable state when transfer validation fails', async () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined);
checkoutServiceStub.getPurchase.mockRejectedValue(new Error('network error'));
const { component } = createComponent();
component.selectedPaymentMethod.set('transfer');
await component.onComplete();
expect(component.transferValidationStatus()).toBe('error');
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(routerStub.navigate).not.toHaveBeenCalled();
});
});

View File

@@ -4,6 +4,7 @@ import {
computed, computed,
effect, effect,
inject, inject,
OnDestroy,
OnInit, OnInit,
signal, signal,
untracked, untracked,
@@ -28,8 +29,10 @@ import {
CheckoutForm, CheckoutForm,
PaymentMethod, PaymentMethod,
PaymentMethodOption, PaymentMethodOption,
QrPaymentStatus,
TransferAccount, TransferAccount,
TransferField, TransferField,
TransferValidationStatus,
} from './checkout-page.models'; } from './checkout-page.models';
@Component({ @Component({
@@ -46,13 +49,21 @@ import {
styleUrl: './checkout-page.component.scss', styleUrl: './checkout-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class CheckoutPageComponent implements OnInit { export class CheckoutPageComponent implements OnInit, OnDestroy {
private readonly formBuilder = inject(FormBuilder); private readonly formBuilder = inject(FormBuilder);
private readonly cartService = inject(CartService); private readonly cartService = inject(CartService);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly tenantService = inject(TenantService); private readonly tenantService = inject(TenantService);
private readonly checkoutService = inject(CheckoutService); private readonly checkoutService = inject(CheckoutService);
private readonly authService = inject(AuthService); private readonly authService = inject(AuthService);
private readonly qrPollingIntervalMs = 5_000;
private readonly qrPollingMaxAttempts = 60;
private qrPollingTimeoutId: ReturnType<typeof setTimeout> | null = null;
private qrPollingAttempts = 0;
private qrPollingRunId = 0;
private paymentMethodRequestId = 0;
private navigationStarted = false;
@ViewChild(StepperComponent) stepper!: StepperComponent; @ViewChild(StepperComponent) stepper!: StepperComponent;
@@ -96,6 +107,9 @@ export class CheckoutPageComponent implements OnInit {
() => this.isGeneratingIntent() || this.cartService.isUpdating(), () => this.isGeneratingIntent() || this.cartService.isUpdating(),
); );
protected readonly qrData = signal<string | null>(null); protected readonly qrData = signal<string | null>(null);
protected readonly qrPaymentStatus = signal<QrPaymentStatus>('idle');
protected readonly isCheckingQrPayment = signal(false);
protected readonly transferValidationStatus = signal<TransferValidationStatus>('idle');
constructor() { constructor() {
effect(() => { effect(() => {
@@ -105,7 +119,7 @@ export class CheckoutPageComponent implements OnInit {
untracked(() => { untracked(() => {
const purchaseId = this.createdPurchaseId(); const purchaseId = this.createdPurchaseId();
if (cart && purchaseId) { if (cart && purchaseId && !this.navigationStarted) {
// Trigger payment intent generation when cart changes and we are on the payment step // Trigger payment intent generation when cart changes and we are on the payment step
void this.selectPaymentMethod(this.selectedPaymentMethod()); void this.selectPaymentMethod(this.selectedPaymentMethod());
} }
@@ -133,6 +147,10 @@ export class CheckoutPageComponent implements OnInit {
this.cartService.loadCart().subscribe(); this.cartService.loadCart().subscribe();
} }
ngOnDestroy(): void {
this.stopQrPolling();
}
private mapCartItemToMock(item: CartItem): CartItemMock { private mapCartItemToMock(item: CartItem): CartItemMock {
const fullName = item.product?.nombre ?? ''; const fullName = item.product?.nombre ?? '';
let product = fullName; let product = fullName;
@@ -202,11 +220,24 @@ export class CheckoutPageComponent implements OnInit {
} }
protected onCancel(): void { protected onCancel(): void {
this.stopQrPolling();
void this.router.navigate(['/']); void this.router.navigate(['/']);
} }
protected async selectPaymentMethod(method: PaymentMethod): Promise<void> { protected async selectPaymentMethod(method: PaymentMethod): Promise<void> {
if (this.navigationStarted) {
return;
}
this.stopQrPolling();
this.qrPaymentStatus.set('idle');
this.transferValidationStatus.set('idle');
this.selectedPaymentMethod.set(method); this.selectedPaymentMethod.set(method);
const requestId = ++this.paymentMethodRequestId;
if (method === 'qr') {
this.qrData.set(null);
}
const purchaseId = this.createdPurchaseId(); const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant(); const tenant = this.tenantService.tenant();
@@ -230,7 +261,12 @@ export class CheckoutPageComponent implements OnInit {
); );
if (method === 'qr' && response.qr_data?.qr_code) { if (method === 'qr' && response.qr_data?.qr_code) {
if (requestId !== this.paymentMethodRequestId || this.selectedPaymentMethod() !== 'qr') {
return;
}
this.qrData.set(response.qr_data.qr_code); this.qrData.set(response.qr_data.qr_code);
this.startQrPolling();
} }
} catch (error) { } catch (error) {
console.error('Failed to generate payment intent:', error); console.error('Failed to generate payment intent:', error);
@@ -248,6 +284,7 @@ export class CheckoutPageComponent implements OnInit {
} }
this.transferDni.set(dni); this.transferDni.set(dni);
this.transferValidationStatus.set('idle');
this.isGeneratingIntent.set(true); this.isGeneratingIntent.set(true);
try { try {
const response = await this.checkoutService.generatePaymentIntent( const response = await this.checkoutService.generatePaymentIntent(
@@ -293,16 +330,140 @@ export class CheckoutPageComponent implements OnInit {
const purchaseId = this.createdPurchaseId(); const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant(); const tenant = this.tenantService.tenant();
if (!purchaseId || !tenant) { if (
!purchaseId ||
!tenant ||
this.navigationStarted ||
this.transferValidationStatus() === 'checking'
) {
return; return;
} }
this.transferValidationStatus.set('checking');
try { try {
await this.checkoutService.completePurchase(tenant.codigo, purchaseId); const purchase = await this.checkoutService.getPurchase(tenant.codigo, purchaseId);
this.cartService.clearCart();
void this.router.navigate(['/checkout/status', purchaseId]); if (purchase.status === 'paid') {
} catch (error) { this.handleConfirmedPayment(purchaseId);
console.error('Failed to complete purchase:', error); return;
} }
if (purchase.status === 'pending_payment') {
this.transferValidationStatus.set('pending');
this.navigateToPurchaseStatus(purchaseId);
return;
}
this.transferValidationStatus.set('pending');
} catch (error) {
console.error('Failed to validate transfer payment:', error);
this.transferValidationStatus.set('error');
}
}
protected retryQrPolling(): void {
if (this.selectedPaymentMethod() === 'qr' && this.qrData()) {
this.startQrPolling();
}
}
private startQrPolling(): void {
this.stopQrPolling();
this.qrPollingAttempts = 0;
this.qrPaymentStatus.set('waiting');
const runId = this.qrPollingRunId;
this.scheduleQrPoll(runId);
}
private scheduleQrPoll(runId: number): void {
this.qrPollingTimeoutId = setTimeout(() => {
this.qrPollingTimeoutId = null;
void this.checkQrPayment(runId);
}, this.qrPollingIntervalMs);
}
private async checkQrPayment(runId: number): Promise<void> {
if (runId !== this.qrPollingRunId) {
return;
}
const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant();
if (!purchaseId || !tenant || this.selectedPaymentMethod() !== 'qr') {
this.stopQrPolling();
return;
}
this.qrPollingAttempts += 1;
this.isCheckingQrPayment.set(true);
try {
const purchase = await this.checkoutService.getPurchase(tenant.codigo, purchaseId);
if (runId !== this.qrPollingRunId) {
return;
}
if (purchase.status === 'paid') {
this.handleConfirmedPayment(purchaseId);
return;
}
if (purchase.status === 'rejected' || purchase.status === 'cancelled') {
this.stopQrPolling();
this.qrPaymentStatus.set('failed');
return;
}
} catch (error) {
console.error('Failed to validate QR payment:', error);
} finally {
if (runId === this.qrPollingRunId) {
this.isCheckingQrPayment.set(false);
}
}
if (runId !== this.qrPollingRunId) {
return;
}
if (this.qrPollingAttempts >= this.qrPollingMaxAttempts) {
this.stopQrPolling();
this.qrPaymentStatus.set('timed_out');
return;
}
this.scheduleQrPoll(runId);
}
private stopQrPolling(): void {
this.qrPollingRunId += 1;
this.isCheckingQrPayment.set(false);
if (this.qrPollingTimeoutId !== null) {
clearTimeout(this.qrPollingTimeoutId);
this.qrPollingTimeoutId = null;
}
}
private handleConfirmedPayment(purchaseId: number): void {
this.navigateToPurchaseStatus(purchaseId, true);
}
private navigateToPurchaseStatus(purchaseId: number, clearCart = false): void {
if (this.navigationStarted) {
return;
}
this.navigationStarted = true;
this.stopQrPolling();
if (clearCart) {
this.cartService.clearCart();
}
void this.router.navigate(['/checkout/status', purchaseId]);
} }
} }

View File

@@ -2,6 +2,8 @@ import { FormControl, FormGroup } from '@angular/forms';
export type PaymentMethod = 'qr' | 'transfer' | 'telepagos'; export type PaymentMethod = 'qr' | 'transfer' | 'telepagos';
export type TransferField = 'cvu' | 'alias'; export type TransferField = 'cvu' | 'alias';
export type QrPaymentStatus = 'idle' | 'waiting' | 'timed_out' | 'failed';
export type TransferValidationStatus = 'idle' | 'checking' | 'pending' | 'error';
export interface PaymentMethodOption { export interface PaymentMethodOption {
id: PaymentMethod; id: PaymentMethod;

View File

@@ -40,12 +40,18 @@
<h3 class="payment-panel__title">Cargando información de pago...</h3> <h3 class="payment-panel__title">Cargando información de pago...</h3>
</div> </div>
} @else if (selectedPaymentMethod() === 'qr') { } @else if (selectedPaymentMethod() === 'qr') {
<app-checkout-payment-qr [qrData]="qrData()" /> <app-checkout-payment-qr
[qrData]="qrData()"
[paymentStatus]="qrPaymentStatus()"
[isCheckingPayment]="isCheckingQrPayment()"
(retryPolling)="retryQrPolling.emit()"
/>
} @else if (selectedPaymentMethod() === 'transfer') { } @else if (selectedPaymentMethod() === 'transfer') {
<app-checkout-payment-transfer <app-checkout-payment-transfer
[transferAccount]="transferAccount()" [transferAccount]="transferAccount()"
[transferDni]="transferDni()" [transferDni]="transferDni()"
[copiedTransferField]="copiedTransferField()" [copiedTransferField]="copiedTransferField()"
[validationStatus]="transferValidationStatus()"
(copyTransferValue)="requestCopy($event.field, $event.value)" (copyTransferValue)="requestCopy($event.field, $event.value)"
(submitDni)="generateTransferIntent.emit($event)" (submitDni)="generateTransferIntent.emit($event)"
(completePurchase)="complete.emit()" (completePurchase)="complete.emit()"

View File

@@ -1,15 +1,21 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
import { CheckoutPaymentQrComponent } from './components/checkout-payment-qr/checkout-payment-qr.component'; import { CheckoutPaymentQrComponent } from './components/checkout-payment-qr/checkout-payment-qr.component';
import { CheckoutPaymentTelepagosComponent } from './components/checkout-payment-telepagos/checkout-payment-telepagos.component'; import { CheckoutPaymentTelepagosComponent } from './components/checkout-payment-telepagos/checkout-payment-telepagos.component';
import { CheckoutPaymentTransferComponent } from './components/checkout-payment-transfer/checkout-payment-transfer.component'; import { CheckoutPaymentTransferComponent } from './components/checkout-payment-transfer/checkout-payment-transfer.component';
import { PaymentMethod, PaymentMethodOption, TransferAccount, TransferField } from './checkout-page.models'; import {
PaymentMethod,
PaymentMethodOption,
QrPaymentStatus,
TransferAccount,
TransferField,
TransferValidationStatus,
} from './checkout-page.models';
@Component({ @Component({
selector: 'app-checkout-payment-step', selector: 'app-checkout-payment-step',
standalone: true, standalone: true,
imports: [ButtonComponent, CheckoutPaymentQrComponent, CheckoutPaymentTelepagosComponent, CheckoutPaymentTransferComponent], imports: [CheckoutPaymentQrComponent, CheckoutPaymentTelepagosComponent, CheckoutPaymentTransferComponent],
templateUrl: './checkout-payment-step.component.html', templateUrl: './checkout-payment-step.component.html',
styleUrl: './checkout-payment-step.component.scss', styleUrl: './checkout-payment-step.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@@ -22,12 +28,16 @@ export class CheckoutPaymentStepComponent {
readonly transferDni = input<string>(''); readonly transferDni = input<string>('');
readonly isGeneratingIntent = input<boolean>(false); readonly isGeneratingIntent = input<boolean>(false);
readonly qrData = input<string | null>(null); readonly qrData = input<string | null>(null);
readonly qrPaymentStatus = input<QrPaymentStatus>('idle');
readonly isCheckingQrPayment = input<boolean>(false);
readonly transferValidationStatus = input<TransferValidationStatus>('idle');
readonly paymentMethodChange = output<PaymentMethod>(); readonly paymentMethodChange = output<PaymentMethod>();
readonly copyTransferValue = output<{ field: TransferField; value: string }>(); readonly copyTransferValue = output<{ field: TransferField; value: string }>();
readonly cancelStep = output<void>(); readonly cancelStep = output<void>();
readonly complete = output<void>(); readonly complete = output<void>();
readonly generateTransferIntent = output<string>(); readonly generateTransferIntent = output<string>();
readonly retryQrPolling = output<void>();
protected selectPaymentMethod(method: PaymentMethod): void { protected selectPaymentMethod(method: PaymentMethod): void {
this.paymentMethodChange.emit(method); this.paymentMethodChange.emit(method);

View File

@@ -10,5 +10,25 @@
<span class="qr-code__finder qr-code__finder--tr"></span> <span class="qr-code__finder qr-code__finder--tr"></span>
<span class="qr-code__finder qr-code__finder--bl"></span> <span class="qr-code__finder qr-code__finder--bl"></span>
} }
@if (isCheckingPayment()) {
<div class="payment-verification" role="status" aria-live="polite">
<span class="payment-verification__spinner" aria-hidden="true"></span>
<span class="payment-verification__message">Verificando pago</span>
</div> </div>
}
</div>
@if (paymentStatus() === 'timed_out') {
<p class="payment-status payment-status--warning" role="status">
Todavía no recibimos el pago.
</p>
<app-button type="button" variant="secondary" (click)="retryPolling.emit()">
Volver a verificar
</app-button>
} @else if (paymentStatus() === 'failed') {
<p class="payment-status payment-status--warning" role="alert">
El pago fue rechazado o cancelado.
</p>
}
</div> </div>

View File

@@ -103,3 +103,48 @@
left: 10px; left: 10px;
} }
} }
.payment-status {
margin: 1rem 0 0;
color: #6f6f6f;
font-size: 0.8rem;
font-weight: 500;
&--warning {
margin-bottom: 0.75rem;
color: #9a6a12;
}
}
.payment-verification {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.45rem;
background: rgba(255, 255, 255, 0.82);
&__spinner {
width: 30px;
height: 30px;
border: 4px solid rgba(17, 17, 17, 0.2);
border-top-color: #111111;
border-radius: 50%;
animation: payment-verification-spin 0.75s linear infinite;
}
&__message {
color: #111111;
font-size: 0.72rem;
font-weight: 700;
}
}
@keyframes payment-verification-spin {
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,30 @@
import { getTestBed, TestBed } from '@angular/core/testing';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { beforeAll, describe, expect, it } from 'vitest';
import { CheckoutPaymentQrComponent } from './checkout-payment-qr.component';
describe('CheckoutPaymentQrComponent', () => {
beforeAll(() => {
try {
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
});
it('renders the animated payment verification overlay over the QR', async () => {
await TestBed.configureTestingModule({
imports: [CheckoutPaymentQrComponent],
}).compileComponents();
const fixture = TestBed.createComponent(CheckoutPaymentQrComponent);
fixture.componentRef.setInput('isCheckingPayment', true);
fixture.detectChanges();
const overlay = fixture.nativeElement.querySelector('.payment-verification') as HTMLElement;
expect(overlay).not.toBeNull();
expect(overlay.textContent).toContain('Verificando pago');
expect(overlay.querySelector('.payment-verification__spinner')).not.toBeNull();
});
});

View File

@@ -1,14 +1,19 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'; import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { QRCodeComponent } from '../../../../../../shared/components/qrcode/qrcode.component'; import { QRCodeComponent } from '../../../../../../shared/components/qrcode/qrcode.component';
import { ButtonComponent } from '../../../../../../shared/components/button/button.component';
import { QrPaymentStatus } from '../../checkout-page.models';
@Component({ @Component({
selector: 'app-checkout-payment-qr', selector: 'app-checkout-payment-qr',
standalone: true, standalone: true,
imports: [QRCodeComponent], imports: [QRCodeComponent, ButtonComponent],
templateUrl: './checkout-payment-qr.component.html', templateUrl: './checkout-payment-qr.component.html',
styleUrl: './checkout-payment-qr.component.scss', styleUrl: './checkout-payment-qr.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class CheckoutPaymentQrComponent { export class CheckoutPaymentQrComponent {
readonly qrData = input<string | null>(null); readonly qrData = input<string | null>(null);
readonly paymentStatus = input<QrPaymentStatus>('idle');
readonly isCheckingPayment = input<boolean>(false);
readonly retryPolling = output<void>();
} }

View File

@@ -85,10 +85,21 @@
variant="primary" variant="primary"
hostClass="w-100" hostClass="w-100"
buttonClass="w-100" buttonClass="w-100"
[disabled]="validationStatus() === 'checking'"
(click)="completePurchase.emit()" (click)="completePurchase.emit()"
> >
Ya transferí {{ validationStatus() === 'checking' ? 'Validando pago...' : 'Ya transferí' }}
</app-button> </app-button>
@if (validationStatus() === 'pending') {
<p class="payment-validation payment-validation--pending" role="status">
Todavía no recibimos la transferencia. Podés volver a verificar.
</p>
} @else if (validationStatus() === 'error') {
<p class="payment-validation payment-validation--error" role="alert">
No pudimos validar la transferencia. Intentá nuevamente.
</p>
}
</div> </div>
} }
</div> </div>

View File

@@ -115,3 +115,17 @@
height: 100%; height: 100%;
} }
} }
.payment-validation {
margin: 0.75rem 0 0;
font-size: 0.8rem;
line-height: 1.35;
&--pending {
color: #8a681d;
}
&--error {
color: #b42318;
}
}

View File

@@ -3,7 +3,7 @@ import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { IconButtonComponent } from '../../../../../../shared/components/icon-button/icon-button.component'; import { IconButtonComponent } from '../../../../../../shared/components/icon-button/icon-button.component';
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';
import { TransferAccount, TransferField } from '../../checkout-page.models'; import { TransferAccount, TransferField, TransferValidationStatus } from '../../checkout-page.models';
@Component({ @Component({
selector: 'app-checkout-payment-transfer', selector: 'app-checkout-payment-transfer',
@@ -17,6 +17,7 @@ export class CheckoutPaymentTransferComponent implements OnInit {
readonly transferAccount = input<TransferAccount | null>(null); readonly transferAccount = input<TransferAccount | null>(null);
readonly transferDni = input<string>(''); readonly transferDni = input<string>('');
readonly copiedTransferField = input<TransferField | null>(null); readonly copiedTransferField = input<TransferField | null>(null);
readonly validationStatus = input<TransferValidationStatus>('idle');
readonly copyTransferValue = output<{ field: TransferField; value: string }>(); readonly copyTransferValue = output<{ field: TransferField; value: string }>();
readonly submitDni = output<string>(); readonly submitDni = output<string>();

View File

@@ -14,7 +14,9 @@
color: #666666; color: #666666;
cursor: pointer; cursor: pointer;
outline: none; outline: none;
transition: color 0.15s ease-in-out, transform 0.1s ease-in-out; transition:
color 0.15s ease-in-out,
transform 0.1s ease-in-out;
border-radius: 4px; border-radius: 4px;
i { i {
@@ -40,7 +42,7 @@
// Disabled state // Disabled state
&:disabled { &:disabled {
color: #A0A0A0; color: #a0a0a0;
cursor: not-allowed; cursor: not-allowed;
pointer-events: none; pointer-events: none;
} }
@@ -74,3 +76,14 @@
font-size: 23px; font-size: 23px;
} }
} }
.icon-btn--download,
.icon-btn--share {
color: #a0a0a0;
i {
width: 15px;
height: 15px;
font-size: 15px;
}
}

View File

@@ -8,14 +8,16 @@ export type IconButtonVariant =
| 'angle-left' | 'angle-left'
| 'angle-up' | 'angle-up'
| 'angle-down' | 'angle-down'
| 'copy'; | 'copy'
| 'download'
| 'share';
@Component({ @Component({
selector: 'app-icon-button', selector: 'app-icon-button',
imports: [NgClass], imports: [NgClass],
templateUrl: './icon-button.component.html', templateUrl: './icon-button.component.html',
styleUrl: './icon-button.component.scss', styleUrl: './icon-button.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class IconButtonComponent { export class IconButtonComponent {
readonly variant = input.required<IconButtonVariant>(); readonly variant = input.required<IconButtonVariant>();
@@ -33,7 +35,9 @@ export class IconButtonComponent {
'angle-left': 'fa-solid fa-angle-left', 'angle-left': 'fa-solid fa-angle-left',
'angle-up': 'fa-solid fa-angle-up', 'angle-up': 'fa-solid fa-angle-up',
'angle-down': 'fa-solid fa-angle-down', 'angle-down': 'fa-solid fa-angle-down',
copy: 'fa-solid fa-copy' copy: 'fa-solid fa-copy',
download: 'fa-solid fa-download',
share: 'fa-solid fa-share-nodes',
}; };
return classes[this.variant()]; return classes[this.variant()];
}); });

View File

@@ -0,0 +1,30 @@
<article class="ticket">
<label class="ticket__selection">
<input
type="checkbox"
[checked]="selected()"
(change)="selected.set($any($event.target).checked)"
/>
<span class="visually-hidden">Seleccionar ticket {{ title() }}</span>
</label>
<div class="ticket__details">
<h3 class="ticket__title">{{ title() }}</h3>
<p class="ticket__id">ID: {{ ticketId() }}</p>
</div>
@if (date(); as ticketDate) {
<time class="ticket__date">{{ ticketDate }}</time>
}
<div class="ticket__actions">
<app-button variant="primary" buttonClass="ticket__qr-button" (click)="viewQr.emit()">
<i class="fa-solid fa-qrcode" aria-hidden="true"></i>
<span>Ver QR</span>
</app-button>
<app-icon-button variant="download" ariaLabel="Descargar ticket" (clicked)="download.emit()" />
<app-icon-button variant="share" ariaLabel="Compartir ticket" (clicked)="share.emit()" />
</div>
</article>

View File

@@ -0,0 +1,100 @@
:host {
display: block;
width: 100%;
}
.ticket {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
width: 100%;
min-height: 56px;
gap: 16px;
padding: 16px 0;
background: transparent;
color: #666666;
}
.ticket__selection {
display: flex;
margin: 0;
cursor: pointer;
}
.ticket__selection input {
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--tenant-primary, #009933);
cursor: pointer;
}
.ticket__details {
min-width: 0;
}
.ticket__title,
.ticket__id {
overflow: hidden;
margin: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.ticket__title {
font-size: 13px;
font-weight: 700;
line-height: 1.25;
color: #666666;
}
.ticket__id {
font-size: 12px;
font-weight: 400;
line-height: 1.4;
color: #666666;
}
.ticket__date {
font-size: 10px;
font-weight: 400;
line-height: 1.4;
color: #666666;
white-space: nowrap;
}
.ticket__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
:host ::ng-deep .ticket__qr-button {
min-width: auto;
min-height: 36px;
padding-inline: 14px;
}
.ticket__selection input:focus-visible {
outline: 2px solid var(--tenant-primary, #009933);
outline-offset: 2px;
}
@media (max-width: 575.98px) {
.ticket {
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 10px;
padding: 20px 0;
}
.ticket__date {
grid-column: 2;
grid-row: 2;
}
.ticket__actions {
grid-column: 3;
grid-row: 1 / span 2;
}
}

View File

@@ -0,0 +1,50 @@
import { TestBed } from '@angular/core/testing';
import { describe, expect, it, vi } from 'vitest';
import { TicketComponent } from './ticket.component';
describe('TicketComponent', () => {
it('renders the ticket data and emits every action', async () => {
await TestBed.configureTestingModule({ imports: [TicketComponent] }).compileComponents();
const fixture = TestBed.createComponent(TicketComponent);
fixture.componentRef.setInput('title', 'ENTRADA GENERAL');
fixture.componentRef.setInput('ticketId', '0000000000');
fixture.componentRef.setInput('date', '09 de Octubre');
const viewQr = vi.fn();
const download = vi.fn();
const share = vi.fn();
fixture.componentInstance.viewQr.subscribe(viewQr);
fixture.componentInstance.download.subscribe(download);
fixture.componentInstance.share.subscribe(share);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('ENTRADA GENERAL');
expect(element.textContent).toContain('ID: 0000000000');
expect(element.textContent).toContain('09 de Octubre');
element.querySelector<HTMLButtonElement>('app-button button')?.click();
element.querySelector<HTMLButtonElement>('[aria-label="Descargar ticket"]')?.click();
element.querySelector<HTMLButtonElement>('[aria-label="Compartir ticket"]')?.click();
expect(viewQr).toHaveBeenCalledOnce();
expect(download).toHaveBeenCalledOnce();
expect(share).toHaveBeenCalledOnce();
});
it('supports two-way selection state', async () => {
await TestBed.configureTestingModule({ imports: [TicketComponent] }).compileComponents();
const fixture = TestBed.createComponent(TicketComponent);
fixture.componentRef.setInput('ticketId', '0000000000');
fixture.componentRef.setInput('date', '09 de Octubre');
fixture.detectChanges();
const checkbox = fixture.nativeElement.querySelector('input') as HTMLInputElement;
checkbox.click();
fixture.detectChanges();
expect(fixture.componentInstance.selected()).toBe(true);
});
});

View File

@@ -0,0 +1,21 @@
import { ChangeDetectionStrategy, Component, input, model, output } from '@angular/core';
import { ButtonComponent } from '../button/button.component';
import { IconButtonComponent } from '../icon-button/icon-button.component';
@Component({
selector: 'app-ticket',
imports: [ButtonComponent, IconButtonComponent],
templateUrl: './ticket.component.html',
styleUrl: './ticket.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TicketComponent {
readonly title = input('ENTRADA GENERAL');
readonly ticketId = input.required<string>();
readonly date = input<string | null>(null);
readonly selected = model(false);
readonly viewQr = output<void>();
readonly download = output<void>();
readonly share = output<void>();
}