11 Commits

32 changed files with 568 additions and 176 deletions

View File

@@ -59,7 +59,13 @@
"maximumError": "8kB"
}
],
"outputHashing": "all"
"outputHashing": "all",
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.production.ts"
}
]
},
"development": {
"optimization": true,

View File

@@ -4,7 +4,7 @@ import {
provideAppInitializer,
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideRouter, UrlSerializer } from '@angular/router';
import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser';
import { routes } from './app.routes';
@@ -12,6 +12,7 @@ import { authBootstrap } from './core/services/auth/auth-bootstrap';
import { authInterceptor } from './core/services/auth/auth.interceptor';
import { globalLoadingInterceptor } from './core/services/global-loading/global-loading.interceptor';
import { tenantBootstrap } from './core/services/tenant-bootstrap';
import { TenantUrlSerializer } from './core/services/tenant-url.serializer';
export function isStoreCatalogRequest(request: HttpRequest<unknown>): boolean {
return (
@@ -26,6 +27,7 @@ export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
{ provide: UrlSerializer, useClass: TenantUrlSerializer },
provideClientHydration(
withHttpTransferCacheOptions({
includeRequestsWithAuthHeaders: true,

View File

@@ -14,5 +14,12 @@ export const routes: Routes = [
{
path: '',
loadChildren: () => import('./features/store/store.routes').then((m) => m.routes)
},
{
path: '**',
loadComponent: () =>
import('./shared/pages/route-not-found-page.component').then(
(m) => m.RouteNotFoundPageComponent,
)
}
];

View File

@@ -29,6 +29,7 @@
[subtotal]="cartSubtotal()"
[discount]="cartDiscount()"
[total]="cartTotal()"
[readonly]="!cartEditingEnabled()"
[backgroundColor]="'#ffffff'"
(closed)="isCartOpen.set(false)"
>

View File

@@ -645,4 +645,35 @@ describe('StoreLayoutComponent', () => {
expect(cartItem.componentInstance.quantityDisabled()).toBe(false);
expect(buyButton).toBeDefined();
});
it('hides quantity selectors when the tenant disables cart editing', () => {
tenantState.set({ ...tenant, cart_editing_enabled: false });
cartState.set({
id: 1,
tenant_codigo: tenant.codigo,
status: 'active',
subtotal: '100.00',
items: [
{
id: 1,
cantidad: 1,
precio_unitario: '100.00',
catalog_item_id: 1,
variant_id: null,
nombre: 'Producto',
imagen: null,
variant: null,
},
],
});
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
(fixture.componentInstance as any).isCartOpen.set(true);
fixture.detectChanges();
const cartItem = fixture.debugElement.query(By.css('app-cart-item'));
expect(cartItem.componentInstance.readonly()).toBe(true);
expect(cartItem.nativeElement.querySelector('app-quantity-selector')).toBeNull();
});
});

View File

@@ -36,6 +36,9 @@ export class StoreLayoutComponent implements OnInit {
protected readonly isCartOpen = signal(false);
protected readonly isCreatingPurchase = signal(false);
protected readonly displayCart = computed(() => this.tenant()?.display_cart ?? true);
protected readonly cartEditingEnabled = computed(
() => this.tenant()?.cart_editing_enabled ?? true,
);
protected readonly cartSubtotal = computed(() => {
const cart = this.cartService.cart();

View File

@@ -119,8 +119,12 @@ export class AuthService extends BaseApiService {
const apiUrl = new URL(environment.url);
const authorizationUrl = new URL('/auth/google/redirect', apiUrl.origin);
const basePath = tenant.base_path && tenant.base_path !== '/' ? tenant.base_path : '';
authorizationUrl.searchParams.set('tenant', tenant.codigo);
authorizationUrl.searchParams.set('return_url', this.document.location.origin);
authorizationUrl.searchParams.set(
'return_url',
`${this.document.location.origin}${basePath}`,
);
this.document.location.assign(authorizationUrl.toString());
}

View File

@@ -130,7 +130,7 @@ export class CheckoutService extends BaseApiService {
async generatePaymentIntent(
tenantCode: string,
purchaseId: number,
method: 'qr' | 'transfer' | 'telepagos',
method: 'qr' | 'transfer',
payerDni?: string,
): Promise<any> {
const payload: any = { method };

View File

@@ -0,0 +1,50 @@
import '@angular/compiler';
import { DefaultUrlSerializer } from '@angular/router';
import { describe, expect, it } from 'vitest';
import { Tenant } from './tenant.interface';
import { TenantService } from './tenant.service';
import { TenantUrlSerializer } from './tenant-url.serializer';
describe('TenantUrlSerializer', () => {
const defaultSerializer = new DefaultUrlSerializer();
function createSerializer(basePath: string): TenantUrlSerializer {
const tenantService = {
getTenant: () => ({ base_path: basePath }) as Tenant,
} as TenantService;
return new TenantUrlSerializer(tenantService);
}
it('keeps root tenants unchanged', () => {
const serializer = createSerializer('/');
const tree = serializer.parse('/producto/123?ref=home');
expect(defaultSerializer.serialize(tree)).toBe('/producto/123?ref=home');
expect(serializer.serialize(tree)).toBe('/producto/123?ref=home');
});
it('removes the tenant base path when parsing and restores it when serializing', () => {
const serializer = createSerializer('/desfile');
const tree = serializer.parse('/desfile/producto/123?ref=home#detalle');
expect(defaultSerializer.serialize(tree)).toBe('/producto/123?ref=home#detalle');
expect(serializer.serialize(tree)).toBe('/desfile/producto/123?ref=home#detalle');
});
it('maps the tenant base path to the application root', () => {
const serializer = createSerializer('/desfile/');
const tree = serializer.parse('/desfile');
expect(defaultSerializer.serialize(tree)).toBe('/');
expect(serializer.serialize(tree)).toBe('/desfile');
});
it('does not strip partial path segment matches', () => {
const serializer = createSerializer('/desfile');
const tree = serializer.parse('/desfile-shop/producto/123');
expect(defaultSerializer.serialize(tree)).toBe('/desfile-shop/producto/123');
});
});

View File

@@ -0,0 +1,66 @@
import { Injectable } from '@angular/core';
import { DefaultUrlSerializer, UrlSerializer, UrlTree } from '@angular/router';
import { TenantService } from './tenant.service';
@Injectable()
export class TenantUrlSerializer extends UrlSerializer {
private readonly defaultSerializer = new DefaultUrlSerializer();
constructor(private readonly tenantService: TenantService) {
super();
}
override parse(url: string): UrlTree {
return this.defaultSerializer.parse(this.removeBasePath(url));
}
override serialize(tree: UrlTree): string {
const url = this.defaultSerializer.serialize(tree);
const basePath = this.basePath();
if (basePath === '/') {
return url;
}
return url === '/' ? basePath : `${basePath}${url}`;
}
private removeBasePath(url: string): string {
const basePath = this.basePath();
if (basePath === '/' || !this.startsWithCompletePathSegment(url, basePath)) {
return url;
}
const remainder = url.slice(basePath.length);
if (remainder === '') {
return '/';
}
return remainder.startsWith('?') || remainder.startsWith('#')
? `/${remainder}`
: remainder;
}
private basePath(): string {
const configuredPath = this.tenantService.getTenant()?.base_path?.trim() ?? '/';
if (configuredPath === '' || configuredPath === '/') {
return '/';
}
return `/${configuredPath.replace(/^\/+|\/+$/g, '')}`;
}
private startsWithCompletePathSegment(url: string, basePath: string): boolean {
if (!url.startsWith(basePath)) {
return false;
}
const boundary = url.charAt(basePath.length);
return boundary === '' || boundary === '/' || boundary === '?' || boundary === '#';
}
}

View File

@@ -101,6 +101,7 @@ export interface Tenant {
codigo: string;
nombre: string;
dominio: string;
base_path?: string;
site_title?: string | null;
favicon?: string | null;
primary_color: string;
@@ -126,6 +127,8 @@ export interface Tenant {
display_categories?: boolean;
display_seach_bar?: boolean;
display_cart?: boolean;
cart_editing_enabled?: boolean;
display_cart_item_images?: boolean;
social_media?: SocialMedia[];
menues?: Menu[];
categories: Category[];

View File

@@ -58,6 +58,7 @@
[subtotal]="cartSubtotal()"
[discount]="cartDiscount()"
[total]="cartTotal()"
[readonly]="!cartEditingEnabled()"
[allowEditing]="
createdPurchase()?.status === 'created' || createdPurchase()?.status === 'pending_payment'
"

View File

@@ -21,7 +21,6 @@ describe('CheckoutPageComponent payment validation', () => {
cancelPurchase: ReturnType<typeof vi.fn>;
generatePaymentIntent: ReturnType<typeof vi.fn>;
getPurchase: ReturnType<typeof vi.fn>;
submitPurchaseForReview: ReturnType<typeof vi.fn>;
withCustomLoading: ReturnType<typeof vi.fn>;
};
let cartServiceStub: {
@@ -33,6 +32,7 @@ describe('CheckoutPageComponent payment validation', () => {
let routerStub: { navigate: ReturnType<typeof vi.fn> };
let routeQueryParamMap: ReturnType<typeof convertToParamMap>;
let authUserState: ReturnType<typeof signal>;
let tenantState: ReturnType<typeof signal<{ codigo: string; cart_editing_enabled?: boolean }>>;
beforeAll(() => {
try {
@@ -60,7 +60,6 @@ describe('CheckoutPageComponent payment validation', () => {
qr_data: { qr_code: 'qr-value' },
}),
getPurchase: vi.fn().mockResolvedValue({ status: 'pending_payment' }),
submitPurchaseForReview: vi.fn().mockResolvedValue({ status: 'pending_payment' }),
withCustomLoading: vi.fn(),
};
checkoutServiceStub.withCustomLoading.mockReturnValue(checkoutServiceStub);
@@ -88,6 +87,7 @@ describe('CheckoutPageComponent payment validation', () => {
routerStub = { navigate: vi.fn() };
routeQueryParamMap = convertToParamMap({});
authUserState = signal(null);
tenantState = signal({ codigo: 'tenant-test' });
await TestBed.configureTestingModule({
imports: [CheckoutPageComponent],
@@ -95,7 +95,7 @@ describe('CheckoutPageComponent payment validation', () => {
{ provide: CheckoutService, useValue: checkoutServiceStub },
{ provide: CatalogService, useValue: { getCatalogItem: vi.fn() } },
{ provide: CartService, useValue: cartServiceStub },
{ provide: TenantService, useValue: { tenant: signal({ codigo: 'tenant-test' }) } },
{ provide: TenantService, useValue: { tenant: tenantState } },
{ provide: AuthService, useValue: { user: authUserState } },
{
provide: ActivatedRoute,
@@ -194,38 +194,36 @@ describe('CheckoutPageComponent payment validation', () => {
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
});
it('checks a transfer once and redirects to purchase status while pending', async () => {
it('checks the purchase detail once when the transfer was made', async () => {
checkoutServiceStub.getPurchase.mockResolvedValue({ status: 'pending_payment' });
const { component } = createComponent();
component.selectedPaymentMethod.set('transfer');
await component.onComplete();
expect(checkoutServiceStub.submitPurchaseForReview).toHaveBeenCalledTimes(1);
expect(component.transferValidationStatus()).toBe('pending');
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledWith('tenant-test', 25);
expect(component.transferValidationStatus()).toBe('error');
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
expect(routerStub.navigate).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(30_000);
expect(checkoutServiceStub.submitPurchaseForReview).toHaveBeenCalledTimes(1);
await component.onComplete();
expect(checkoutServiceStub.submitPurchaseForReview).toHaveBeenCalledTimes(1);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(1);
});
it('navigates after a transfer is confirmed as paid', async () => {
checkoutServiceStub.submitPurchaseForReview.mockResolvedValue({ status: 'paid' });
checkoutServiceStub.getPurchase.mockResolvedValue({ status: 'paid' });
const { component } = createComponent();
component.selectedPaymentMethod.set('transfer');
await component.onComplete();
expect(checkoutServiceStub.submitPurchaseForReview).toHaveBeenCalledWith('tenant-test', 25);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledWith('tenant-test', 25);
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
});
it('shows a retryable state when transfer validation fails', async () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined);
checkoutServiceStub.submitPurchaseForReview.mockRejectedValue(new Error('network error'));
checkoutServiceStub.getPurchase.mockRejectedValue(new Error('network error'));
const { component } = createComponent();
component.selectedPaymentMethod.set('transfer');
@@ -366,11 +364,7 @@ describe('CheckoutPageComponent payment validation', () => {
expect(component.checkoutStepIndex()).toBe(1);
expect(component.selectedPaymentMethod()).toBe('qr');
expect(checkoutServiceStub.generatePaymentIntent).toHaveBeenCalledWith(
'tenant-test',
25,
'qr',
);
expect(checkoutServiceStub.generatePaymentIntent).toHaveBeenCalledWith('tenant-test', 25, 'qr');
expect(component.qrData()).toBe('qr-value');
expect(component.qrPaymentStatus()).toBe('waiting');
});
@@ -472,6 +466,17 @@ describe('CheckoutPageComponent payment validation', () => {
expect(component.isEditingItems()).toBe(false);
});
it('does not allow item editing when the tenant disables cart editing', async () => {
tenantState.set({ codigo: 'tenant-test', cart_editing_enabled: false });
const { component } = createComponent();
await component.onEditingItemsChange(true);
expect(component.cartEditingEnabled()).toBe(false);
expect(component.isEditingItems()).toBe(false);
expect(checkoutServiceStub.prepareItemEditing).not.toHaveBeenCalled();
});
it('updates customer data on the existing purchase before payment', async () => {
const updatedPurchase = {
id: 25,

View File

@@ -79,6 +79,9 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
protected readonly createdPurchase = signal<PurchaseDetailResponse | null>(null);
protected readonly isLoadingPurchase = signal(true);
protected readonly checkoutStepIndex = signal(0);
protected readonly cartEditingEnabled = computed(
() => this.tenantService.tenant()?.cart_editing_enabled ?? true,
);
protected readonly cartSubtotal = computed(() => {
const purchase = this.createdPurchase();
@@ -101,7 +104,6 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
protected readonly paymentMethods: ReadonlyArray<PaymentMethodOption> = [
{ id: 'qr', label: 'QR' },
{ id: 'transfer', label: 'Transferencia' },
{ id: 'telepagos', label: 'TelePagos' },
];
protected readonly selectedPaymentMethod = signal<PaymentMethod>('qr');
protected readonly copiedTransferField = signal<TransferField | null>(null);
@@ -179,6 +181,10 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
return;
}
if (editing && !this.cartEditingEnabled()) {
return;
}
if (!editing) {
this.isEditingItems.set(false);
@@ -224,7 +230,14 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
const purchaseId = this.createdPurchaseId();
const itemId = event.item.cartItemId;
if (!tenant || !purchaseId || !itemId || this.isUpdatingItem() || !this.isEditingItems()) {
if (
!this.cartEditingEnabled() ||
!tenant ||
!purchaseId ||
!itemId ||
this.isUpdatingItem() ||
!this.isEditingItems()
) {
return;
}
@@ -331,7 +344,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant();
if (!purchaseId || !tenant || method === 'telepagos') {
if (!purchaseId || !tenant) {
return;
}
@@ -435,20 +448,16 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
try {
const purchase = await this.checkoutService
.withCustomLoading()
.submitPurchaseForReview(tenant.codigo, purchaseId);
if (purchase.status === 'paid' || purchase.status === 'pending_payment') {
if (purchase.status === 'pending_payment') {
this.transferValidationStatus.set('pending');
}
.getPurchase(tenant.codigo, purchaseId);
if (purchase.status === 'paid') {
this.navigateToPurchaseStatus(purchaseId);
return;
}
this.transferValidationStatus.set('error');
} catch (error) {
console.error('Failed to submit purchase for review:', error);
console.error('Failed to validate transfer payment:', error);
this.transferValidationStatus.set('error');
}
}

View File

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

View File

@@ -18,14 +18,7 @@
/>
<span class="payment-method__label">
@if (method.id === 'telepagos') {
<span class="telepagos-logo" aria-label="TelePagos">
<span class="telepagos-logo__tele">tele</span
><span class="telepagos-logo__pagos">pagos</span>
</span>
} @else {
{{ method.label }}
}
{{ method.label }}
</span>
<i class="fa-solid fa-angle-right payment-method__chevron" aria-hidden="true"></i>
@@ -59,8 +52,6 @@
(submitDni)="generateTransferIntent.emit($event)"
(completePurchase)="complete.emit()"
/>
} @else {
<app-checkout-payment-telepagos />
}
</section>
</div>

View File

@@ -101,26 +101,13 @@
}
}
.telepagos-logo {
display: inline-block;
font-size: 1.45rem;
font-weight: 800;
line-height: 1;
letter-spacing: -0.03em;
background: linear-gradient(90deg, #0a69d8 0 78%, #f6a11a 78% 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.payment-panel {
display: flex;
justify-content: flex-end;
min-width: 0;
> app-checkout-payment-qr,
> app-checkout-payment-transfer,
> app-checkout-payment-telepagos {
> app-checkout-payment-transfer {
display: block;
width: 100%;
max-width: 320px;

View File

@@ -1,7 +1,6 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { CheckoutPaymentQrComponent } from './components/checkout-payment-qr/checkout-payment-qr.component';
import { CheckoutPaymentTelepagosComponent } from './components/checkout-payment-telepagos/checkout-payment-telepagos.component';
import { CheckoutPaymentTransferComponent } from './components/checkout-payment-transfer/checkout-payment-transfer.component';
import {
PaymentMethod,
@@ -15,7 +14,7 @@ import {
@Component({
selector: 'app-checkout-payment-step',
standalone: true,
imports: [CheckoutPaymentQrComponent, CheckoutPaymentTelepagosComponent, CheckoutPaymentTransferComponent],
imports: [CheckoutPaymentQrComponent, CheckoutPaymentTransferComponent],
templateUrl: './checkout-payment-step.component.html',
styleUrl: './checkout-payment-step.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -1,22 +0,0 @@
<div class="payment-panel__card">
<h3 class="payment-panel__title">Descarga la app de TelePagos para finalizar la compra</h3>
<div class="payment-panel__divider"></div>
<div class="store-badges" aria-label="Tiendas disponibles">
<div class="store-badge">
<i class="fa-brands fa-google-play" aria-hidden="true"></i>
<span class="store-badge__text">
<small>Disponible en</small>
<strong>Google Play</strong>
</span>
</div>
<div class="store-badge">
<i class="fa-brands fa-apple" aria-hidden="true"></i>
<span class="store-badge__text">
<small>Descargalo en</small>
<strong>App Store</strong>
</span>
</div>
</div>
</div>

View File

@@ -1,71 +0,0 @@
.payment-panel__card {
width: 100%;
max-width: 320px;
min-height: 269px;
padding: 1.5rem 1.75rem;
border-radius: 5px;
background: #ffffff;
color: #666666;
box-shadow: 0 0 0 1px #f1f1f1;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.payment-panel__title {
max-width: 18rem;
margin: 0;
color: #8a8a8a;
font-size: 13px;
font-weight: 700;
line-height: 1.35;
}
.payment-panel__divider {
width: 100%;
max-width: 220px;
height: 1px;
margin: 1.2rem 0 1.45rem;
background: #dddddd;
}
.store-badges {
width: 100%;
max-width: 210px;
display: grid;
gap: 0.8rem;
}
.store-badge {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0.95rem;
border-radius: 0.85rem;
background: #111111;
color: #ffffff;
box-shadow: 0 10px 24px rgba(17, 17, 17, 0.16);
i {
font-size: 1.35rem;
}
&__text {
display: grid;
text-align: left;
line-height: 1.1;
small {
font-size: 0.62rem;
letter-spacing: 0.05em;
text-transform: uppercase;
opacity: 0.8;
}
strong {
font-size: 1rem;
font-weight: 700;
}
}
}

View File

@@ -1,11 +0,0 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'app-checkout-payment-telepagos',
standalone: true,
imports: [],
templateUrl: './checkout-payment-telepagos.component.html',
styleUrl: './checkout-payment-telepagos.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CheckoutPaymentTelepagosComponent {}

View File

@@ -1,5 +1,5 @@
<div class="payment-panel__card">
@if (validationStatus() === 'pending' || validationStatus() === 'error') {
@if (validationStatus() === 'error') {
<app-payment-verification-error
[paymentAmount]="paymentAmount()"
[whatsappUrl]="whatsappUrl()"
@@ -102,5 +102,12 @@
</app-button>
</div>
}
@if (validationStatus() === 'checking') {
<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>

View File

@@ -1,4 +1,5 @@
.payment-panel__card {
position: relative;
width: 100%;
max-width: 320px;
min-height: 400px;
@@ -13,6 +14,40 @@
text-align: center;
}
.payment-verification {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.45rem;
border-radius: inherit;
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);
}
}
.payment-panel__title {
max-width: 18rem;
margin: 0;

View File

@@ -0,0 +1,50 @@
import { getTestBed, TestBed } from '@angular/core/testing';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { beforeAll, describe, expect, it } from 'vitest';
import { CheckoutPaymentTransferComponent } from './checkout-payment-transfer.component';
describe('CheckoutPaymentTransferComponent', () => {
beforeAll(() => {
try {
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
});
it('renders the payment verification overlay while checking the transfer', async () => {
await TestBed.configureTestingModule({
imports: [CheckoutPaymentTransferComponent],
}).compileComponents();
const fixture = TestBed.createComponent(CheckoutPaymentTransferComponent);
fixture.componentRef.setInput('validationStatus', 'checking');
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();
});
it('shows the QR payment error and WhatsApp action when validation fails', async () => {
await TestBed.configureTestingModule({
imports: [CheckoutPaymentTransferComponent],
}).compileComponents();
const fixture = TestBed.createComponent(CheckoutPaymentTransferComponent);
fixture.componentRef.setInput('validationStatus', 'error');
fixture.componentRef.setInput('paymentAmount', 300000);
fixture.componentRef.setInput('whatsappUrl', 'https://wa.me/543412602222');
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const whatsapp = Array.from(element.querySelectorAll('button')).find((button) =>
button.textContent?.includes('WhatsApp'),
);
expect(element.textContent).toMatch(/No pudimos verificar el pago de \$\s*300\.000\./);
expect(whatsapp).toBeDefined();
expect(element.querySelector('.payment-verification')).toBeNull();
});
});

View File

@@ -1,7 +1,10 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
import { CheckoutService, PurchaseDetailResponse } from '../../../../core/services/checkout.service';
import {
CheckoutService,
PurchaseDetailResponse,
} from '../../../../core/services/checkout.service';
import { Tenant } from '../../../../core/services/tenant.interface';
import { TenantService } from '../../../../core/services/tenant.service';
import { PurchaseStatusPageComponent } from './purchase-status-page.component';
@@ -130,4 +133,89 @@ describe('PurchaseStatusPageComponent', () => {
openSpy.mockRestore();
});
it('polls every five seconds while the payment is pending and shows the confirmation', async () => {
vi.useFakeTimers();
try {
const checkoutService = {
getPurchase: vi
.fn()
.mockResolvedValueOnce({ status: 'pending_payment' } as PurchaseDetailResponse)
.mockResolvedValueOnce(purchase(true)),
withCustomLoading() {
return this;
},
};
await TestBed.configureTestingModule({
imports: [PurchaseStatusPageComponent],
providers: [
{ provide: CheckoutService, useValue: checkoutService },
{ provide: TenantService, useValue: { tenant: () => tenant } },
{
provide: ActivatedRoute,
useValue: { snapshot: { paramMap: convertToParamMap({ id: '42' }) } },
},
{ provide: Router, useValue: { navigate: vi.fn(), navigateByUrl: vi.fn() } },
],
}).compileComponents();
const fixture = TestBed.createComponent(PurchaseStatusPageComponent);
fixture.detectChanges();
await Promise.resolve();
fixture.detectChanges();
expect(checkoutService.getPurchase).toHaveBeenCalledOnce();
expect(fixture.nativeElement.textContent).toContain('ESTAMOS VERIFICANDO TU PAGO');
await vi.advanceTimersByTimeAsync(5_000);
fixture.detectChanges();
expect(checkoutService.getPurchase).toHaveBeenCalledTimes(2);
expect(fixture.nativeElement.textContent).toContain('COMPRA REALIZADA!');
await vi.advanceTimersByTimeAsync(10_000);
expect(checkoutService.getPurchase).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
it('stops polling when the page is destroyed', async () => {
vi.useFakeTimers();
try {
const checkoutService = {
getPurchase: vi.fn().mockResolvedValue({ status: 'pending_payment' }),
withCustomLoading() {
return this;
},
};
await TestBed.configureTestingModule({
imports: [PurchaseStatusPageComponent],
providers: [
{ provide: CheckoutService, useValue: checkoutService },
{ provide: TenantService, useValue: { tenant: () => tenant } },
{
provide: ActivatedRoute,
useValue: { snapshot: { paramMap: convertToParamMap({ id: '42' }) } },
},
{ provide: Router, useValue: { navigate: vi.fn(), navigateByUrl: vi.fn() } },
],
}).compileComponents();
const fixture = TestBed.createComponent(PurchaseStatusPageComponent);
fixture.detectChanges();
await Promise.resolve();
fixture.destroy();
await vi.advanceTimersByTimeAsync(5_000);
expect(checkoutService.getPurchase).toHaveBeenCalledOnce();
} finally {
vi.useRealTimers();
}
});
});

View File

@@ -1,29 +1,47 @@
import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
OnDestroy,
OnInit,
PLATFORM_ID,
computed,
inject,
signal,
} from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { CheckoutService, PurchaseStatusResponse } from '../../../../core/services/checkout.service';
import {
CheckoutService,
PurchaseStatusResponse,
} from '../../../../core/services/checkout.service';
import { findMenu } from '../../../../core/services/menu.utils';
import { TenantService } from '../../../../core/services/tenant.service';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
type PurchaseStatusView = 'approved' | 'pending' | 'rejected' | 'expired' | 'error';
const PAYMENT_STATUS_POLL_INTERVAL_MS = 5_000;
@Component({
selector: 'app-purchase-status-page',
standalone: true,
imports: [ButtonComponent],
templateUrl: './purchase-status-page.component.html',
styleUrl: './purchase-status-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PurchaseStatusPageComponent implements OnInit {
export class PurchaseStatusPageComponent implements OnInit, OnDestroy {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly checkoutService = inject(CheckoutService);
private readonly tenantService = inject(TenantService);
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private purchaseId: string | null = null;
private tenantCode: string | null = null;
private pollingTimeout: ReturnType<typeof setTimeout> | null = null;
private isDestroyed = false;
protected readonly isLoading = signal(true);
protected readonly status = signal<PurchaseStatusView>('pending');
@@ -57,7 +75,12 @@ export class PurchaseStatusPageComponent implements OnInit {
void this.loadStatus();
}
private async loadStatus(): Promise<void> {
ngOnDestroy(): void {
this.isDestroyed = true;
this.stopPolling();
}
private async loadStatus(isPolling = false): Promise<void> {
if (!this.purchaseId || !this.tenantCode) {
return;
}
@@ -66,13 +89,52 @@ export class PurchaseStatusPageComponent implements OnInit {
const purchase = await this.checkoutService
.withCustomLoading()
.getPurchase(this.tenantCode, this.purchaseId);
this.status.set(this.resolveStatus(purchase));
if (this.isDestroyed) {
return;
}
const status = this.resolveStatus(purchase);
this.status.set(status);
this.hasGeneratedTickets.set(purchase.has_generated_tickets === true);
if (status === 'pending') {
this.schedulePolling();
} else {
this.stopPolling();
}
} catch (error) {
console.error('Failed to fetch purchase status:', error);
this.status.set('error');
if (!this.isDestroyed) {
if (isPolling) {
this.schedulePolling();
} else {
this.status.set('error');
}
}
} finally {
this.isLoading.set(false);
if (!this.isDestroyed) {
this.isLoading.set(false);
}
}
}
private schedulePolling(): void {
if (!this.isBrowser || this.isDestroyed || this.pollingTimeout) {
return;
}
this.pollingTimeout = setTimeout(() => {
this.pollingTimeout = null;
void this.loadStatus(true);
}, PAYMENT_STATUS_POLL_INTERVAL_MS);
}
private stopPolling(): void {
if (this.pollingTimeout) {
clearTimeout(this.pollingTimeout);
this.pollingTimeout = null;
}
}

View File

@@ -20,6 +20,17 @@
<span class="ticket-selector__label">Seleccioná Entrada/s:</span>
<div class="ticket-selector__rows">
@if (attributes().length > 0) {
<div class="ticket-selector__columns" aria-hidden="true">
<div class="ticket-selector__column-labels">
@for (attribute of attributes(); track attribute.key) {
<span class="ticket-selector__column-label">{{ attribute.label }}</span>
}
</div>
<span class="ticket-selector__action-spacer"></span>
</div>
}
@for (row of rows(); track row.id) {
<div class="ticket-selector__row">
<div class="ticket-selector__row-content">

View File

@@ -57,6 +57,30 @@
gap: 0.5rem;
}
&__columns {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: 0.5rem;
}
&__column-labels {
min-width: 0;
display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr));
gap: 0.5rem;
}
&__column-label {
color: #666;
font-size: 13px;
font-weight: 600;
}
&__action-spacer {
width: 38px;
}
&__row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@@ -160,6 +184,10 @@
grid-template-columns: 1fr;
}
&__columns {
display: none;
}
&__map {
margin-top: 2rem;
}

View File

@@ -209,6 +209,23 @@ describe('ProductTicketSelectorComponent', () => {
expect(removeButton?.classList.contains('icon-btn--bordered')).toBe(true);
});
it('shows one header row with the label of every selector', async () => {
const { fixture } = await createComponent({
maps: [mapResponse([variant(401, 'general', 'A', '1', '1')])],
});
const headerRows = fixture.nativeElement.querySelectorAll('.ticket-selector__columns');
const labels = fixture.nativeElement.querySelectorAll('.ticket-selector__column-label');
expect(headerRows).toHaveLength(1);
expect([...labels].map((label: Element) => label.textContent?.trim())).toEqual([
'Tipo',
'Sector',
'Fila',
'Asiento',
]);
});
it('clears only the seat after a stock conflict when the row still has alternatives', async () => {
const failed = variant(401, 'general', 'A', '1', '1');
const alternative = variant(402, 'general', 'A', '1', '2');

View File

@@ -71,7 +71,7 @@ export class ProductTicketSelectorComponent {
private readonly viewReady = signal(false);
private readonly mapReady = signal(false);
private readonly variants = signal<CatalogFeaturedItemVariant[]>([]);
private readonly attributes = signal<VariantAttribute[]>([]);
protected readonly attributes = signal<VariantAttribute[]>([]);
private mapRequest: Subscription | null = null;
readonly productId = input.required<number>();

View File

@@ -0,0 +1,27 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-route-not-found-page',
template: `
<main class="route-not-found">
<h1>404</h1>
<p>No encontramos la página solicitada.</p>
</main>
`,
styles: `
.route-not-found {
min-height: 100vh;
display: grid;
place-content: center;
gap: 0.5rem;
padding: 2rem;
text-align: center;
}
h1,
p {
margin: 0;
}
`,
})
export class RouteNotFoundPageComponent {}

View File

@@ -0,0 +1,6 @@
export const environment = {
production: true,
nombre: "Producción - activo",
url: "https://backend.shopit.com.ar/api/",
urlDescarga: "url/"
};