Merge branch 'homologacion'
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
[subtotal]="cartSubtotal()"
|
[subtotal]="cartSubtotal()"
|
||||||
[discount]="cartDiscount()"
|
[discount]="cartDiscount()"
|
||||||
[total]="cartTotal()"
|
[total]="cartTotal()"
|
||||||
|
[readonly]="!cartEditingEnabled()"
|
||||||
[backgroundColor]="'#ffffff'"
|
[backgroundColor]="'#ffffff'"
|
||||||
(closed)="isCartOpen.set(false)"
|
(closed)="isCartOpen.set(false)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -645,4 +645,35 @@ describe('StoreLayoutComponent', () => {
|
|||||||
expect(cartItem.componentInstance.quantityDisabled()).toBe(false);
|
expect(cartItem.componentInstance.quantityDisabled()).toBe(false);
|
||||||
expect(buyButton).toBeDefined();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ export class StoreLayoutComponent implements OnInit {
|
|||||||
protected readonly isCartOpen = signal(false);
|
protected readonly isCartOpen = signal(false);
|
||||||
protected readonly isCreatingPurchase = signal(false);
|
protected readonly isCreatingPurchase = signal(false);
|
||||||
protected readonly displayCart = computed(() => this.tenant()?.display_cart ?? true);
|
protected readonly displayCart = computed(() => this.tenant()?.display_cart ?? true);
|
||||||
|
protected readonly cartEditingEnabled = computed(
|
||||||
|
() => this.tenant()?.cart_editing_enabled ?? true,
|
||||||
|
);
|
||||||
|
|
||||||
protected readonly cartSubtotal = computed(() => {
|
protected readonly cartSubtotal = computed(() => {
|
||||||
const cart = this.cartService.cart();
|
const cart = this.cartService.cart();
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export class CheckoutService extends BaseApiService {
|
|||||||
async generatePaymentIntent(
|
async generatePaymentIntent(
|
||||||
tenantCode: string,
|
tenantCode: string,
|
||||||
purchaseId: number,
|
purchaseId: number,
|
||||||
method: 'qr' | 'transfer' | 'telepagos',
|
method: 'qr' | 'transfer',
|
||||||
payerDni?: string,
|
payerDni?: string,
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const payload: any = { method };
|
const payload: any = { method };
|
||||||
|
|||||||
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;
|
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;
|
||||||
@@ -126,6 +127,8 @@ export interface Tenant {
|
|||||||
display_categories?: boolean;
|
display_categories?: boolean;
|
||||||
display_seach_bar?: boolean;
|
display_seach_bar?: boolean;
|
||||||
display_cart?: boolean;
|
display_cart?: boolean;
|
||||||
|
cart_editing_enabled?: boolean;
|
||||||
|
display_cart_item_images?: boolean;
|
||||||
social_media?: SocialMedia[];
|
social_media?: SocialMedia[];
|
||||||
menues?: Menu[];
|
menues?: Menu[];
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
|
|||||||
@@ -58,6 +58,7 @@
|
|||||||
[subtotal]="cartSubtotal()"
|
[subtotal]="cartSubtotal()"
|
||||||
[discount]="cartDiscount()"
|
[discount]="cartDiscount()"
|
||||||
[total]="cartTotal()"
|
[total]="cartTotal()"
|
||||||
|
[readonly]="!cartEditingEnabled()"
|
||||||
[allowEditing]="
|
[allowEditing]="
|
||||||
createdPurchase()?.status === 'created' || createdPurchase()?.status === 'pending_payment'
|
createdPurchase()?.status === 'created' || createdPurchase()?.status === 'pending_payment'
|
||||||
"
|
"
|
||||||
|
|||||||
@@ -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: {
|
||||||
@@ -33,6 +32,7 @@ describe('CheckoutPageComponent payment validation', () => {
|
|||||||
let routerStub: { navigate: ReturnType<typeof vi.fn> };
|
let routerStub: { navigate: ReturnType<typeof vi.fn> };
|
||||||
let routeQueryParamMap: ReturnType<typeof convertToParamMap>;
|
let routeQueryParamMap: ReturnType<typeof convertToParamMap>;
|
||||||
let authUserState: ReturnType<typeof signal>;
|
let authUserState: ReturnType<typeof signal>;
|
||||||
|
let tenantState: ReturnType<typeof signal<{ codigo: string; cart_editing_enabled?: boolean }>>;
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
try {
|
try {
|
||||||
@@ -60,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);
|
||||||
@@ -88,6 +87,7 @@ describe('CheckoutPageComponent payment validation', () => {
|
|||||||
routerStub = { navigate: vi.fn() };
|
routerStub = { navigate: vi.fn() };
|
||||||
routeQueryParamMap = convertToParamMap({});
|
routeQueryParamMap = convertToParamMap({});
|
||||||
authUserState = signal(null);
|
authUserState = signal(null);
|
||||||
|
tenantState = signal({ codigo: 'tenant-test' });
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [CheckoutPageComponent],
|
imports: [CheckoutPageComponent],
|
||||||
@@ -95,7 +95,7 @@ describe('CheckoutPageComponent payment validation', () => {
|
|||||||
{ provide: CheckoutService, useValue: checkoutServiceStub },
|
{ provide: CheckoutService, useValue: checkoutServiceStub },
|
||||||
{ provide: CatalogService, useValue: { getCatalogItem: vi.fn() } },
|
{ provide: CatalogService, useValue: { getCatalogItem: vi.fn() } },
|
||||||
{ provide: CartService, useValue: cartServiceStub },
|
{ provide: CartService, useValue: cartServiceStub },
|
||||||
{ provide: TenantService, useValue: { tenant: signal({ codigo: 'tenant-test' }) } },
|
{ provide: TenantService, useValue: { tenant: tenantState } },
|
||||||
{ provide: AuthService, useValue: { user: authUserState } },
|
{ provide: AuthService, useValue: { user: authUserState } },
|
||||||
{
|
{
|
||||||
provide: ActivatedRoute,
|
provide: ActivatedRoute,
|
||||||
@@ -194,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');
|
||||||
|
|
||||||
@@ -366,11 +364,7 @@ describe('CheckoutPageComponent payment validation', () => {
|
|||||||
|
|
||||||
expect(component.checkoutStepIndex()).toBe(1);
|
expect(component.checkoutStepIndex()).toBe(1);
|
||||||
expect(component.selectedPaymentMethod()).toBe('qr');
|
expect(component.selectedPaymentMethod()).toBe('qr');
|
||||||
expect(checkoutServiceStub.generatePaymentIntent).toHaveBeenCalledWith(
|
expect(checkoutServiceStub.generatePaymentIntent).toHaveBeenCalledWith('tenant-test', 25, 'qr');
|
||||||
'tenant-test',
|
|
||||||
25,
|
|
||||||
'qr',
|
|
||||||
);
|
|
||||||
expect(component.qrData()).toBe('qr-value');
|
expect(component.qrData()).toBe('qr-value');
|
||||||
expect(component.qrPaymentStatus()).toBe('waiting');
|
expect(component.qrPaymentStatus()).toBe('waiting');
|
||||||
});
|
});
|
||||||
@@ -472,6 +466,17 @@ describe('CheckoutPageComponent payment validation', () => {
|
|||||||
expect(component.isEditingItems()).toBe(false);
|
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 () => {
|
it('updates customer data on the existing purchase before payment', async () => {
|
||||||
const updatedPurchase = {
|
const updatedPurchase = {
|
||||||
id: 25,
|
id: 25,
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
protected readonly createdPurchase = signal<PurchaseDetailResponse | null>(null);
|
protected readonly createdPurchase = signal<PurchaseDetailResponse | null>(null);
|
||||||
protected readonly isLoadingPurchase = signal(true);
|
protected readonly isLoadingPurchase = signal(true);
|
||||||
protected readonly checkoutStepIndex = signal(0);
|
protected readonly checkoutStepIndex = signal(0);
|
||||||
|
protected readonly cartEditingEnabled = computed(
|
||||||
|
() => this.tenantService.tenant()?.cart_editing_enabled ?? true,
|
||||||
|
);
|
||||||
|
|
||||||
protected readonly cartSubtotal = computed(() => {
|
protected readonly cartSubtotal = computed(() => {
|
||||||
const purchase = this.createdPurchase();
|
const purchase = this.createdPurchase();
|
||||||
@@ -101,7 +104,6 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
protected readonly paymentMethods: ReadonlyArray<PaymentMethodOption> = [
|
protected readonly paymentMethods: ReadonlyArray<PaymentMethodOption> = [
|
||||||
{ id: 'qr', label: 'QR' },
|
{ id: 'qr', label: 'QR' },
|
||||||
{ id: 'transfer', label: 'Transferencia' },
|
{ id: 'transfer', label: 'Transferencia' },
|
||||||
{ id: 'telepagos', label: 'TelePagos' },
|
|
||||||
];
|
];
|
||||||
protected readonly selectedPaymentMethod = signal<PaymentMethod>('qr');
|
protected readonly selectedPaymentMethod = signal<PaymentMethod>('qr');
|
||||||
protected readonly copiedTransferField = signal<TransferField | null>(null);
|
protected readonly copiedTransferField = signal<TransferField | null>(null);
|
||||||
@@ -179,6 +181,10 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (editing && !this.cartEditingEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!editing) {
|
if (!editing) {
|
||||||
this.isEditingItems.set(false);
|
this.isEditingItems.set(false);
|
||||||
|
|
||||||
@@ -224,7 +230,14 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
const purchaseId = this.createdPurchaseId();
|
const purchaseId = this.createdPurchaseId();
|
||||||
const itemId = event.item.cartItemId;
|
const itemId = event.item.cartItemId;
|
||||||
|
|
||||||
if (!tenant || !purchaseId || !itemId || this.isUpdatingItem() || !this.isEditingItems()) {
|
if (
|
||||||
|
!this.cartEditingEnabled() ||
|
||||||
|
!tenant ||
|
||||||
|
!purchaseId ||
|
||||||
|
!itemId ||
|
||||||
|
this.isUpdatingItem() ||
|
||||||
|
!this.isEditingItems()
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,7 +344,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
const purchaseId = this.createdPurchaseId();
|
const purchaseId = this.createdPurchaseId();
|
||||||
const tenant = this.tenantService.tenant();
|
const tenant = this.tenantService.tenant();
|
||||||
|
|
||||||
if (!purchaseId || !tenant || method === 'telepagos') {
|
if (!purchaseId || !tenant) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { FormControl, FormGroup } from '@angular/forms';
|
import { FormControl, FormGroup } from '@angular/forms';
|
||||||
|
|
||||||
export type PaymentMethod = 'qr' | 'transfer' | 'telepagos';
|
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;
|
||||||
|
|||||||
@@ -18,14 +18,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<span class="payment-method__label">
|
<span class="payment-method__label">
|
||||||
@if (method.id === 'telepagos') {
|
{{ method.label }}
|
||||||
<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 }}
|
|
||||||
}
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<i class="fa-solid fa-angle-right payment-method__chevron" aria-hidden="true"></i>
|
<i class="fa-solid fa-angle-right payment-method__chevron" aria-hidden="true"></i>
|
||||||
@@ -59,8 +52,6 @@
|
|||||||
(submitDni)="generateTransferIntent.emit($event)"
|
(submitDni)="generateTransferIntent.emit($event)"
|
||||||
(completePurchase)="complete.emit()"
|
(completePurchase)="complete.emit()"
|
||||||
/>
|
/>
|
||||||
} @else {
|
|
||||||
<app-checkout-payment-telepagos />
|
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 {
|
.payment-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
||||||
> app-checkout-payment-qr,
|
> app-checkout-payment-qr,
|
||||||
> app-checkout-payment-transfer,
|
> app-checkout-payment-transfer {
|
||||||
> app-checkout-payment-telepagos {
|
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
||||||
|
|
||||||
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 { CheckoutPaymentTransferComponent } from './components/checkout-payment-transfer/checkout-payment-transfer.component';
|
import { CheckoutPaymentTransferComponent } from './components/checkout-payment-transfer/checkout-payment-transfer.component';
|
||||||
import {
|
import {
|
||||||
PaymentMethod,
|
PaymentMethod,
|
||||||
@@ -15,7 +14,7 @@ import {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-checkout-payment-step',
|
selector: 'app-checkout-payment-step',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CheckoutPaymentQrComponent, CheckoutPaymentTelepagosComponent, CheckoutPaymentTransferComponent],
|
imports: [CheckoutPaymentQrComponent, 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
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
|
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 { Tenant } from '../../../../core/services/tenant.interface';
|
||||||
import { TenantService } from '../../../../core/services/tenant.service';
|
import { TenantService } from '../../../../core/services/tenant.service';
|
||||||
import { PurchaseStatusPageComponent } from './purchase-status-page.component';
|
import { PurchaseStatusPageComponent } from './purchase-status-page.component';
|
||||||
@@ -130,4 +133,89 @@ describe('PurchaseStatusPageComponent', () => {
|
|||||||
|
|
||||||
openSpy.mockRestore();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { 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 { findMenu } from '../../../../core/services/menu.utils';
|
||||||
import { TenantService } from '../../../../core/services/tenant.service';
|
import { TenantService } from '../../../../core/services/tenant.service';
|
||||||
import { ButtonComponent } from '../../../../shared/components/button/button.component';
|
import { ButtonComponent } from '../../../../shared/components/button/button.component';
|
||||||
|
|
||||||
type PurchaseStatusView = 'approved' | 'pending' | 'rejected' | 'expired' | 'error';
|
type PurchaseStatusView = 'approved' | 'pending' | 'rejected' | 'expired' | 'error';
|
||||||
|
|
||||||
|
const PAYMENT_STATUS_POLL_INTERVAL_MS = 5_000;
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-purchase-status-page',
|
selector: 'app-purchase-status-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [ButtonComponent],
|
imports: [ButtonComponent],
|
||||||
templateUrl: './purchase-status-page.component.html',
|
templateUrl: './purchase-status-page.component.html',
|
||||||
styleUrl: './purchase-status-page.component.scss',
|
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 route = inject(ActivatedRoute);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly checkoutService = inject(CheckoutService);
|
private readonly checkoutService = inject(CheckoutService);
|
||||||
private readonly tenantService = inject(TenantService);
|
private readonly tenantService = inject(TenantService);
|
||||||
|
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
||||||
|
|
||||||
private purchaseId: string | null = null;
|
private purchaseId: string | null = null;
|
||||||
private tenantCode: 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 isLoading = signal(true);
|
||||||
protected readonly status = signal<PurchaseStatusView>('pending');
|
protected readonly status = signal<PurchaseStatusView>('pending');
|
||||||
@@ -57,7 +75,12 @@ export class PurchaseStatusPageComponent implements OnInit {
|
|||||||
void this.loadStatus();
|
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) {
|
if (!this.purchaseId || !this.tenantCode) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -66,13 +89,52 @@ export class PurchaseStatusPageComponent implements OnInit {
|
|||||||
const purchase = await this.checkoutService
|
const purchase = await this.checkoutService
|
||||||
.withCustomLoading()
|
.withCustomLoading()
|
||||||
.getPurchase(this.tenantCode, this.purchaseId);
|
.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);
|
this.hasGeneratedTickets.set(purchase.has_generated_tickets === true);
|
||||||
|
|
||||||
|
if (status === 'pending') {
|
||||||
|
this.schedulePolling();
|
||||||
|
} else {
|
||||||
|
this.stopPolling();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch purchase status:', 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 {
|
} 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,17 @@
|
|||||||
<span class="ticket-selector__label">Seleccioná Entrada/s:</span>
|
<span class="ticket-selector__label">Seleccioná Entrada/s:</span>
|
||||||
|
|
||||||
<div class="ticket-selector__rows">
|
<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) {
|
@for (row of rows(); track row.id) {
|
||||||
<div class="ticket-selector__row">
|
<div class="ticket-selector__row">
|
||||||
<div class="ticket-selector__row-content">
|
<div class="ticket-selector__row-content">
|
||||||
|
|||||||
@@ -57,6 +57,30 @@
|
|||||||
gap: 0.5rem;
|
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 {
|
&__row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
@@ -160,6 +184,10 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__columns {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
&__map {
|
&__map {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,6 +209,23 @@ describe('ProductTicketSelectorComponent', () => {
|
|||||||
expect(removeButton?.classList.contains('icon-btn--bordered')).toBe(true);
|
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 () => {
|
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 failed = variant(401, 'general', 'A', '1', '1');
|
||||||
const alternative = variant(402, 'general', 'A', '1', '2');
|
const alternative = variant(402, 'general', 'A', '1', '2');
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export class ProductTicketSelectorComponent {
|
|||||||
private readonly viewReady = signal(false);
|
private readonly viewReady = signal(false);
|
||||||
private readonly mapReady = signal(false);
|
private readonly mapReady = signal(false);
|
||||||
private readonly variants = signal<CatalogFeaturedItemVariant[]>([]);
|
private readonly variants = signal<CatalogFeaturedItemVariant[]>([]);
|
||||||
private readonly attributes = signal<VariantAttribute[]>([]);
|
protected readonly attributes = signal<VariantAttribute[]>([]);
|
||||||
private mapRequest: Subscription | null = null;
|
private mapRequest: Subscription | null = null;
|
||||||
|
|
||||||
readonly productId = input.required<number>();
|
readonly productId = input.required<number>();
|
||||||
|
|||||||
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