Compare commits
6 Commits
refactor/s
...
6f4aa3b1bd
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f4aa3b1bd | |||
| 2b342ec235 | |||
| d06b146104 | |||
| f4e0a9e028 | |||
| 8c2b738806 | |||
| 1362d0c163 |
@@ -152,9 +152,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="store-layout__categories d-none d-md-block">
|
|
||||||
<div class="container-xl h-100 px-3 px-md-4 d-flex align-items-center">
|
|
||||||
@if (displayCategories()) {
|
@if (displayCategories()) {
|
||||||
|
<div class="store-layout__categories d-none d-md-block">
|
||||||
|
<div class="container-xl px-3 px-md-4">
|
||||||
<div class="store-layout__category-menu">
|
<div class="store-layout__category-menu">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -175,18 +175,7 @@
|
|||||||
(categorySelect)="onCategorySelect($event)"
|
(categorySelect)="onCategorySelect($event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
</div>
|
||||||
|
|
||||||
@if (checkoutRemainingTime(); as remainingTime) {
|
|
||||||
<div
|
|
||||||
class="store-layout__checkout-timer ms-auto d-none d-md-flex align-items-baseline gap-3"
|
|
||||||
role="timer"
|
|
||||||
aria-label="Tiempo restante de compra"
|
|
||||||
>
|
|
||||||
<span class="store-layout__checkout-timer-label">Tiempo restante de compra:</span>
|
|
||||||
<span class="store-layout__checkout-timer-value">{{ remainingTime }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -10,26 +10,6 @@
|
|||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-layout__categories {
|
|
||||||
height: 3.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-layout__checkout-timer {
|
|
||||||
color: var(--tenant-primary);
|
|
||||||
font-weight: 700;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-layout__checkout-timer-label {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-layout__checkout-timer-value {
|
|
||||||
font-size: 20px;
|
|
||||||
line-height: 1;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-layout__brand-slot {
|
.store-layout__brand-slot {
|
||||||
min-width: 150px;
|
min-width: 150px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,4 @@
|
|||||||
import {
|
import { Component, ElementRef, HostListener, inject, input, output, signal } from '@angular/core';
|
||||||
Component,
|
|
||||||
computed,
|
|
||||||
ElementRef,
|
|
||||||
HostListener,
|
|
||||||
inject,
|
|
||||||
input,
|
|
||||||
output,
|
|
||||||
signal,
|
|
||||||
} from '@angular/core';
|
|
||||||
import { Router, RouterLink } from '@angular/router';
|
import { Router, RouterLink } from '@angular/router';
|
||||||
import { AuthUser } from '../../../services/auth/auth.interfaces';
|
import { AuthUser } from '../../../services/auth/auth.interfaces';
|
||||||
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
|
import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component';
|
||||||
@@ -49,7 +40,6 @@ export class StoreHeaderComponent {
|
|||||||
readonly displaySeachBar = input(true);
|
readonly displaySeachBar = input(true);
|
||||||
readonly displayCart = input(true);
|
readonly displayCart = input(true);
|
||||||
readonly cartDisabled = input(false);
|
readonly cartDisabled = input(false);
|
||||||
readonly checkoutRemainingSeconds = input<number | null>(null);
|
|
||||||
readonly cartClick = output<void>();
|
readonly cartClick = output<void>();
|
||||||
readonly ticketsClick = output<void>();
|
readonly ticketsClick = output<void>();
|
||||||
readonly loginClick = output<void>();
|
readonly loginClick = output<void>();
|
||||||
@@ -63,18 +53,6 @@ export class StoreHeaderComponent {
|
|||||||
protected readonly minSearchLength = 3;
|
protected readonly minSearchLength = 3;
|
||||||
protected readonly showSearchError = signal(false);
|
protected readonly showSearchError = signal(false);
|
||||||
protected readonly searchControl = new FormControl('', { nonNullable: true });
|
protected readonly searchControl = new FormControl('', { nonNullable: true });
|
||||||
protected readonly checkoutRemainingTime = computed(() => {
|
|
||||||
const remainingSeconds = this.checkoutRemainingSeconds();
|
|
||||||
|
|
||||||
if (remainingSeconds === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const minutes = Math.floor(remainingSeconds / 60);
|
|
||||||
const seconds = remainingSeconds % 60;
|
|
||||||
|
|
||||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
protected onCartClick(): void {
|
protected onCartClick(): void {
|
||||||
if (this.cartDisabled()) {
|
if (this.cartDisabled()) {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
[displaySeachBar]="tenant()?.display_seach_bar ?? true"
|
[displaySeachBar]="tenant()?.display_seach_bar ?? true"
|
||||||
[displayCart]="displayCart()"
|
[displayCart]="displayCart()"
|
||||||
[cartDisabled]="isCheckoutRoute()"
|
[cartDisabled]="isCheckoutRoute()"
|
||||||
[checkoutRemainingSeconds]="checkoutRemainingSeconds()"
|
|
||||||
(cartClick)="onCartClick()"
|
(cartClick)="onCartClick()"
|
||||||
(ticketsClick)="onTicketsClick()"
|
(ticketsClick)="onTicketsClick()"
|
||||||
(loginClick)="onLoginClick()"
|
(loginClick)="onLoginClick()"
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import { AuthUser } from '../../services/auth/auth.interfaces';
|
|||||||
import { StoreLayoutComponent } from './store-layout.component';
|
import { StoreLayoutComponent } from './store-layout.component';
|
||||||
import { StoreHeaderComponent } from './store-header/store-header.component';
|
import { StoreHeaderComponent } from './store-header/store-header.component';
|
||||||
import { CheckoutService } from '../../services/checkout.service';
|
import { CheckoutService } from '../../services/checkout.service';
|
||||||
import { CheckoutCountdownService } from '../../services/checkout-countdown.service';
|
|
||||||
import { TenantUrlSerializer } from '../../services/tenant-url.serializer';
|
import { TenantUrlSerializer } from '../../services/tenant-url.serializer';
|
||||||
|
|
||||||
const tenant: Tenant = {
|
const tenant: Tenant = {
|
||||||
@@ -151,7 +150,6 @@ describe('StoreLayoutComponent', () => {
|
|||||||
let tenantState = signal<Tenant | null>(tenant);
|
let tenantState = signal<Tenant | null>(tenant);
|
||||||
let cartState = signal<Cart | null>(null);
|
let cartState = signal<Cart | null>(null);
|
||||||
let authUserState = signal<AuthUser | null>(null);
|
let authUserState = signal<AuthUser | null>(null);
|
||||||
let checkoutRemainingSecondsState = signal<number | null>(null);
|
|
||||||
let checkoutServiceStub: { startCheckout: ReturnType<typeof vi.fn> };
|
let checkoutServiceStub: { startCheckout: ReturnType<typeof vi.fn> };
|
||||||
let queryParamMapState: BehaviorSubject<ParamMap>;
|
let queryParamMapState: BehaviorSubject<ParamMap>;
|
||||||
|
|
||||||
@@ -159,7 +157,6 @@ describe('StoreLayoutComponent', () => {
|
|||||||
tenantState = signal<Tenant | null>(tenant);
|
tenantState = signal<Tenant | null>(tenant);
|
||||||
cartState = signal<Cart | null>(null);
|
cartState = signal<Cart | null>(null);
|
||||||
authUserState = signal<AuthUser | null>(null);
|
authUserState = signal<AuthUser | null>(null);
|
||||||
checkoutRemainingSecondsState = signal<number | null>(null);
|
|
||||||
queryParamMapState = new BehaviorSubject(convertToParamMap({}));
|
queryParamMapState = new BehaviorSubject(convertToParamMap({}));
|
||||||
const isAuthenticatedState = signal(false);
|
const isAuthenticatedState = signal(false);
|
||||||
checkoutServiceStub = {
|
checkoutServiceStub = {
|
||||||
@@ -218,12 +215,6 @@ describe('StoreLayoutComponent', () => {
|
|||||||
provide: CheckoutService,
|
provide: CheckoutService,
|
||||||
useValue: checkoutServiceStub,
|
useValue: checkoutServiceStub,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
provide: CheckoutCountdownService,
|
|
||||||
useValue: {
|
|
||||||
remainingSeconds: checkoutRemainingSecondsState.asReadonly(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
});
|
});
|
||||||
@@ -280,22 +271,6 @@ describe('StoreLayoutComponent', () => {
|
|||||||
expect(element.querySelector('.store-layout__cart-overlay')).toBeNull();
|
expect(element.querySelector('.store-layout__cart-overlay')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the synchronized countdown in the header whenever one is active', () => {
|
|
||||||
checkoutRemainingSecondsState.set(587);
|
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
const element = fixture.nativeElement as HTMLElement;
|
|
||||||
|
|
||||||
expect(element.querySelector('.store-layout__checkout-timer-label')?.textContent).toContain(
|
|
||||||
'Tiempo restante de compra:',
|
|
||||||
);
|
|
||||||
expect(element.querySelector('.store-layout__checkout-timer-value')?.textContent?.trim()).toBe(
|
|
||||||
'09:47',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hides the configured header elements when the tenant disables them', () => {
|
it('hides the configured header elements when the tenant disables them', () => {
|
||||||
tenantState.set({
|
tenantState.set({
|
||||||
...tenant,
|
...tenant,
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import {
|
|||||||
} from '../../services/checkout.service';
|
} from '../../services/checkout.service';
|
||||||
import { ToastService } from '../../services/toast.service';
|
import { ToastService } from '../../services/toast.service';
|
||||||
import { Category } from '../../services/tenant.interface';
|
import { Category } from '../../services/tenant.interface';
|
||||||
import { CheckoutCountdownService } from '../../services/checkout-countdown.service';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-store-layout',
|
selector: 'app-store-layout',
|
||||||
@@ -43,7 +42,6 @@ export class StoreLayoutComponent implements OnInit {
|
|||||||
private readonly cartService = inject(CartService);
|
private readonly cartService = inject(CartService);
|
||||||
private readonly authService = inject(AuthService);
|
private readonly authService = inject(AuthService);
|
||||||
private readonly checkoutService = inject(CheckoutService);
|
private readonly checkoutService = inject(CheckoutService);
|
||||||
private readonly checkoutCountdownService = inject(CheckoutCountdownService);
|
|
||||||
private readonly toastService = inject(ToastService);
|
private readonly toastService = inject(ToastService);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
@@ -51,7 +49,6 @@ export class StoreLayoutComponent implements OnInit {
|
|||||||
|
|
||||||
protected readonly isCartOpen = signal(false);
|
protected readonly isCartOpen = signal(false);
|
||||||
protected readonly isCheckoutRoute = signal(this.isCheckoutUrl(this.router.url));
|
protected readonly isCheckoutRoute = signal(this.isCheckoutUrl(this.router.url));
|
||||||
protected readonly checkoutRemainingSeconds = this.checkoutCountdownService.remainingSeconds;
|
|
||||||
protected readonly isCreatingPurchase = signal(false);
|
protected readonly isCreatingPurchase = signal(false);
|
||||||
protected readonly displayCart = computed(() => this.tenant()?.display_cart ?? true);
|
protected readonly displayCart = computed(() => this.tenant()?.display_cart ?? true);
|
||||||
protected readonly cartEditingPolicy = computed(() => this.tenant()?.cart_editing_policy ?? null);
|
protected readonly cartEditingPolicy = computed(() => this.tenant()?.cart_editing_policy ?? null);
|
||||||
@@ -290,7 +287,7 @@ export class StoreLayoutComponent implements OnInit {
|
|||||||
this.toastService.danger(message);
|
this.toastService.danger(message);
|
||||||
|
|
||||||
if (error instanceof HttpErrorResponse && isExpiredStockReservationResponse(error.error)) {
|
if (error instanceof HttpErrorResponse && isExpiredStockReservationResponse(error.error)) {
|
||||||
this.cartService.loadCart(true).subscribe({
|
this.cartService.loadCart().subscribe({
|
||||||
error: (refreshError) => console.error('Error refreshing expired cart', refreshError),
|
error: (refreshError) => console.error('Error refreshing expired cart', refreshError),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,20 +83,6 @@ describe('CartService', () => {
|
|||||||
req.flush({ data: mockCart });
|
req.flush({ data: mockCart });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('refreshes catalog availability only after the expired cart has been reloaded', () => {
|
|
||||||
const availabilityChanged = vi.fn();
|
|
||||||
catalogAvailabilityService.availabilityChanged$.subscribe(availabilityChanged);
|
|
||||||
|
|
||||||
service.loadCart(true).subscribe();
|
|
||||||
|
|
||||||
expect(availabilityChanged).not.toHaveBeenCalled();
|
|
||||||
const req = httpMock.expectOne('http://api.test/tenants/acme/cart');
|
|
||||||
req.flush({ data: mockCart });
|
|
||||||
|
|
||||||
expect(service.cart()).toEqual(mockCart);
|
|
||||||
expect(availabilityChanged).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('propagates a custom loading mode to the request context', () => {
|
it('propagates a custom loading mode to the request context', () => {
|
||||||
service.withCustomLoading().loadCart().subscribe();
|
service.withCustomLoading().loadCart().subscribe();
|
||||||
|
|
||||||
|
|||||||
@@ -34,19 +34,14 @@ export class CartService extends BaseApiService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
loadCart(refreshCatalogAvailability = false): Observable<Cart> {
|
loadCart(): Observable<Cart> {
|
||||||
return this.http
|
return this.http
|
||||||
.get<ApiResponse<Cart>>(`${this.tenantApiUrl}/cart`, {
|
.get<ApiResponse<Cart>>(`${this.tenantApiUrl}/cart`, {
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
map((response) => response.data),
|
map((response) => response.data),
|
||||||
tap((cart) => {
|
tap((cart) => this.cartState.set(cart)),
|
||||||
this.cartState.set(cart);
|
|
||||||
if (refreshCatalogAvailability) {
|
|
||||||
this.catalogAvailabilityService.notifyAvailabilityChanged();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
CatalogItemDetail,
|
CatalogItemDetail,
|
||||||
CatalogVariantOptionsResponse,
|
CatalogVariantOptionsResponse,
|
||||||
CategoryItemsResponse,
|
CategoryItemsResponse,
|
||||||
|
Product,
|
||||||
} from './catalog.interface';
|
} from './catalog.interface';
|
||||||
|
|
||||||
type HttpParamValue = string | number | boolean | readonly (string | number | boolean)[];
|
type HttpParamValue = string | number | boolean | readonly (string | number | boolean)[];
|
||||||
@@ -32,6 +33,12 @@ export class CatalogService extends BaseApiService {
|
|||||||
return this.tenantService.getTenantApiUrl();
|
return this.tenantService.getTenantApiUrl();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getProductos(params?: ApiPaginationQueryParams): Observable<ApiPaginatedResponse<Product[]>> {
|
||||||
|
return this.http.get<ApiPaginatedResponse<Product[]>>(`${this.tenantApiUrl}/productos`, {
|
||||||
|
params: this.buildHttpParams(params),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
getCatalog(): Observable<CatalogFeaturedGroup[]> {
|
getCatalog(): Observable<CatalogFeaturedGroup[]> {
|
||||||
return this.http.get<CatalogFeaturedGroup[]>(`${this.tenantApiUrl}/catalog`);
|
return this.http.get<CatalogFeaturedGroup[]>(`${this.tenantApiUrl}/catalog`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the active countdown when a partial checkout response omits timing fields', () => {
|
|
||||||
service.synchronize({
|
|
||||||
expires_at: null,
|
|
||||||
expires_in_seconds: 10,
|
|
||||||
server_time: '2026-08-27T15:00:00.000Z',
|
|
||||||
});
|
|
||||||
|
|
||||||
service.synchronize({});
|
|
||||||
|
|
||||||
expect(service.remainingSeconds()).toBe(10);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
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: Partial<CheckoutTiming>): void {
|
|
||||||
const remainingSeconds = this.resolveRemainingSeconds(timing);
|
|
||||||
|
|
||||||
if (remainingSeconds === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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: Partial<CheckoutTiming>): number | null | undefined {
|
|
||||||
if (timing.expires_at === null && timing.expires_in_seconds === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const expiresAt =
|
|
||||||
typeof timing.expires_at === 'string' ? Date.parse(timing.expires_at) : Number.NaN;
|
|
||||||
const serverTime =
|
|
||||||
typeof timing.server_time === 'string' ? Date.parse(timing.server_time) : Number.NaN;
|
|
||||||
|
|
||||||
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 undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import { provideHttpClient } from '@angular/common/http';
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import { CatalogAvailabilityService } from './catalog/catalog-availability.service';
|
|
||||||
import { CheckoutService } from './checkout.service';
|
import { CheckoutService } from './checkout.service';
|
||||||
|
|
||||||
describe('CheckoutService', () => {
|
describe('CheckoutService', () => {
|
||||||
@@ -39,59 +38,4 @@ describe('CheckoutService', () => {
|
|||||||
|
|
||||||
await expect(purchasePromise).resolves.toMatchObject({ id: 55 });
|
await expect(purchasePromise).resolves.toMatchObject({ id: 55 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('refreshes catalog availability when starting checkout fails', async () => {
|
|
||||||
const catalogAvailabilityService = TestBed.inject(CatalogAvailabilityService);
|
|
||||||
const notifyAvailabilityChanged = vi.spyOn(
|
|
||||||
catalogAvailabilityService,
|
|
||||||
'notifyAvailabilityChanged',
|
|
||||||
);
|
|
||||||
const purchasePromise = service.startCheckout('desfile', { cart_id: 12 });
|
|
||||||
const request = httpMock.expectOne(`${environment.url}tenants/desfile/compras/start-checkout`);
|
|
||||||
|
|
||||||
request.flush(
|
|
||||||
{ message: 'No hay stock disponible.' },
|
|
||||||
{ status: 422, statusText: 'Unprocessable Entity' },
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(purchasePromise).rejects.toBeTruthy();
|
|
||||||
expect(notifyAvailabilityChanged).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('waits for the expired cart refresh before refreshing catalog availability', async () => {
|
|
||||||
const catalogAvailabilityService = TestBed.inject(CatalogAvailabilityService);
|
|
||||||
const notifyAvailabilityChanged = vi.spyOn(
|
|
||||||
catalogAvailabilityService,
|
|
||||||
'notifyAvailabilityChanged',
|
|
||||||
);
|
|
||||||
const purchasePromise = service.startCheckout('desfile', { cart_id: 12 });
|
|
||||||
const request = httpMock.expectOne(`${environment.url}tenants/desfile/compras/start-checkout`);
|
|
||||||
|
|
||||||
request.flush(
|
|
||||||
{
|
|
||||||
code: 'stock_reservation.expired',
|
|
||||||
message: 'La reserva de stock venció.',
|
|
||||||
},
|
|
||||||
{ status: 422, statusText: 'Unprocessable Entity' },
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(purchasePromise).rejects.toBeTruthy();
|
|
||||||
expect(notifyAvailabilityChanged).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves checkout timing fields when completing a purchase', async () => {
|
|
||||||
const purchasePromise = service.completePurchase('desfile', 55);
|
|
||||||
const request = httpMock.expectOne(`${environment.url}tenants/desfile/compras/55/complete`);
|
|
||||||
const response = {
|
|
||||||
status: 'pending_payment',
|
|
||||||
expires_at: '2026-08-26T20:00:00.000Z',
|
|
||||||
expires_in_seconds: 900,
|
|
||||||
server_time: '2026-08-26T19:45:00.000Z',
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(request.request.method).toBe('POST');
|
|
||||||
request.flush({ data: response });
|
|
||||||
|
|
||||||
await expect(purchasePromise).resolves.toEqual(response);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { inject, Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import { ApiPaginatedResponse } from './api-paginated-response.interface';
|
import { ApiPaginatedResponse } from './api-paginated-response.interface';
|
||||||
import { ApiPaginationQueryParams } from './api-pagination-query-params.interface';
|
import { ApiPaginationQueryParams } from './api-pagination-query-params.interface';
|
||||||
import { BaseApiService } from './base-api.service';
|
import { BaseApiService } from './base-api.service';
|
||||||
import { CatalogAvailabilityService } from './catalog/catalog-availability.service';
|
|
||||||
|
|
||||||
export interface UpdatePurchaseCustomerPayload {
|
export interface UpdatePurchaseCustomerPayload {
|
||||||
dni: string;
|
dni: string;
|
||||||
@@ -71,9 +70,6 @@ export type StartCheckoutPayload =
|
|||||||
|
|
||||||
export interface PurchaseStatusResponse {
|
export interface PurchaseStatusResponse {
|
||||||
status: string | null;
|
status: string | null;
|
||||||
expires_at: string | null;
|
|
||||||
expires_in_seconds: number | null;
|
|
||||||
server_time: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PurchaseSummaryResponse extends PurchaseStatusResponse {
|
export interface PurchaseSummaryResponse extends PurchaseStatusResponse {
|
||||||
@@ -110,6 +106,7 @@ export interface PurchaseDetailResponse extends PurchaseStatusResponse {
|
|||||||
user_id: number;
|
user_id: number;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
payment_method: string | null;
|
payment_method: string | null;
|
||||||
|
expires_at: string | null;
|
||||||
dni: string | null;
|
dni: string | null;
|
||||||
transfer_payer_dni: string | null;
|
transfer_payer_dni: string | null;
|
||||||
telefono: string | null;
|
telefono: string | null;
|
||||||
@@ -127,13 +124,10 @@ export interface PurchaseDetailResponse extends PurchaseStatusResponse {
|
|||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class CheckoutService extends BaseApiService {
|
export class CheckoutService extends BaseApiService {
|
||||||
private readonly catalogAvailabilityService = inject(CatalogAvailabilityService);
|
|
||||||
|
|
||||||
async startCheckout(
|
async startCheckout(
|
||||||
tenantCode: string,
|
tenantCode: string,
|
||||||
payload: StartCheckoutPayload,
|
payload: StartCheckoutPayload,
|
||||||
): Promise<PurchaseDetailResponse> {
|
): Promise<PurchaseDetailResponse> {
|
||||||
try {
|
|
||||||
const response = await firstValueFrom(
|
const response = await firstValueFrom(
|
||||||
this.http.post<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
|
this.http.post<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
|
||||||
`${environment.url}tenants/${tenantCode}/compras/start-checkout`,
|
`${environment.url}tenants/${tenantCode}/compras/start-checkout`,
|
||||||
@@ -148,13 +142,6 @@ export class CheckoutService extends BaseApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return purchase;
|
return purchase;
|
||||||
} catch (error) {
|
|
||||||
const responseBody = (error as { error?: unknown } | null)?.error;
|
|
||||||
if (!isExpiredStockReservationResponse(responseBody)) {
|
|
||||||
this.catalogAvailabilityService.notifyAvailabilityChanged();
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async generatePaymentIntent(
|
async generatePaymentIntent(
|
||||||
@@ -199,6 +186,25 @@ export class CheckoutService extends BaseApiService {
|
|||||||
return purchase;
|
return purchase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async completePurchase(tenantCode: string, purchaseId: number): Promise<PurchaseStatusResponse> {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(
|
||||||
|
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/complete`,
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const purchase = this.extractResponseData<PurchaseStatusResponse>(response);
|
||||||
|
|
||||||
|
if (!purchase) {
|
||||||
|
throw new Error('Error al finalizar la compra.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: purchase.status ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async submitPurchaseForReview(
|
async submitPurchaseForReview(
|
||||||
tenantCode: string,
|
tenantCode: string,
|
||||||
purchaseId: number,
|
purchaseId: number,
|
||||||
@@ -215,7 +221,7 @@ export class CheckoutService extends BaseApiService {
|
|||||||
throw new Error('Error al enviar la compra a revisi\u00f3n.');
|
throw new Error('Error al enviar la compra a revisi\u00f3n.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return purchase;
|
return { status: purchase.status ?? null };
|
||||||
}
|
}
|
||||||
|
|
||||||
async cancelPurchase(tenantCode: string, purchaseId: number): Promise<PurchaseStatusResponse> {
|
async cancelPurchase(tenantCode: string, purchaseId: number): Promise<PurchaseStatusResponse> {
|
||||||
@@ -231,7 +237,7 @@ export class CheckoutService extends BaseApiService {
|
|||||||
throw new Error('Error al cancelar la compra.');
|
throw new Error('Error al cancelar la compra.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return purchase;
|
return { status: purchase.status ?? null };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPurchases(
|
async getPurchases(
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ export class CategoryItemsPageComponent {
|
|||||||
this.toastService.danger(message);
|
this.toastService.danger(message);
|
||||||
|
|
||||||
if (error instanceof HttpErrorResponse && isExpiredStockReservationResponse(error.error)) {
|
if (error instanceof HttpErrorResponse && isExpiredStockReservationResponse(error.error)) {
|
||||||
this.cartService.loadCart(true).subscribe({
|
this.cartService.loadCart().subscribe({
|
||||||
error: (refreshError) => console.error('Error refreshing expired cart', refreshError),
|
error: (refreshError) => console.error('Error refreshing expired cart', refreshError),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,17 +46,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="checkout-page__cart-col">
|
<div class="checkout-page__cart-col">
|
||||||
@if (checkoutRemainingTime(); as remainingTime) {
|
|
||||||
<div
|
|
||||||
class="checkout-page__mobile-countdown d-flex d-md-none align-items-baseline justify-content-center gap-3"
|
|
||||||
role="timer"
|
|
||||||
aria-label="Tiempo restante de compra"
|
|
||||||
>
|
|
||||||
<span class="checkout-page__mobile-countdown-label">Tiempo restante de compra:</span>
|
|
||||||
<span class="checkout-page__mobile-countdown-value">{{ remainingTime }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<app-cart
|
<app-cart
|
||||||
title="COMPRA"
|
title="COMPRA"
|
||||||
[items]="mappedCartItems()"
|
[items]="mappedCartItems()"
|
||||||
|
|||||||
@@ -28,24 +28,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__mobile-countdown {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
color: var(--tenant-primary);
|
|
||||||
font-weight: 700;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__mobile-countdown-label {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__mobile-countdown-value {
|
|
||||||
font-size: 20px;
|
|
||||||
line-height: 1;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkout-page__loading {
|
.checkout-page__loading {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { TenantService } from '../../../../core/services/tenant.service';
|
|||||||
import { ToastService } from '../../../../core/services/toast.service';
|
import { ToastService } from '../../../../core/services/toast.service';
|
||||||
import { CartService } from '../../../../core/services/cart/cart.service';
|
import { CartService } from '../../../../core/services/cart/cart.service';
|
||||||
import { CartEditingPolicy } from '../../../../core/services/tenant.interface';
|
import { CartEditingPolicy } from '../../../../core/services/tenant.interface';
|
||||||
import { CheckoutCountdownService } from '../../../../core/services/checkout-countdown.service';
|
|
||||||
import { CheckoutPageComponent } from './checkout-page.component';
|
import { CheckoutPageComponent } from './checkout-page.component';
|
||||||
|
|
||||||
describe('CheckoutPageComponent payment validation', () => {
|
describe('CheckoutPageComponent payment validation', () => {
|
||||||
@@ -130,35 +129,6 @@ describe('CheckoutPageComponent payment validation', () => {
|
|||||||
return { fixture, component: fixture.componentInstance as any };
|
return { fixture, component: fixture.componentInstance as any };
|
||||||
}
|
}
|
||||||
|
|
||||||
it('does not expose an active countdown until the purchase timing is loaded', async () => {
|
|
||||||
const countdown = TestBed.inject(CheckoutCountdownService);
|
|
||||||
countdown.synchronize({
|
|
||||||
expires_at: null,
|
|
||||||
expires_in_seconds: 600,
|
|
||||||
server_time: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
checkoutServiceStub.getPurchase.mockResolvedValue({
|
|
||||||
id: 25,
|
|
||||||
status: 'created',
|
|
||||||
expires_at: '2026-08-27T15:10:00.000Z',
|
|
||||||
expires_in_seconds: 600,
|
|
||||||
server_time: '2026-08-27T15:00:00.000Z',
|
|
||||||
items: [],
|
|
||||||
subtotal: '0.00',
|
|
||||||
total: '0.00',
|
|
||||||
});
|
|
||||||
routeParamMap = convertToParamMap({ id: 25 });
|
|
||||||
|
|
||||||
const { component } = createComponent();
|
|
||||||
|
|
||||||
expect(component.checkoutRemainingTime()).toBeNull();
|
|
||||||
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(countdown.remainingSeconds()).toBe(600);
|
|
||||||
expect(component.checkoutRemainingTime()).toBe('10:00');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('polls QR after five seconds and navigates only when payment is paid', async () => {
|
it('polls QR after five seconds and navigates only when payment is paid', async () => {
|
||||||
checkoutServiceStub.getPurchase
|
checkoutServiceStub.getPurchase
|
||||||
.mockResolvedValueOnce({ status: 'pending_payment' })
|
.mockResolvedValueOnce({ status: 'pending_payment' })
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ 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,
|
||||||
@@ -65,7 +64,6 @@ 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);
|
||||||
@@ -97,28 +95,6 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
protected readonly createdPurchase = signal<PurchaseDetailResponse | null>(null);
|
protected readonly createdPurchase = signal<PurchaseDetailResponse | null>(null);
|
||||||
protected readonly isLoadingPurchase = signal(true);
|
protected readonly isLoadingPurchase = signal(true);
|
||||||
protected readonly checkoutStepIndex = signal(0);
|
protected readonly checkoutStepIndex = signal(0);
|
||||||
protected readonly checkoutRemainingTime = computed(() => {
|
|
||||||
const purchase = this.createdPurchase();
|
|
||||||
|
|
||||||
if (
|
|
||||||
!purchase ||
|
|
||||||
(typeof purchase.expires_at !== 'string' &&
|
|
||||||
typeof purchase.expires_in_seconds !== 'number')
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const remainingSeconds = this.checkoutCountdownService.remainingSeconds();
|
|
||||||
|
|
||||||
if (remainingSeconds === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const minutes = Math.floor(remainingSeconds / 60);
|
|
||||||
const seconds = remainingSeconds % 60;
|
|
||||||
|
|
||||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
|
||||||
});
|
|
||||||
protected readonly cartSubtotal = computed(() => {
|
protected readonly cartSubtotal = computed(() => {
|
||||||
const purchase = this.createdPurchase();
|
const purchase = this.createdPurchase();
|
||||||
return purchase ? parseFloat(purchase.subtotal) : 0;
|
return purchase ? parseFloat(purchase.subtotal) : 0;
|
||||||
@@ -252,7 +228,6 @@ 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();
|
||||||
|
|
||||||
@@ -306,7 +281,6 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
const tenant = this.tenantService.tenant();
|
const tenant = this.tenantService.tenant();
|
||||||
const purchaseId = this.createdPurchaseId();
|
const purchaseId = this.createdPurchaseId();
|
||||||
if (!tenant || !purchaseId) {
|
if (!tenant || !purchaseId) {
|
||||||
this.checkoutCountdownService.clear();
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,7 +291,6 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
await firstValueFrom(this.cartService.loadCart());
|
await firstValueFrom(this.cartService.loadCart());
|
||||||
this.createdPurchaseId.set(null);
|
this.createdPurchaseId.set(null);
|
||||||
this.createdPurchase.set(null);
|
this.createdPurchase.set(null);
|
||||||
this.checkoutCountdownService.clear();
|
|
||||||
|
|
||||||
this.navigationStarted = true;
|
this.navigationStarted = true;
|
||||||
return true;
|
return true;
|
||||||
@@ -326,12 +299,11 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
if (this.isStockReservationExpiredError(error)) {
|
if (this.isStockReservationExpiredError(error)) {
|
||||||
this.showRequestError(error, 'La reserva de stock venció.');
|
this.showRequestError(error, 'La reserva de stock venció.');
|
||||||
this.cartService.loadCart(true).subscribe({
|
this.cartService.loadCart().subscribe({
|
||||||
error: (refreshError) => console.error('Error refreshing expired cart', refreshError),
|
error: (refreshError) => console.error('Error refreshing expired cart', refreshError),
|
||||||
});
|
});
|
||||||
this.createdPurchaseId.set(null);
|
this.createdPurchaseId.set(null);
|
||||||
this.createdPurchase.set(null);
|
this.createdPurchase.set(null);
|
||||||
this.checkoutCountdownService.clear();
|
|
||||||
this.navigationStarted = true;
|
this.navigationStarted = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -479,8 +451,6 @@ 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;
|
||||||
@@ -530,8 +500,6 @@ 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;
|
||||||
@@ -618,8 +586,6 @@ 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;
|
||||||
@@ -693,7 +659,6 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
this.navigationStarted = true;
|
this.navigationStarted = true;
|
||||||
this.stopQrPolling();
|
this.stopQrPolling();
|
||||||
this.stopTransferPolling();
|
this.stopTransferPolling();
|
||||||
this.checkoutCountdownService.clear();
|
|
||||||
|
|
||||||
void this.router.navigate(['/checkout/status', purchaseId]);
|
void this.router.navigate(['/checkout/status', purchaseId]);
|
||||||
}
|
}
|
||||||
@@ -731,7 +696,6 @@ 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 (
|
||||||
@@ -815,7 +779,6 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
this.navigationStarted = true;
|
this.navigationStarted = true;
|
||||||
this.stopQrPolling();
|
this.stopQrPolling();
|
||||||
this.stopTransferPolling();
|
this.stopTransferPolling();
|
||||||
this.checkoutCountdownService.clear();
|
|
||||||
void this.router.navigate(['/checkout/status', purchaseId], {
|
void this.router.navigate(['/checkout/status', purchaseId], {
|
||||||
queryParams: { status: 'expired' },
|
queryParams: { status: 'expired' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,13 +38,11 @@
|
|||||||
<label class="visually-hidden" for="register-password">Contraseña</label>
|
<label class="visually-hidden" for="register-password">Contraseña</label>
|
||||||
<app-input
|
<app-input
|
||||||
id="register-password"
|
id="register-password"
|
||||||
type="password-toggle"
|
type="password"
|
||||||
placeholder="Contraseña"
|
placeholder="Contraseña"
|
||||||
[value]="form.controls.password.value"
|
[value]="form.controls.password.value"
|
||||||
[invalid]="showControlError('password')"
|
[invalid]="showControlError('password')"
|
||||||
[visible]="passwordVisible()"
|
|
||||||
(valueChange)="updateField('password', $event)"
|
(valueChange)="updateField('password', $event)"
|
||||||
(visibleChange)="setPasswordVisibility($event)"
|
|
||||||
/>
|
/>
|
||||||
@if (getControlError('password'); as errorMessage) {
|
@if (getControlError('password'); as errorMessage) {
|
||||||
<small class="text-danger">{{ errorMessage }}</small>
|
<small class="text-danger">{{ errorMessage }}</small>
|
||||||
@@ -53,13 +51,11 @@
|
|||||||
<label class="visually-hidden" for="register-password-repeat">Repetir Contraseña</label>
|
<label class="visually-hidden" for="register-password-repeat">Repetir Contraseña</label>
|
||||||
<app-input
|
<app-input
|
||||||
id="register-password-repeat"
|
id="register-password-repeat"
|
||||||
type="password-toggle"
|
type="password"
|
||||||
placeholder="Repetir Contraseña"
|
placeholder="Repetir Contraseña"
|
||||||
[value]="form.controls.password_confirmation.value"
|
[value]="form.controls.password_confirmation.value"
|
||||||
[invalid]="showControlError('password_confirmation')"
|
[invalid]="showControlError('password_confirmation')"
|
||||||
[visible]="passwordVisible()"
|
|
||||||
(valueChange)="updateField('password_confirmation', $event)"
|
(valueChange)="updateField('password_confirmation', $event)"
|
||||||
(visibleChange)="setPasswordVisibility($event)"
|
|
||||||
/>
|
/>
|
||||||
@if (getControlError('password_confirmation'); as errorMessage) {
|
@if (getControlError('password_confirmation'); as errorMessage) {
|
||||||
<small class="text-danger">{{ errorMessage }}</small>
|
<small class="text-danger">{{ errorMessage }}</small>
|
||||||
|
|||||||
@@ -12,48 +12,6 @@ describe('RegisterPageComponent', () => {
|
|||||||
TestBed.resetTestingModule();
|
TestBed.resetTestingModule();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows and hides both password fields with either visibility control', async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [RegisterPageComponent],
|
|
||||||
providers: [
|
|
||||||
provideRouter([]),
|
|
||||||
{ provide: AuthService, useValue: { register: vi.fn() } },
|
|
||||||
{ provide: ModalService, useValue: { openSimple: vi.fn() } },
|
|
||||||
{ provide: ToastService, useValue: { danger: vi.fn() } }
|
|
||||||
]
|
|
||||||
}).compileComponents();
|
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(RegisterPageComponent);
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
const getPasswordInputs = () =>
|
|
||||||
Array.from(
|
|
||||||
fixture.nativeElement.querySelectorAll(
|
|
||||||
'input#register-password, input#register-password-repeat'
|
|
||||||
)
|
|
||||||
) as HTMLInputElement[];
|
|
||||||
const getVisibilityButtons = () =>
|
|
||||||
Array.from(
|
|
||||||
fixture.nativeElement.querySelectorAll('button[aria-label]')
|
|
||||||
) as HTMLButtonElement[];
|
|
||||||
|
|
||||||
expect(getPasswordInputs().map((input) => input.type)).toEqual(['password', 'password']);
|
|
||||||
|
|
||||||
getVisibilityButtons()[0].click();
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
expect(getPasswordInputs().map((input) => input.type)).toEqual(['text', 'text']);
|
|
||||||
expect(getVisibilityButtons().map((button) => button.getAttribute('aria-label'))).toEqual([
|
|
||||||
'Ocultar contraseña',
|
|
||||||
'Ocultar contraseña'
|
|
||||||
]);
|
|
||||||
|
|
||||||
getVisibilityButtons()[1].click();
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
expect(getPasswordInputs().map((input) => input.type)).toEqual(['password', 'password']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('submits registration data and redirects to /login on success', async () => {
|
it('submits registration data and redirects to /login on success', async () => {
|
||||||
const authService = {
|
const authService = {
|
||||||
register: vi.fn().mockReturnValue(
|
register: vi.fn().mockReturnValue(
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ export class RegisterPageComponent {
|
|||||||
private readonly submittedState = signal(false);
|
private readonly submittedState = signal(false);
|
||||||
private readonly serverErrorState = signal<string | null>(null);
|
private readonly serverErrorState = signal<string | null>(null);
|
||||||
private readonly isSubmittingState = signal(false);
|
private readonly isSubmittingState = signal(false);
|
||||||
private readonly passwordVisibleState = signal(false);
|
|
||||||
|
|
||||||
protected readonly form = this.formBuilder.nonNullable.group({
|
protected readonly form = this.formBuilder.nonNullable.group({
|
||||||
nombre_apellido: ['', [Validators.required, Validators.maxLength(TEXT_MAX_LENGTH)]],
|
nombre_apellido: ['', [Validators.required, Validators.maxLength(TEXT_MAX_LENGTH)]],
|
||||||
@@ -68,11 +67,6 @@ export class RegisterPageComponent {
|
|||||||
protected readonly submitted = this.submittedState.asReadonly();
|
protected readonly submitted = this.submittedState.asReadonly();
|
||||||
protected readonly serverError = this.serverErrorState.asReadonly();
|
protected readonly serverError = this.serverErrorState.asReadonly();
|
||||||
protected readonly isSubmitting = this.isSubmittingState.asReadonly();
|
protected readonly isSubmitting = this.isSubmittingState.asReadonly();
|
||||||
protected readonly passwordVisible = this.passwordVisibleState.asReadonly();
|
|
||||||
|
|
||||||
protected setPasswordVisibility(visible: boolean): void {
|
|
||||||
this.passwordVisibleState.set(visible);
|
|
||||||
}
|
|
||||||
|
|
||||||
goToLogin(): void {
|
goToLogin(): void {
|
||||||
void this.router.navigate(['/login']);
|
void this.router.navigate(['/login']);
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ export class SearchPageComponent {
|
|||||||
this.toastService.danger(message);
|
this.toastService.danger(message);
|
||||||
|
|
||||||
if (error instanceof HttpErrorResponse && isExpiredStockReservationResponse(error.error)) {
|
if (error instanceof HttpErrorResponse && isExpiredStockReservationResponse(error.error)) {
|
||||||
this.cartService.loadCart(true).subscribe({
|
this.cartService.loadCart().subscribe({
|
||||||
error: (refreshError) => console.error('Error refreshing expired cart', refreshError),
|
error: (refreshError) => console.error('Error refreshing expired cart', refreshError),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
if (error instanceof HttpErrorResponse && isExpiredStockReservationResponse(error.error)) {
|
if (error instanceof HttpErrorResponse && isExpiredStockReservationResponse(error.error)) {
|
||||||
this.toastService.danger(error.error.message);
|
this.toastService.danger(error.error.message);
|
||||||
this.cartService.loadCart(true).subscribe({
|
this.cartService.loadCart().subscribe({
|
||||||
error: (refreshError) => console.error('Error refreshing expired cart', refreshError),
|
error: (refreshError) => console.error('Error refreshing expired cart', refreshError),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ export class CartComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.cartService.loadCart(true).subscribe({
|
this.cartService.loadCart().subscribe({
|
||||||
error: (refreshError) => console.error('Error refreshing expired cart', refreshError),
|
error: (refreshError) => console.error('Error refreshing expired cart', refreshError),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: true,
|
production: false,
|
||||||
nombre:"Homologación - activo",
|
nombre:"Homologación - activo",
|
||||||
url:"https://backend.qa.shopit.com.ar/api/",
|
url:"https://backend.qa.shopit.com.ar/api/",
|
||||||
urlDescarga:"url/"
|
urlDescarga:"url/"
|
||||||
|
|||||||
Reference in New Issue
Block a user