feat(checkout): synchronize purchase countdown
This commit is contained in:
57
src/app/core/services/checkout-countdown.service.spec.ts
Normal file
57
src/app/core/services/checkout-countdown.service.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { CheckoutCountdownService } from './checkout-countdown.service';
|
||||||
|
|
||||||
|
describe('CheckoutCountdownService', () => {
|
||||||
|
let service: CheckoutCountdownService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-08-27T12:00:00.000Z'));
|
||||||
|
TestBed.configureTestingModule({ providers: [CheckoutCountdownService] });
|
||||||
|
service = TestBed.inject(CheckoutCountdownService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
service.clear();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts down from the checkout server timing without depending on the client clock', () => {
|
||||||
|
service.synchronize({
|
||||||
|
expires_at: '2026-08-27T15:10:00.000Z',
|
||||||
|
expires_in_seconds: 600,
|
||||||
|
server_time: '2026-08-27T15:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.remainingSeconds()).toBe(600);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1_000);
|
||||||
|
|
||||||
|
expect(service.remainingSeconds()).toBe(599);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recalculates against the deadline after a delayed browser interval', () => {
|
||||||
|
service.synchronize({
|
||||||
|
expires_at: null,
|
||||||
|
expires_in_seconds: 10,
|
||||||
|
server_time: '2026-08-27T15:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.setSystemTime(new Date('2026-08-27T12:00:07.000Z'));
|
||||||
|
vi.advanceTimersByTime(1_000);
|
||||||
|
|
||||||
|
expect(service.remainingSeconds()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the countdown when checkout has no expiration', () => {
|
||||||
|
service.synchronize({
|
||||||
|
expires_at: null,
|
||||||
|
expires_in_seconds: null,
|
||||||
|
server_time: '2026-08-27T15:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.remainingSeconds()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
87
src/app/core/services/checkout-countdown.service.ts
Normal file
87
src/app/core/services/checkout-countdown.service.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { Injectable, OnDestroy, signal } from '@angular/core';
|
||||||
|
|
||||||
|
export interface CheckoutTiming {
|
||||||
|
expires_at: string | null;
|
||||||
|
expires_in_seconds: number | null;
|
||||||
|
server_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class CheckoutCountdownService implements OnDestroy {
|
||||||
|
private readonly remainingSecondsState = signal<number | null>(null);
|
||||||
|
private deadlineMs: number | null = null;
|
||||||
|
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
readonly remainingSeconds = this.remainingSecondsState.asReadonly();
|
||||||
|
|
||||||
|
synchronize(timing: CheckoutTiming): void {
|
||||||
|
const remainingSeconds = this.resolveRemainingSeconds(timing);
|
||||||
|
|
||||||
|
if (remainingSeconds === null) {
|
||||||
|
this.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stopInterval();
|
||||||
|
this.deadlineMs = Date.now() + remainingSeconds * 1_000;
|
||||||
|
this.updateRemainingSeconds();
|
||||||
|
|
||||||
|
if (remainingSeconds > 0) {
|
||||||
|
this.intervalId = setInterval(() => this.updateRemainingSeconds(), 1_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.stopInterval();
|
||||||
|
this.deadlineMs = null;
|
||||||
|
this.remainingSecondsState.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveRemainingSeconds(timing: CheckoutTiming): number | null {
|
||||||
|
const expiresAt = timing.expires_at ? Date.parse(timing.expires_at) : Number.NaN;
|
||||||
|
const serverTime = Date.parse(timing.server_time);
|
||||||
|
|
||||||
|
if (Number.isFinite(expiresAt) && Number.isFinite(serverTime)) {
|
||||||
|
return Math.max(0, Math.ceil((expiresAt - serverTime) / 1_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof timing.expires_in_seconds === 'number' &&
|
||||||
|
Number.isFinite(timing.expires_in_seconds)
|
||||||
|
) {
|
||||||
|
return Math.max(0, Math.ceil(timing.expires_in_seconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isFinite(expiresAt)) {
|
||||||
|
return Math.max(0, Math.ceil((expiresAt - Date.now()) / 1_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateRemainingSeconds(): void {
|
||||||
|
if (this.deadlineMs === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingSeconds = Math.max(0, Math.ceil((this.deadlineMs - Date.now()) / 1_000));
|
||||||
|
this.remainingSecondsState.set(remainingSeconds);
|
||||||
|
|
||||||
|
if (remainingSeconds === 0) {
|
||||||
|
this.stopInterval();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopInterval(): void {
|
||||||
|
if (this.intervalId !== null) {
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
this.intervalId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ import { StepComponent } from '../../../../shared/components/stepper/step.compon
|
|||||||
import { StepperComponent } from '../../../../shared/components/stepper/stepper.component';
|
import { StepperComponent } from '../../../../shared/components/stepper/stepper.component';
|
||||||
import { CheckoutDataStepComponent } from './checkout-data-step.component';
|
import { CheckoutDataStepComponent } from './checkout-data-step.component';
|
||||||
import { CheckoutPaymentStepComponent } from './checkout-payment-step.component';
|
import { CheckoutPaymentStepComponent } from './checkout-payment-step.component';
|
||||||
|
import { CheckoutCountdownService } from '../../../../core/services/checkout-countdown.service';
|
||||||
import {
|
import {
|
||||||
CheckoutForm,
|
CheckoutForm,
|
||||||
PaymentMethod,
|
PaymentMethod,
|
||||||
@@ -64,6 +65,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly tenantService = inject(TenantService);
|
private readonly tenantService = inject(TenantService);
|
||||||
private readonly checkoutService = inject(CheckoutService);
|
private readonly checkoutService = inject(CheckoutService);
|
||||||
|
private readonly checkoutCountdownService = inject(CheckoutCountdownService);
|
||||||
private readonly authService = inject(AuthService);
|
private readonly authService = inject(AuthService);
|
||||||
private readonly globalLoadingService = inject(GlobalLoadingService);
|
private readonly globalLoadingService = inject(GlobalLoadingService);
|
||||||
private readonly toastService = inject(ToastService);
|
private readonly toastService = inject(ToastService);
|
||||||
@@ -161,6 +163,8 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
this.checkoutCountdownService.clear();
|
||||||
|
|
||||||
const purchaseId = Number(this.route.snapshot.paramMap.get('id'));
|
const purchaseId = Number(this.route.snapshot.paramMap.get('id'));
|
||||||
if (!Number.isInteger(purchaseId) || purchaseId <= 0) {
|
if (!Number.isInteger(purchaseId) || purchaseId <= 0) {
|
||||||
void this.router.navigate(['/']);
|
void this.router.navigate(['/']);
|
||||||
@@ -173,6 +177,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.stopQrPolling();
|
this.stopQrPolling();
|
||||||
this.stopTransferPolling();
|
this.stopTransferPolling();
|
||||||
|
this.checkoutCountdownService.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapPurchaseItemToMock(item: PurchaseDetailItemResponse): CartItemMock {
|
private mapPurchaseItemToMock(item: PurchaseDetailItemResponse): CartItemMock {
|
||||||
@@ -228,6 +233,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
nombre_apellido: formValue.nombre,
|
nombre_apellido: formValue.nombre,
|
||||||
});
|
});
|
||||||
this.createdPurchase.set(purchase);
|
this.createdPurchase.set(purchase);
|
||||||
|
this.checkoutCountdownService.synchronize(purchase);
|
||||||
|
|
||||||
this.stepper.next();
|
this.stepper.next();
|
||||||
|
|
||||||
@@ -451,6 +457,8 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.checkoutCountdownService.synchronize(purchase);
|
||||||
|
|
||||||
if (purchase.status === 'paid') {
|
if (purchase.status === 'paid') {
|
||||||
this.navigateToPurchaseStatus(purchaseId);
|
this.navigateToPurchaseStatus(purchaseId);
|
||||||
return;
|
return;
|
||||||
@@ -500,6 +508,8 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.checkoutCountdownService.synchronize(purchase);
|
||||||
|
|
||||||
if (purchase.status === 'paid') {
|
if (purchase.status === 'paid') {
|
||||||
this.navigateToPurchaseStatus(purchaseId);
|
this.navigateToPurchaseStatus(purchaseId);
|
||||||
return;
|
return;
|
||||||
@@ -586,6 +596,8 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.checkoutCountdownService.synchronize(purchase);
|
||||||
|
|
||||||
if (purchase.status === 'paid') {
|
if (purchase.status === 'paid') {
|
||||||
this.handleConfirmedPayment(purchaseId);
|
this.handleConfirmedPayment(purchaseId);
|
||||||
return;
|
return;
|
||||||
@@ -696,6 +708,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
this.createdPurchaseId.set(purchase.id);
|
this.createdPurchaseId.set(purchase.id);
|
||||||
this.createdPurchase.set(purchase);
|
this.createdPurchase.set(purchase);
|
||||||
|
this.checkoutCountdownService.synchronize(purchase);
|
||||||
this.checkoutStepIndex.set(purchase.status === 'pending_payment' ? 1 : 0);
|
this.checkoutStepIndex.set(purchase.status === 'pending_payment' ? 1 : 0);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
Reference in New Issue
Block a user