diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 1404718..49ee050 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -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): boolean { return ( @@ -26,6 +27,7 @@ export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideRouter(routes), + { provide: UrlSerializer, useClass: TenantUrlSerializer }, provideClientHydration( withHttpTransferCacheOptions({ includeRequestsWithAuthHeaders: true, diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 1bf87ad..d4afdd4 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -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, + ) } ]; diff --git a/src/app/core/layout/store-layout/store-layout.component.html b/src/app/core/layout/store-layout/store-layout.component.html index 96036aa..204c82d 100644 --- a/src/app/core/layout/store-layout/store-layout.component.html +++ b/src/app/core/layout/store-layout/store-layout.component.html @@ -29,6 +29,7 @@ [subtotal]="cartSubtotal()" [discount]="cartDiscount()" [total]="cartTotal()" + [readonly]="!cartEditingEnabled()" [backgroundColor]="'#ffffff'" (closed)="isCartOpen.set(false)" > diff --git a/src/app/core/layout/store-layout/store-layout.component.spec.ts b/src/app/core/layout/store-layout/store-layout.component.spec.ts index 190f689..1b6758d 100644 --- a/src/app/core/layout/store-layout/store-layout.component.spec.ts +++ b/src/app/core/layout/store-layout/store-layout.component.spec.ts @@ -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(); + }); }); diff --git a/src/app/core/layout/store-layout/store-layout.component.ts b/src/app/core/layout/store-layout/store-layout.component.ts index 87f919e..47d17e6 100644 --- a/src/app/core/layout/store-layout/store-layout.component.ts +++ b/src/app/core/layout/store-layout/store-layout.component.ts @@ -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(); diff --git a/src/app/core/services/auth/auth.service.ts b/src/app/core/services/auth/auth.service.ts index 73f921f..ae1d310 100644 --- a/src/app/core/services/auth/auth.service.ts +++ b/src/app/core/services/auth/auth.service.ts @@ -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()); } diff --git a/src/app/core/services/checkout.service.ts b/src/app/core/services/checkout.service.ts index 80e537a..f97eea4 100644 --- a/src/app/core/services/checkout.service.ts +++ b/src/app/core/services/checkout.service.ts @@ -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 { const payload: any = { method }; diff --git a/src/app/core/services/tenant-url.serializer.spec.ts b/src/app/core/services/tenant-url.serializer.spec.ts new file mode 100644 index 0000000..ebcd141 --- /dev/null +++ b/src/app/core/services/tenant-url.serializer.spec.ts @@ -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'); + }); +}); diff --git a/src/app/core/services/tenant-url.serializer.ts b/src/app/core/services/tenant-url.serializer.ts new file mode 100644 index 0000000..97eb37a --- /dev/null +++ b/src/app/core/services/tenant-url.serializer.ts @@ -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 === '#'; + } +} diff --git a/src/app/core/services/tenant.interface.ts b/src/app/core/services/tenant.interface.ts index 34c6f33..1ee741c 100644 --- a/src/app/core/services/tenant.interface.ts +++ b/src/app/core/services/tenant.interface.ts @@ -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[]; diff --git a/src/app/features/store/pages/checkout-page/checkout-page.component.html b/src/app/features/store/pages/checkout-page/checkout-page.component.html index f062cd9..33a2c30 100644 --- a/src/app/features/store/pages/checkout-page/checkout-page.component.html +++ b/src/app/features/store/pages/checkout-page/checkout-page.component.html @@ -58,6 +58,7 @@ [subtotal]="cartSubtotal()" [discount]="cartDiscount()" [total]="cartTotal()" + [readonly]="!cartEditingEnabled()" [allowEditing]=" createdPurchase()?.status === 'created' || createdPurchase()?.status === 'pending_payment' " diff --git a/src/app/features/store/pages/checkout-page/checkout-page.component.spec.ts b/src/app/features/store/pages/checkout-page/checkout-page.component.spec.ts index c3f71b7..61cf098 100644 --- a/src/app/features/store/pages/checkout-page/checkout-page.component.spec.ts +++ b/src/app/features/store/pages/checkout-page/checkout-page.component.spec.ts @@ -21,7 +21,6 @@ describe('CheckoutPageComponent payment validation', () => { cancelPurchase: ReturnType; generatePaymentIntent: ReturnType; getPurchase: ReturnType; - submitPurchaseForReview: ReturnType; withCustomLoading: ReturnType; }; let cartServiceStub: { @@ -33,6 +32,7 @@ describe('CheckoutPageComponent payment validation', () => { let routerStub: { navigate: ReturnType }; let routeQueryParamMap: ReturnType; let authUserState: ReturnType; + let tenantState: ReturnType>; 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, diff --git a/src/app/features/store/pages/checkout-page/checkout-page.component.ts b/src/app/features/store/pages/checkout-page/checkout-page.component.ts index 6db657f..59c6286 100644 --- a/src/app/features/store/pages/checkout-page/checkout-page.component.ts +++ b/src/app/features/store/pages/checkout-page/checkout-page.component.ts @@ -79,6 +79,9 @@ export class CheckoutPageComponent implements OnInit, OnDestroy { protected readonly createdPurchase = signal(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 = [ { id: 'qr', label: 'QR' }, { id: 'transfer', label: 'Transferencia' }, - { id: 'telepagos', label: 'TelePagos' }, ]; protected readonly selectedPaymentMethod = signal('qr'); protected readonly copiedTransferField = signal(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'); } } diff --git a/src/app/features/store/pages/checkout-page/checkout-page.models.ts b/src/app/features/store/pages/checkout-page/checkout-page.models.ts index 652a466..b9d2e86 100644 --- a/src/app/features/store/pages/checkout-page/checkout-page.models.ts +++ b/src/app/features/store/pages/checkout-page/checkout-page.models.ts @@ -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; diff --git a/src/app/features/store/pages/checkout-page/checkout-payment-step.component.html b/src/app/features/store/pages/checkout-page/checkout-payment-step.component.html index 5bb2d70..1c28710 100644 --- a/src/app/features/store/pages/checkout-page/checkout-payment-step.component.html +++ b/src/app/features/store/pages/checkout-page/checkout-payment-step.component.html @@ -18,14 +18,7 @@ /> - @if (method.id === 'telepagos') { - - } @else { - {{ method.label }} - } + {{ method.label }} @@ -59,8 +52,6 @@ (submitDni)="generateTransferIntent.emit($event)" (completePurchase)="complete.emit()" /> - } @else { - } diff --git a/src/app/features/store/pages/checkout-page/checkout-payment-step.component.scss b/src/app/features/store/pages/checkout-page/checkout-payment-step.component.scss index 1531030..2db76a6 100644 --- a/src/app/features/store/pages/checkout-page/checkout-payment-step.component.scss +++ b/src/app/features/store/pages/checkout-page/checkout-payment-step.component.scss @@ -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; diff --git a/src/app/features/store/pages/checkout-page/checkout-payment-step.component.ts b/src/app/features/store/pages/checkout-page/checkout-payment-step.component.ts index 60b9630..c84ffc0 100644 --- a/src/app/features/store/pages/checkout-page/checkout-payment-step.component.ts +++ b/src/app/features/store/pages/checkout-page/checkout-payment-step.component.ts @@ -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 diff --git a/src/app/features/store/pages/checkout-page/components/checkout-payment-telepagos/checkout-payment-telepagos.component.html b/src/app/features/store/pages/checkout-page/components/checkout-payment-telepagos/checkout-payment-telepagos.component.html deleted file mode 100644 index 3e21971..0000000 --- a/src/app/features/store/pages/checkout-page/components/checkout-payment-telepagos/checkout-payment-telepagos.component.html +++ /dev/null @@ -1,22 +0,0 @@ -
-

Descarga la app de TelePagos para finalizar la compra

-
- -
-
- - - Disponible en - Google Play - -
- -
- - - Descargalo en - App Store - -
-
-
diff --git a/src/app/features/store/pages/checkout-page/components/checkout-payment-telepagos/checkout-payment-telepagos.component.scss b/src/app/features/store/pages/checkout-page/components/checkout-payment-telepagos/checkout-payment-telepagos.component.scss deleted file mode 100644 index d8bace7..0000000 --- a/src/app/features/store/pages/checkout-page/components/checkout-payment-telepagos/checkout-payment-telepagos.component.scss +++ /dev/null @@ -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; - } - } -} diff --git a/src/app/features/store/pages/checkout-page/components/checkout-payment-telepagos/checkout-payment-telepagos.component.ts b/src/app/features/store/pages/checkout-page/components/checkout-payment-telepagos/checkout-payment-telepagos.component.ts deleted file mode 100644 index 0a35b23..0000000 --- a/src/app/features/store/pages/checkout-page/components/checkout-payment-telepagos/checkout-payment-telepagos.component.ts +++ /dev/null @@ -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 {} diff --git a/src/app/features/store/pages/checkout-page/components/checkout-payment-transfer/checkout-payment-transfer.component.html b/src/app/features/store/pages/checkout-page/components/checkout-payment-transfer/checkout-payment-transfer.component.html index 1808163..46167a6 100644 --- a/src/app/features/store/pages/checkout-page/components/checkout-payment-transfer/checkout-payment-transfer.component.html +++ b/src/app/features/store/pages/checkout-page/components/checkout-payment-transfer/checkout-payment-transfer.component.html @@ -1,5 +1,5 @@
- @if (validationStatus() === 'pending' || validationStatus() === 'error') { + @if (validationStatus() === 'error') {
} + + @if (validationStatus() === 'checking') { +
+ + Verificando pago +
+ } } diff --git a/src/app/features/store/pages/checkout-page/components/checkout-payment-transfer/checkout-payment-transfer.component.scss b/src/app/features/store/pages/checkout-page/components/checkout-payment-transfer/checkout-payment-transfer.component.scss index def5cf0..e937bd4 100644 --- a/src/app/features/store/pages/checkout-page/components/checkout-payment-transfer/checkout-payment-transfer.component.scss +++ b/src/app/features/store/pages/checkout-page/components/checkout-payment-transfer/checkout-payment-transfer.component.scss @@ -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; diff --git a/src/app/features/store/pages/checkout-page/components/checkout-payment-transfer/checkout-payment-transfer.component.spec.ts b/src/app/features/store/pages/checkout-page/components/checkout-payment-transfer/checkout-payment-transfer.component.spec.ts new file mode 100644 index 0000000..ec21f16 --- /dev/null +++ b/src/app/features/store/pages/checkout-page/components/checkout-payment-transfer/checkout-payment-transfer.component.spec.ts @@ -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(); + }); +}); diff --git a/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.spec.ts b/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.spec.ts index 49c81e7..a41a839 100644 --- a/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.spec.ts +++ b/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.spec.ts @@ -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(); + } + }); }); diff --git a/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.ts b/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.ts index a397a6d..2797027 100644 --- a/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.ts +++ b/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.ts @@ -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 | null = null; + private isDestroyed = false; protected readonly isLoading = signal(true); protected readonly status = signal('pending'); @@ -57,7 +75,12 @@ export class PurchaseStatusPageComponent implements OnInit { void this.loadStatus(); } - private async loadStatus(): Promise { + ngOnDestroy(): void { + this.isDestroyed = true; + this.stopPolling(); + } + + private async loadStatus(isPolling = false): Promise { 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; } } diff --git a/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.html b/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.html index da697a2..b38192e 100644 --- a/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.html +++ b/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.html @@ -20,6 +20,17 @@ Seleccioná Entrada/s:
+ @if (attributes().length > 0) { + + } + @for (row of rows(); track row.id) {
diff --git a/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.scss b/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.scss index 197ff8a..4b2496a 100644 --- a/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.scss +++ b/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.scss @@ -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; } diff --git a/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.spec.ts b/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.spec.ts index d78db61..311d51a 100644 --- a/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.spec.ts +++ b/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.spec.ts @@ -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'); diff --git a/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.ts b/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.ts index 254af04..5803f48 100644 --- a/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.ts +++ b/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.ts @@ -71,7 +71,7 @@ export class ProductTicketSelectorComponent { private readonly viewReady = signal(false); private readonly mapReady = signal(false); private readonly variants = signal([]); - private readonly attributes = signal([]); + protected readonly attributes = signal([]); private mapRequest: Subscription | null = null; readonly productId = input.required(); diff --git a/src/app/shared/pages/route-not-found-page.component.ts b/src/app/shared/pages/route-not-found-page.component.ts new file mode 100644 index 0000000..bcbafb0 --- /dev/null +++ b/src/app/shared/pages/route-not-found-page.component.ts @@ -0,0 +1,27 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-route-not-found-page', + template: ` +
+

404

+

No encontramos la página solicitada.

+
+ `, + 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 {}