Compare commits

...

5 Commits

13 changed files with 265 additions and 24 deletions

View File

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

View File

@@ -14,5 +14,12 @@ export const routes: Routes = [
{ {
path: '', path: '',
loadChildren: () => import('./features/store/store.routes').then((m) => m.routes) 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

@@ -119,8 +119,12 @@ export class AuthService extends BaseApiService {
const apiUrl = new URL(environment.url); const apiUrl = new URL(environment.url);
const authorizationUrl = new URL('/auth/google/redirect', apiUrl.origin); 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('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()); this.document.location.assign(authorizationUrl.toString());
} }

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; codigo: string;
nombre: string; nombre: string;
dominio: string; dominio: string;
base_path?: string;
site_title?: string | null; site_title?: string | null;
favicon?: string | null; favicon?: string | null;
primary_color: string; primary_color: string;

View File

@@ -21,7 +21,6 @@ describe('CheckoutPageComponent payment validation', () => {
cancelPurchase: ReturnType<typeof vi.fn>; cancelPurchase: ReturnType<typeof vi.fn>;
generatePaymentIntent: ReturnType<typeof vi.fn>; generatePaymentIntent: ReturnType<typeof vi.fn>;
getPurchase: ReturnType<typeof vi.fn>; getPurchase: ReturnType<typeof vi.fn>;
submitPurchaseForReview: ReturnType<typeof vi.fn>;
withCustomLoading: ReturnType<typeof vi.fn>; withCustomLoading: ReturnType<typeof vi.fn>;
}; };
let cartServiceStub: { let cartServiceStub: {
@@ -61,7 +60,6 @@ describe('CheckoutPageComponent payment validation', () => {
qr_data: { qr_code: 'qr-value' }, qr_data: { qr_code: 'qr-value' },
}), }),
getPurchase: vi.fn().mockResolvedValue({ status: 'pending_payment' }), getPurchase: vi.fn().mockResolvedValue({ status: 'pending_payment' }),
submitPurchaseForReview: vi.fn().mockResolvedValue({ status: 'pending_payment' }),
withCustomLoading: vi.fn(), withCustomLoading: vi.fn(),
}; };
checkoutServiceStub.withCustomLoading.mockReturnValue(checkoutServiceStub); checkoutServiceStub.withCustomLoading.mockReturnValue(checkoutServiceStub);
@@ -196,38 +194,36 @@ describe('CheckoutPageComponent payment validation', () => {
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled(); 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(); const { component } = createComponent();
component.selectedPaymentMethod.set('transfer'); component.selectedPaymentMethod.set('transfer');
await component.onComplete(); await component.onComplete();
expect(checkoutServiceStub.submitPurchaseForReview).toHaveBeenCalledTimes(1); expect(checkoutServiceStub.getPurchase).toHaveBeenCalledWith('tenant-test', 25);
expect(component.transferValidationStatus()).toBe('pending'); expect(component.transferValidationStatus()).toBe('error');
expect(cartServiceStub.clearCart).not.toHaveBeenCalled(); expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]); expect(routerStub.navigate).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(30_000); await vi.advanceTimersByTimeAsync(30_000);
expect(checkoutServiceStub.submitPurchaseForReview).toHaveBeenCalledTimes(1); expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(1);
await component.onComplete();
expect(checkoutServiceStub.submitPurchaseForReview).toHaveBeenCalledTimes(1);
}); });
it('navigates after a transfer is confirmed as paid', async () => { it('navigates after a transfer is confirmed as paid', async () => {
checkoutServiceStub.submitPurchaseForReview.mockResolvedValue({ status: 'paid' }); checkoutServiceStub.getPurchase.mockResolvedValue({ status: 'paid' });
const { component } = createComponent(); const { component } = createComponent();
component.selectedPaymentMethod.set('transfer'); component.selectedPaymentMethod.set('transfer');
await component.onComplete(); await component.onComplete();
expect(checkoutServiceStub.submitPurchaseForReview).toHaveBeenCalledWith('tenant-test', 25); expect(checkoutServiceStub.getPurchase).toHaveBeenCalledWith('tenant-test', 25);
expect(cartServiceStub.clearCart).not.toHaveBeenCalled(); expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]); expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
}); });
it('shows a retryable state when transfer validation fails', async () => { it('shows a retryable state when transfer validation fails', async () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined); vi.spyOn(console, 'error').mockImplementation(() => undefined);
checkoutServiceStub.submitPurchaseForReview.mockRejectedValue(new Error('network error')); checkoutServiceStub.getPurchase.mockRejectedValue(new Error('network error'));
const { component } = createComponent(); const { component } = createComponent();
component.selectedPaymentMethod.set('transfer'); component.selectedPaymentMethod.set('transfer');

View File

@@ -448,20 +448,16 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
try { try {
const purchase = await this.checkoutService const purchase = await this.checkoutService
.withCustomLoading() .withCustomLoading()
.submitPurchaseForReview(tenant.codigo, purchaseId); .getPurchase(tenant.codigo, purchaseId);
if (purchase.status === 'paid' || purchase.status === 'pending_payment') {
if (purchase.status === 'pending_payment') {
this.transferValidationStatus.set('pending');
}
if (purchase.status === 'paid') {
this.navigateToPurchaseStatus(purchaseId); this.navigateToPurchaseStatus(purchaseId);
return; return;
} }
this.transferValidationStatus.set('error'); this.transferValidationStatus.set('error');
} catch (error) { } catch (error) {
console.error('Failed to submit purchase for review:', error); console.error('Failed to validate transfer payment:', error);
this.transferValidationStatus.set('error'); this.transferValidationStatus.set('error');
} }
} }

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
.payment-panel__card { .payment-panel__card {
position: relative;
width: 100%; width: 100%;
max-width: 320px; max-width: 320px;
min-height: 400px; min-height: 400px;
@@ -13,6 +14,40 @@
text-align: center; 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 { .payment-panel__title {
max-width: 18rem; max-width: 18rem;
margin: 0; 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

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