Compare commits

...

5 Commits

12 changed files with 236 additions and 50 deletions

View File

@@ -22,14 +22,18 @@
<div class="col-12 col-md-6 col-xl-5 d-grid gap-3 align-content-start store-layout__contact">
<div class="d-grid gap-2 store-layout__contact-details">
@if (address) {
<div class="d-flex align-items-center gap-2">
<i class="fa-solid fa-location-dot" aria-hidden="true"></i>
<span>Av. San Lorenzo 1542, Rosario</span>
<span>{{ address }}</span>
</div>
}
@if (phone) {
<div class="d-flex align-items-center gap-2">
<i class="fa-solid fa-phone" aria-hidden="true"></i>
<span>54 9 (0341) 6658247</span>
<span>{{ phone }}</span>
</div>
}
</div>
<p class="mb-0 small store-layout__muted-text store-layout__copyright">

View File

@@ -27,5 +27,7 @@ export class StoreFooterComponent {
@Input() logoUrl: string | null = null;
@Input() backgroundImageUrl: string | null = null;
@Input() storeName: string | null = null;
@Input() address: string | null = null;
@Input() phone: string | null = null;
readonly logoutClick = output<void>();
}

View File

@@ -67,6 +67,8 @@
[logoUrl]="tenant()?.footer_logo ?? null"
[backgroundImageUrl]="tenant()?.footer_bg_image ?? null"
[storeName]="tenant()?.nombre ?? null"
[address]="tenant()?.address ?? null"
[phone]="tenant()?.phone ?? null"
(logoutClick)="onLogoutClick()"
/>
</div>

View File

