Compare commits
5 Commits
53cee7ecdd
...
fix/minor_
| Author | SHA1 | Date | |
|---|---|---|---|
| 8659708e1a | |||
| dce54ceb67 | |||
| cab33d4842 | |||
| f318f614af | |||
| 581696ffab |
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
50
src/app/core/services/tenant-url.serializer.spec.ts
Normal file
50
src/app/core/services/tenant-url.serializer.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
66
src/app/core/services/tenant-url.serializer.ts
Normal file
66
src/app/core/services/tenant-url.serializer.ts
Normal 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 === '#';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
@@ -61,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);
|
||||
@@ -196,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');
|
||||
|
||||
|
||||
@@ -448,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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FormControl, FormGroup } from '@angular/forms';
|
||||
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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
27
src/app/shared/pages/route-not-found-page.component.ts
Normal file
27
src/app/shared/pages/route-not-found-page.component.ts
Normal 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 {}
|
||||
Reference in New Issue
Block a user