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="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"> <div class="d-grid gap-2 store-layout__contact-details">
<div class="d-flex align-items-center gap-2"> @if (address) {
<i class="fa-solid fa-location-dot" aria-hidden="true"></i> <div class="d-flex align-items-center gap-2">
<span>Av. San Lorenzo 1542, Rosario</span> <i class="fa-solid fa-location-dot" aria-hidden="true"></i>
</div> <span>{{ address }}</span>
<div class="d-flex align-items-center gap-2"> </div>
<i class="fa-solid fa-phone" aria-hidden="true"></i> }
<span>54 9 (0341) 6658247</span> @if (phone) {
</div> <div class="d-flex align-items-center gap-2">
<i class="fa-solid fa-phone" aria-hidden="true"></i>
<span>{{ phone }}</span>
</div>
}
</div> </div>
<p class="mb-0 small store-layout__muted-text store-layout__copyright"> <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() logoUrl: string | null = null;
@Input() backgroundImageUrl: string | null = null; @Input() backgroundImageUrl: string | null = null;
@Input() storeName: string | null = null; @Input() storeName: string | null = null;
@Input() address: string | null = null;
@Input() phone: string | null = null;
readonly logoutClick = output<void>(); readonly logoutClick = output<void>();
} }

View File

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

View File

@@ -28,6 +28,8 @@ const tenant: Tenant = {
codigo: 'test', codigo: 'test',
nombre: 'Test Tenant', nombre: 'Test Tenant',
dominio: 'localhost', dominio: 'localhost',
address: 'Calle Test 123, Rosario',
phone: '+54 341 555 1234',
primary_color: '#6376F3', primary_color: '#6376F3',
secondary_color: '#A0A0A0', secondary_color: '#A0A0A0',
danger_color: '#FF8888', danger_color: '#FF8888',
@@ -299,6 +301,12 @@ describe('StoreLayoutComponent', () => {
expect(compiled.querySelector('.fa-whatsapp')).not.toBeNull(); expect(compiled.querySelector('.fa-whatsapp')).not.toBeNull();
expect(compiled.querySelector('.fa-facebook')).not.toBeNull(); expect(compiled.querySelector('.fa-facebook')).not.toBeNull();
expect(compiled.querySelector('.fa-linkedin-in')).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', () => { 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> { protected async onLogoutClick(): Promise<void> {
const isLeavingCheckout = this.router.url.startsWith('/checkout'); const isLeavingCheckout = this.router.url.startsWith('/checkout');
// Checkout must be left while the authenticated session is still valid so // Leave checkout before closing the authenticated session so its component
// its CanDeactivate guard can cancel the pending purchase. // can stop payment polling cleanly. The checkout itself remains pending.
if (isLeavingCheckout) { if (isLeavingCheckout) {
const navigationSucceeded = await this.router.navigate(['/']); const navigationSucceeded = await this.router.navigate(['/']);

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import { HttpErrorResponse } from '@angular/common/http';
import { signal } from '@angular/core'; import { signal } from '@angular/core';
import { getTestBed, TestBed } from '@angular/core/testing'; import { getTestBed, TestBed } from '@angular/core/testing';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/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 { CheckoutService } from '../../../../core/services/checkout.service';
import { CatalogService } from '../../../../core/services/catalog/catalog.service'; import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { TenantService } from '../../../../core/services/tenant.service'; import { TenantService } from '../../../../core/services/tenant.service';
import { ToastService } from '../../../../core/services/toast.service';
import { CartEditingPolicy } from '../../../../core/services/tenant.interface'; import { CartEditingPolicy } from '../../../../core/services/tenant.interface';
import { CheckoutPageComponent } from './checkout-page.component'; import { CheckoutPageComponent } from './checkout-page.component';
@@ -23,6 +25,7 @@ describe('CheckoutPageComponent payment validation', () => {
prepareItemEditing: ReturnType<typeof vi.fn>; prepareItemEditing: ReturnType<typeof vi.fn>;
cancelPurchase: ReturnType<typeof vi.fn>; cancelPurchase: ReturnType<typeof vi.fn>;
generatePaymentIntent: ReturnType<typeof vi.fn>; generatePaymentIntent: ReturnType<typeof vi.fn>;
submitPurchaseForReview: ReturnType<typeof vi.fn>;
getPurchase: ReturnType<typeof vi.fn>; getPurchase: ReturnType<typeof vi.fn>;
withCustomLoading: ReturnType<typeof vi.fn>; withCustomLoading: ReturnType<typeof vi.fn>;
}; };
@@ -33,6 +36,7 @@ describe('CheckoutPageComponent payment validation', () => {
clearCart: ReturnType<typeof vi.fn>; clearCart: ReturnType<typeof vi.fn>;
}; };
let routerStub: { navigate: ReturnType<typeof vi.fn> }; let routerStub: { navigate: ReturnType<typeof vi.fn> };
let toastServiceStub: { danger: 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< let tenantState: ReturnType<
@@ -69,6 +73,7 @@ describe('CheckoutPageComponent payment validation', () => {
generatePaymentIntent: vi.fn().mockResolvedValue({ generatePaymentIntent: vi.fn().mockResolvedValue({
qr_data: { qr_code: 'qr-value' }, qr_data: { qr_code: 'qr-value' },
}), }),
submitPurchaseForReview: vi.fn().mockResolvedValue({ status: 'pending_payment' }),
getPurchase: vi.fn().mockResolvedValue({ status: 'pending_payment' }), getPurchase: vi.fn().mockResolvedValue({ status: 'pending_payment' }),
withCustomLoading: vi.fn(), withCustomLoading: vi.fn(),
}; };
@@ -95,6 +100,7 @@ describe('CheckoutPageComponent payment validation', () => {
}); });
}); });
routerStub = { navigate: vi.fn() }; routerStub = { navigate: vi.fn() };
toastServiceStub = { danger: vi.fn() };
routeQueryParamMap = convertToParamMap({}); routeQueryParamMap = convertToParamMap({});
authUserState = signal(null); authUserState = signal(null);
tenantState = signal({ tenantState = signal({
@@ -116,6 +122,7 @@ describe('CheckoutPageComponent payment validation', () => {
{ provide: CartService, useValue: cartServiceStub }, { provide: CartService, useValue: cartServiceStub },
{ provide: TenantService, useValue: { tenant: tenantState } }, { provide: TenantService, useValue: { tenant: tenantState } },
{ provide: AuthService, useValue: { user: authUserState } }, { provide: AuthService, useValue: { user: authUserState } },
{ provide: ToastService, useValue: toastServiceStub },
{ {
provide: ActivatedRoute, provide: ActivatedRoute,
useValue: { useValue: {
@@ -218,8 +225,9 @@ describe('CheckoutPageComponent payment validation', () => {
const { component } = createComponent(); const { component } = createComponent();
component.selectedPaymentMethod.set('transfer'); component.selectedPaymentMethod.set('transfer');
component.onComplete(); await component.onComplete();
expect(component.transferValidationStatus()).toBe('checking'); expect(component.transferValidationStatus()).toBe('checking');
expect(checkoutServiceStub.submitPurchaseForReview).toHaveBeenCalledWith('tenant-test', 25);
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled(); expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
for (let attempt = 1; attempt <= 3; attempt += 1) { for (let attempt = 1; attempt <= 3; attempt += 1) {
@@ -241,7 +249,7 @@ describe('CheckoutPageComponent payment validation', () => {
const { component } = createComponent(); const { component } = createComponent();
component.selectedPaymentMethod.set('transfer'); component.selectedPaymentMethod.set('transfer');
component.onComplete(); await component.onComplete();
await vi.advanceTimersByTimeAsync(3_000); await vi.advanceTimersByTimeAsync(3_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledWith('tenant-test', 25); expect(checkoutServiceStub.getPurchase).toHaveBeenCalledWith('tenant-test', 25);
@@ -255,7 +263,7 @@ describe('CheckoutPageComponent payment validation', () => {
const { component } = createComponent(); const { component } = createComponent();
component.selectedPaymentMethod.set('transfer'); component.selectedPaymentMethod.set('transfer');
component.onComplete(); await component.onComplete();
await vi.advanceTimersByTimeAsync(12_000); await vi.advanceTimersByTimeAsync(12_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(4); expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(4);
@@ -264,6 +272,20 @@ describe('CheckoutPageComponent payment validation', () => {
expect(routerStub.navigate).not.toHaveBeenCalled(); 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 () => { it('cancels transfer polling when the payment method changes or the component is destroyed', async () => {
const first = createComponent(); const first = createComponent();
first.component.selectedPaymentMethod.set('transfer'); first.component.selectedPaymentMethod.set('transfer');
@@ -608,14 +630,65 @@ describe('CheckoutPageComponent payment validation', () => {
expect(component.stepper.next).toHaveBeenCalledOnce(); 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(); const { component } = createComponent();
await expect(component.canDeactivate()).resolves.toBe(true); 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.loadCart).toHaveBeenCalled();
expect(cartServiceStub.clearCart).not.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, signal,
ViewChild, ViewChild,
} from '@angular/core'; } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { FormBuilder, Validators } from '@angular/forms'; import { FormBuilder, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { firstValueFrom, startWith } from 'rxjs'; import { firstValueFrom, startWith } from 'rxjs';
@@ -20,6 +21,7 @@ import {
} from '../../../../core/services/checkout.service'; } from '../../../../core/services/checkout.service';
import { AuthService } from '../../../../core/services/auth/auth.service'; import { AuthService } from '../../../../core/services/auth/auth.service';
import { CartService } from '../../../../core/services/cart/cart.service'; import { CartService } from '../../../../core/services/cart/cart.service';
import { ToastService } from '../../../../core/services/toast.service';
import { BankAccount } from '../../../../core/services/tenant.interface'; import { BankAccount } from '../../../../core/services/tenant.interface';
import { CartComponent, CartItemMock } from '../../../../shared/components/cart/cart.component'; import { CartComponent, CartItemMock } from '../../../../shared/components/cart/cart.component';
import { StepComponent } from '../../../../shared/components/stepper/step.component'; import { StepComponent } from '../../../../shared/components/stepper/step.component';
@@ -36,6 +38,12 @@ import {
TransferValidationStatus, TransferValidationStatus,
} from './checkout-page.models'; } from './checkout-page.models';
interface ApiErrorResponse {
code?: string;
message?: string;
errors?: Record<string, string[]>;
}
@Component({ @Component({
selector: 'app-checkout-page', selector: 'app-checkout-page',
standalone: true, standalone: true,
@@ -59,6 +67,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
private readonly authService = inject(AuthService); private readonly authService = inject(AuthService);
private readonly cartService = inject(CartService); private readonly cartService = inject(CartService);
private readonly qrPollingIntervalMs = 5_000; private readonly qrPollingIntervalMs = 5_000;
private readonly toastService = inject(ToastService);
private readonly qrPollingMaxAttempts = 9; private readonly qrPollingMaxAttempts = 9;
private readonly transferPollingIntervalMs = 3_000; private readonly transferPollingIntervalMs = 3_000;
private readonly transferPollingMaxAttempts = 4; private readonly transferPollingMaxAttempts = 4;
@@ -199,9 +208,28 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
} }
if (editing && !this.canModifyCart()) { if (editing && !this.canModifyCart()) {
await this.router.navigate(['/'], { const tenant = this.tenantService.tenant();
queryParams: { openCart: true }, 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; return;
} }
@@ -236,7 +264,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
const purchase = await this.checkoutService.prepareItemEditing(tenant.codigo, purchaseId); const purchase = await this.checkoutService.prepareItemEditing(tenant.codigo, purchaseId);
this.createdPurchase.set(purchase); this.createdPurchase.set(purchase);
} catch (error) { } 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); this.isEditingItems.set(false);
} finally { } finally {
this.isPreparingItemEdit.set(false); this.isPreparingItemEdit.set(false);
@@ -274,7 +302,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
); );
this.createdPurchase.set(purchase); this.createdPurchase.set(purchase);
} catch (error) { } catch (error) {
console.error('Failed to update purchase item quantity:', error); this.handleCheckoutError(error, 'No se pudo actualizar la cantidad del producto.');
} finally { } finally {
this.isUpdatingItem.set(false); this.isUpdatingItem.set(false);
} }
@@ -313,7 +341,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
), ),
); );
} catch (error) { } catch (error) {
console.error('Failed to update purchase item variant:', error); this.handleCheckoutError(error, 'No se pudo actualizar la variante del producto.');
} finally { } finally {
this.isUpdatingItem.set(false); this.isUpdatingItem.set(false);
} }
@@ -347,7 +375,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
), ),
); );
} catch (error) { } catch (error) {
console.error('Failed to remove purchase item:', error); this.handleCheckoutError(error, 'No se pudo eliminar el producto de la compra.');
} finally { } finally {
this.isUpdatingItem.set(false); this.isUpdatingItem.set(false);
} }
@@ -377,8 +405,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
// Auto trigger intent for default option // Auto trigger intent for default option
void this.selectPaymentMethod(this.selectedPaymentMethod()); void this.selectPaymentMethod(this.selectedPaymentMethod());
} catch (error) { } catch (error) {
console.error('Failed to create purchase:', error); this.handleCheckoutError(error, 'No se pudieron actualizar los datos de la compra.');
// Here we could show an alert or toast
} finally { } finally {
this.isUpdatingPurchase.set(false); this.isUpdatingPurchase.set(false);
} }
@@ -398,29 +425,15 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
return true; return true;
} }
const purchaseId = this.createdPurchaseId(); // The checkout cart remains attached to its purchase. Once the user adds a
const tenant = this.tenantService.tenant(); // new item, the cart API creates a separate active cart automatically.
if (!purchaseId || !tenant) {
return true;
}
try { try {
await this.checkoutService.cancelPurchase(tenant.codigo, purchaseId); await firstValueFrom(this.cartService.loadCart());
this.createdPurchaseId.set(null);
this.createdPurchase.set(null);
try {
await firstValueFrom(this.cartService.loadCart());
} catch (error) {
console.error('Failed to restore cart after cancelling checkout:', error);
}
return true;
} catch (error) { } catch (error) {
console.error('Failed to cancel purchase:', error); console.error('Failed to load the active cart after leaving checkout:', error);
return false;
} }
return true;
} }
protected async selectPaymentMethod(method: PaymentMethod): Promise<void> { protected async selectPaymentMethod(method: PaymentMethod): Promise<void> {
@@ -468,7 +481,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
this.startQrPolling(); this.startQrPolling();
} }
} catch (error) { } catch (error) {
console.error('Failed to generate payment intent:', error); this.handleCheckoutError(error, 'No se pudo generar la intenci\u00f3n de pago.');
} finally { } finally {
this.isGeneratingIntent.set(false); this.isGeneratingIntent.set(false);
} }
@@ -505,7 +518,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
}); });
} }
} catch (error) { } catch (error) {
console.error('Failed to generate transfer payment intent:', error); this.handleCheckoutError(error, 'No se pudo generar la intenci\u00f3n de transferencia.');
} finally { } finally {
this.isGeneratingIntent.set(false); 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 purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant(); const tenant = this.tenantService.tenant();
@@ -547,7 +560,32 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
this.transferPollingAttempts = 0; this.transferPollingAttempts = 0;
const runId = this.transferPollingRunId; const runId = this.transferPollingRunId;
this.scheduleTransferPoll(runId);
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 { private scheduleTransferPoll(runId: number): void {
@@ -776,4 +814,54 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
void this.router.navigate(['/']); 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> </p>
} @else { } @else {
@for (group of catalog(); track group.id) { @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 <app-product-list
[layout]="group.layout" [layout]="group.layout"
[groupLayout]="group.group_layout" [groupLayout]="group.group_layout"

View File

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

View File

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