@@ -28,6 +28,8 @@ const tenant: Tenant = {
codigo: 'test',
nombre: 'Test Tenant',
dominio: 'localhost',
address: 'Calle Test 123, Rosario',
phone: '+54 341 555 1234',
primary_color: '#6376F3',
secondary_color: '#A0A0A0',
danger_color: '#FF8888',
@@ -299,6 +301,12 @@ describe('StoreLayoutComponent', () => {
expect(compiled.querySelector('.fa-whatsapp')).not.toBeNull();
expect(compiled.querySelector('.fa-facebook')).not.toBeNull();
expect(compiled.querySelector('.fa-linkedin-in')).not.toBeNull();
expect(compiled.querySelector('.store-layout__contact-details')?.textContent).toContain(
tenant.address,
);
expect(compiled.querySelector('.store-layout__contact-details')?.textContent).toContain(
tenant.phone,
);
});
it('navigates to search results when the search button is clicked', () => {

View File

@@ -187,8 +187,8 @@ export class StoreLayoutComponent implements OnInit {
protected async onLogoutClick(): Promise<void> {
const isLeavingCheckout = this.router.url.startsWith('/checkout');
// Checkout must be left while the authenticated session is still valid so
// its CanDeactivate guard can cancel the pending purchase.
// Leave checkout before closing the authenticated session so its component
// can stop payment polling cleanly. The checkout itself remains pending.
if (isLeavingCheckout) {
const navigationSucceeded = await this.router.navigate(['/']);

View File

@@ -155,6 +155,7 @@ export type CatalogFeaturedItems =
export interface CatalogFeaturedGroup {
id: number;
code: string;
title: string;
layout: CatalogProductLayout;
group_layout: CatalogGroupLayout;

View File

@@ -113,6 +113,8 @@ export interface Tenant {
dominio: string;
base_path?: string;
site_title?: string | null;
address?: string | null;
phone?: string | null;
favicon?: string | null;
primary_color: string;
secondary_color: string;

View File

@@ -1,3 +1,4 @@
import { HttpErrorResponse } from '@angular/common/http';
import { signal } from '@angular/core';
import { getTestBed, TestBed } from '@angular/core/testing';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
@@ -10,6 +11,7 @@ import { CartService } from '../../../../core/services/cart/cart.service';
import { CheckoutService } from '../../../../core/services/checkout.service';
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { TenantService } from '../../../../core/services/tenant.service';
import { ToastService } from '../../../../core/services/toast.service';
import { CartEditingPolicy } from '../../../../core/services/tenant.interface';
import { CheckoutPageComponent } from './checkout-page.component';
@@ -23,6 +25,7 @@ describe('CheckoutPageComponent payment validation', () => {
prepareItemEditing: ReturnType<typeof vi.fn>;
cancelPurchase: ReturnType<typeof vi.fn>;
generatePaymentIntent: ReturnType<typeof vi.fn>;
submitPurchaseForReview: ReturnType<typeof vi.fn>;
getPurchase: ReturnType<typeof vi.fn>;
withCustomLoading: ReturnType<typeof vi.fn>;
};
@@ -33,6 +36,7 @@ describe('CheckoutPageComponent payment validation', () => {
clearCart: ReturnType<typeof vi.fn>;
};
let routerStub: { navigate: ReturnType<typeof vi.fn> };
let toastServiceStub: { danger: ReturnType<typeof vi.fn> };
let routeQueryParamMap: ReturnType<typeof convertToParamMap>;
let authUserState: ReturnType<typeof signal>;
let tenantState: ReturnType<
@@ -69,6 +73,7 @@ describe('CheckoutPageComponent payment validation', () => {
generatePaymentIntent: vi.fn().mockResolvedValue({
qr_data: { qr_code: 'qr-value' },
}),
submitPurchaseForReview: vi.fn().mockResolvedValue({ status: 'pending_payment' }),
getPurchase: vi.fn().mockResolvedValue({ status: 'pending_payment' }),
withCustomLoading: vi.fn(),
};
@@ -95,6 +100,7 @@ describe('CheckoutPageComponent payment validation', () => {
});
});
routerStub = { navigate: vi.fn() };
toastServiceStub = { danger: vi.fn() };
routeQueryParamMap = convertToParamMap({});
authUserState = signal(null);
tenantState = signal({
@@ -116,6 +122,7 @@ describe('CheckoutPageComponent payment validation', () => {
{ provide: CartService, useValue: cartServiceStub },
{ provide: TenantService, useValue: { tenant: tenantState } },
{ provide: AuthService, useValue: { user: authUserState } },
{ provide: ToastService, useValue: toastServiceStub },
{
provide: ActivatedRoute,
useValue: {
@@ -218,8 +225,9 @@ describe('CheckoutPageComponent payment validation', () => {
const { component } = createComponent();
component.selectedPaymentMethod.set('transfer');
component.onComplete();
await component.onComplete();
expect(component.transferValidationStatus()).toBe('checking');
expect(checkoutServiceStub.submitPurchaseForReview).toHaveBeenCalledWith('tenant-test', 25);
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
for (let attempt = 1; attempt <= 3; attempt += 1) {
@@ -241,7 +249,7 @@ describe('CheckoutPageComponent payment validation', () => {
const { component } = createComponent();
component.selectedPaymentMethod.set('transfer');
component.onComplete();
await component.onComplete();
await vi.advanceTimersByTimeAsync(3_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledWith('tenant-test', 25);
@@ -255,7 +263,7 @@ describe('CheckoutPageComponent payment validation', () => {
const { component } = createComponent();
component.selectedPaymentMethod.set('transfer');
component.onComplete();
await component.onComplete();
await vi.advanceTimersByTimeAsync(12_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(4);
@@ -264,6 +272,20 @@ describe('CheckoutPageComponent payment validation', () => {
expect(routerStub.navigate).not.toHaveBeenCalled();
});
it('does not poll when submitting a transfer for review fails', async () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined);
checkoutServiceStub.submitPurchaseForReview.mockRejectedValue(new Error('network error'));
const { component } = createComponent();
component.selectedPaymentMethod.set('transfer');
await component.onComplete();
await vi.advanceTimersByTimeAsync(12_000);
expect(checkoutServiceStub.submitPurchaseForReview).toHaveBeenCalledWith('tenant-test', 25);
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
expect(component.transferValidationStatus()).toBe('error');
});
it('cancels transfer polling when the payment method changes or the component is destroyed', async () => {
const first = createComponent();
first.component.selectedPaymentMethod.set('transfer');
@@ -608,14 +630,65 @@ describe('CheckoutPageComponent payment validation', () => {
expect(component.stepper.next).toHaveBeenCalledOnce();
});
it('cancels the pending purchase before allowing navigation away', async () => {
it('keeps the checkout purchase intact when navigating away', async () => {
const { component } = createComponent();
await expect(component.canDeactivate()).resolves.toBe(true);
expect(checkoutServiceStub.cancelPurchase).toHaveBeenCalledWith('tenant-test', 25);
expect(checkoutServiceStub.cancelPurchase).not.toHaveBeenCalled();
expect(cartServiceStub.loadCart).toHaveBeenCalled();
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(component.createdPurchaseId()).toBeNull();
expect(component.createdPurchaseId()).toBe(25);
});
it('shows the API message and redirects home when a checkout operation finds an expired purchase', async () => {
checkoutServiceStub.generatePaymentIntent.mockRejectedValue(
new HttpErrorResponse({
status: 422,
error: {
code: 'purchase.expired',
message: 'La compra venci\u00f3. Inici\u00e1 una nueva compra.',
},
}),
);
const { component } = createComponent();
await component.selectPaymentMethod('qr');
expect(toastServiceStub.danger).toHaveBeenCalledWith(
'La compra venci\u00f3. Inici\u00e1 una nueva compra.',
);
expect(routerStub.navigate).toHaveBeenCalledWith(['/']);
expect(component.isGeneratingIntent()).toBe(false);
});
it('treats a generic customer-data error as expired when the local deadline passed', () => {
const { component } = createComponent();
component.createdPurchase.set({
id: 25,
status: 'created',
expires_at: new Date(Date.now() - 1_000).toISOString(),
items: [],
subtotal: '100.00',
total: '100.00',
});
const handled = component.handleCheckoutError(
new HttpErrorResponse({
status: 422,
error: {
errors: {
purchase: ['La compra ya no se puede modificar.'],
},
},
}),
'No se pudieron actualizar los datos de la compra.',
);
expect(handled).toBe(true);
expect(toastServiceStub.danger).toHaveBeenCalledWith(
'La compra venci\u00f3. Inici\u00e1 una nueva compra.',
);
expect(routerStub.navigate).toHaveBeenCalledWith(['/']);
});
});

View File

@@ -8,6 +8,7 @@ import {
signal,
ViewChild,
} from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { FormBuilder, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { firstValueFrom, startWith } from 'rxjs';
@@ -20,6 +21,7 @@ import {
} from '../../../../core/services/checkout.service';
import { AuthService } from '../../../../core/services/auth/auth.service';
import { CartService } from '../../../../core/services/cart/cart.service';
import { ToastService } from '../../../../core/services/toast.service';
import { BankAccount } from '../../../../core/services/tenant.interface';
import { CartComponent, CartItemMock } from '../../../../shared/components/cart/cart.component';
import { StepComponent } from '../../../../shared/components/stepper/step.component';
@@ -36,6 +38,12 @@ import {
TransferValidationStatus,
} from './checkout-page.models';
interface ApiErrorResponse {
code?: string;
message?: string;
errors?: Record<string, string[]>;
}
@Component({
selector: 'app-checkout-page',
standalone: true,
@@ -59,6 +67,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
private readonly authService = inject(AuthService);
private readonly cartService = inject(CartService);
private readonly qrPollingIntervalMs = 5_000;
private readonly toastService = inject(ToastService);
private readonly qrPollingMaxAttempts = 9;
private readonly transferPollingIntervalMs = 3_000;
private readonly transferPollingMaxAttempts = 4;
@@ -199,9 +208,28 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
}
if (editing && !this.canModifyCart()) {
const tenant = this.tenantService.tenant();
const purchaseId = this.createdPurchaseId();
if (!tenant || !purchaseId) {
return;
}
this.isPreparingItemEdit.set(true);
try {
await this.checkoutService.cancelPurchase(tenant.codigo, purchaseId);
this.createdPurchaseId.set(null);
this.createdPurchase.set(null);
await firstValueFrom(this.cartService.loadCart());
await this.router.navigate(['/'], {
queryParams: { openCart: true },
});
} catch (error) {
this.handleCheckoutError(error, 'No se pudo restaurar el carrito para editarlo.');
} finally {
this.isPreparingItemEdit.set(false);
}
return;
}
@@ -236,7 +264,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
const purchase = await this.checkoutService.prepareItemEditing(tenant.codigo, purchaseId);
this.createdPurchase.set(purchase);
} catch (error) {
console.error('Failed to prepare purchase item editing:', error);
this.handleCheckoutError(error, 'No se pudo preparar la compra para editarla.');
this.isEditingItems.set(false);
} finally {
this.isPreparingItemEdit.set(false);
@@ -274,7 +302,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
);
this.createdPurchase.set(purchase);
} catch (error) {
console.error('Failed to update purchase item quantity:', error);
this.handleCheckoutError(error, 'No se pudo actualizar la cantidad del producto.');
} finally {
this.isUpdatingItem.set(false);
}
@@ -313,7 +341,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
),
);
} catch (error) {
console.error('Failed to update purchase item variant:', error);
this.handleCheckoutError(error, 'No se pudo actualizar la variante del producto.');
} finally {
this.isUpdatingItem.set(false);
}
@@ -347,7 +375,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
),
);
} catch (error) {
console.error('Failed to remove purchase item:', error);
this.handleCheckoutError(error, 'No se pudo eliminar el producto de la compra.');
} finally {
this.isUpdatingItem.set(false);
}
@@ -377,8 +405,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
// Auto trigger intent for default option
void this.selectPaymentMethod(this.selectedPaymentMethod());
} catch (error) {
console.error('Failed to create purchase:', error);
// Here we could show an alert or toast
this.handleCheckoutError(error, 'No se pudieron actualizar los datos de la compra.');
} finally {
this.isUpdatingPurchase.set(false);
}
@@ -398,29 +425,15 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
return true;
}
const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant();
if (!purchaseId || !tenant) {
return true;
}
try {
await this.checkoutService.cancelPurchase(tenant.codigo, purchaseId);
this.createdPurchaseId.set(null);
this.createdPurchase.set(null);
// The checkout cart remains attached to its purchase. Once the user adds a
// new item, the cart API creates a separate active cart automatically.
try {
await firstValueFrom(this.cartService.loadCart());
} catch (error) {
console.error('Failed to restore cart after cancelling checkout:', error);
console.error('Failed to load the active cart after leaving checkout:', error);
}
return true;
} catch (error) {
console.error('Failed to cancel purchase:', error);
return false;
}
}
protected async selectPaymentMethod(method: PaymentMethod): Promise<void> {
@@ -468,7 +481,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
this.startQrPolling();
}
} catch (error) {
console.error('Failed to generate payment intent:', error);
this.handleCheckoutError(error, 'No se pudo generar la intenci\u00f3n de pago.');
} finally {
this.isGeneratingIntent.set(false);
}
@@ -505,7 +518,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
});
}
} catch (error) {
console.error('Failed to generate transfer payment intent:', error);
this.handleCheckoutError(error, 'No se pudo generar la intenci\u00f3n de transferencia.');
} finally {
this.isGeneratingIntent.set(false);
}
@@ -528,7 +541,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
}
}
protected onComplete(): void {
protected async onComplete(): Promise<void> {
const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant();
@@ -547,7 +560,32 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
this.transferPollingAttempts = 0;
const runId = this.transferPollingRunId;
try {
const purchase = await this.checkoutService
.withCustomLoading()
.submitPurchaseForReview(tenant.codigo, purchaseId);
if (runId !== this.transferPollingRunId) {
return;
}
if (purchase.status === 'paid') {
this.navigateToPurchaseStatus(purchaseId);
return;
}
this.scheduleTransferPoll(runId);
} catch (error) {
const expired = this.handleCheckoutError(
error,
'No se pudo enviar la compra a revisi\u00f3n.',
);
if (!expired && runId === this.transferPollingRunId) {
this.transferValidationStatus.set('error');
}
}
}
private scheduleTransferPoll(runId: number): void {
@@ -776,4 +814,54 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
void this.router.navigate(['/']);
}
}
private handleCheckoutError(error: unknown, fallbackMessage: string): boolean {
if (!(error instanceof HttpErrorResponse)) {
this.toastService.danger(fallbackMessage);
return false;
}
const response = error.error as ApiErrorResponse | null;
if (response?.code === 'purchase.expired' || this.hasExpiredPurchase()) {
this.navigationStarted = true;
this.stopQrPolling();
this.stopTransferPolling();
this.toastService.danger(
response?.code === 'purchase.expired' && response.message
? response.message
: 'La compra venci\u00f3. Inici\u00e1 una nueva compra.',
);
void this.router.navigate(['/']);
return true;
}
const validationMessage = response?.errors
? Object.values(response.errors).flat().find(Boolean)
: undefined;
this.toastService.danger(validationMessage ?? response?.message ?? fallbackMessage);
return false;
}
private hasExpiredPurchase(): boolean {
const purchase = this.createdPurchase();
if (!purchase) {
return false;
}
if (purchase.status === 'expired') {
return true;
}
if (!purchase.expires_at) {
return false;
}
return Date.parse(purchase.expires_at) <= Date.now();
}
}

View File

@@ -23,7 +23,7 @@
</p>
} @else {
@for (group of catalog(); track group.id) {
<app-store-section [title]="group.title">
<app-store-section [attr.id]="group.code" [title]="group.title">
<app-product-list
[layout]="group.layout"
[groupLayout]="group.group_layout"

View File

@@ -11,6 +11,10 @@ app-store-section + app-store-section {
margin-top: clamp(3rem, 6vw, 5rem);
}
app-store-section[id] {
scroll-margin-top: 8rem;
}
:host > .store-home__additional-info:not(:first-child) {
margin-top: clamp(3rem, 6vw, 5rem);
}

View File

@@ -58,6 +58,7 @@ function createCatalog(
return [
{
id: 7,
code: 'destacados',
title: 'Destacados',
layout: 'column_with_image',
group_layout: 'paginated',
@@ -149,6 +150,7 @@ describe('StoreHomePageComponent', () => {
const element = fixture.nativeElement as HTMLElement;
expect(catalogServiceStub.getCatalog).not.toHaveBeenCalled();
expect(element.querySelector('app-store-section')?.id).toBe('destacados');
expect(element.querySelector('.store-section__title')?.textContent?.trim()).toBe('Destacados');
expect(element.querySelectorAll('app-product-column-with-image')).toHaveLength(2);
expect(element.textContent).toContain('Auriculares Bluetooth');