Compare commits
3 Commits
c24a8b946d
...
88c3a08a23
| Author | SHA1 | Date | |
|---|---|---|---|
| 88c3a08a23 | |||
| 21e89805d0 | |||
| 195ac73aed |
@@ -79,10 +79,13 @@
|
|||||||
|
|
||||||
@if (displayCart()) {
|
@if (displayCart()) {
|
||||||
<app-cart-icon
|
<app-cart-icon
|
||||||
[quantity]="cartQuantity()"
|
[quantity]="cartDisabled() ? null : cartQuantity()"
|
||||||
ariaLabel="Carrito de compras"
|
[disabled]="cartDisabled()"
|
||||||
title="Carrito"
|
[ariaLabel]="
|
||||||
(click)="cartClick.emit()"
|
cartDisabled() ? 'Carrito no disponible durante el checkout' : 'Carrito de compras'
|
||||||
|
"
|
||||||
|
[title]="cartDisabled() ? 'Carrito no disponible durante el checkout' : 'Carrito'"
|
||||||
|
(click)="onCartClick()"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export class StoreHeaderComponent {
|
|||||||
readonly displayCategories = input(true);
|
readonly displayCategories = input(true);
|
||||||
readonly displaySeachBar = input(true);
|
readonly displaySeachBar = input(true);
|
||||||
readonly displayCart = input(true);
|
readonly displayCart = input(true);
|
||||||
|
readonly cartDisabled = input(false);
|
||||||
readonly cartClick = output<void>();
|
readonly cartClick = output<void>();
|
||||||
readonly ticketsClick = output<void>();
|
readonly ticketsClick = output<void>();
|
||||||
readonly loginClick = output<void>();
|
readonly loginClick = output<void>();
|
||||||
@@ -53,6 +54,14 @@ export class StoreHeaderComponent {
|
|||||||
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 onCartClick(): void {
|
||||||
|
if (this.cartDisabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cartClick.emit();
|
||||||
|
}
|
||||||
|
|
||||||
@HostListener('document:click', ['$event'])
|
@HostListener('document:click', ['$event'])
|
||||||
protected onDocumentClick(event: MouseEvent): void {
|
protected onDocumentClick(event: MouseEvent): void {
|
||||||
if (!this.isUserDropdownOpen() && !this.isCategoryDropdownOpen() && !this.isMobileMenuOpen()) {
|
if (!this.isUserDropdownOpen() && !this.isCategoryDropdownOpen() && !this.isMobileMenuOpen()) {
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
[displayCategories]="tenant()?.display_categories ?? true"
|
[displayCategories]="tenant()?.display_categories ?? true"
|
||||||
[displaySeachBar]="tenant()?.display_seach_bar ?? true"
|
[displaySeachBar]="tenant()?.display_seach_bar ?? true"
|
||||||
[displayCart]="displayCart()"
|
[displayCart]="displayCart()"
|
||||||
(cartClick)="isCartOpen.set(!isCartOpen())"
|
[cartDisabled]="isCheckoutRoute()"
|
||||||
|
(cartClick)="onCartClick()"
|
||||||
(ticketsClick)="onTicketsClick()"
|
(ticketsClick)="onTicketsClick()"
|
||||||
(loginClick)="onLoginClick()"
|
(loginClick)="onLoginClick()"
|
||||||
(logoutClick)="onLogoutClick()"
|
(logoutClick)="onLogoutClick()"
|
||||||
@@ -20,7 +21,7 @@
|
|||||||
(categorySelect)="onCategorySelect($event)"
|
(categorySelect)="onCategorySelect($event)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@if (displayCart() && isCartOpen()) {
|
@if (displayCart() && !isCheckoutRoute() && isCartOpen()) {
|
||||||
<div class="store-layout__cart-overlay" (click)="isCartOpen.set(false)"></div>
|
<div class="store-layout__cart-overlay" (click)="isCartOpen.set(false)"></div>
|
||||||
<div class="store-layout__cart-dropdown card border-0 shadow-lg">
|
<div class="store-layout__cart-dropdown card border-0 shadow-lg">
|
||||||
<app-cart
|
<app-cart
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ParamMap,
|
ParamMap,
|
||||||
provideRouter,
|
provideRouter,
|
||||||
Router,
|
Router,
|
||||||
|
UrlSerializer,
|
||||||
} from '@angular/router';
|
} from '@angular/router';
|
||||||
|
|
||||||
import { BehaviorSubject, of } from 'rxjs';
|
import { BehaviorSubject, of } from 'rxjs';
|
||||||
@@ -22,6 +23,7 @@ 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 { TenantUrlSerializer } from '../../services/tenant-url.serializer';
|
||||||
|
|
||||||
const tenant: Tenant = {
|
const tenant: Tenant = {
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -164,6 +166,7 @@ describe('StoreLayoutComponent', () => {
|
|||||||
imports: [StoreLayoutComponent],
|
imports: [StoreLayoutComponent],
|
||||||
providers: [
|
providers: [
|
||||||
provideRouter([]),
|
provideRouter([]),
|
||||||
|
{ provide: UrlSerializer, useClass: TenantUrlSerializer },
|
||||||
{
|
{
|
||||||
provide: ActivatedRoute,
|
provide: ActivatedRoute,
|
||||||
useValue: { queryParamMap: queryParamMapState.asObservable() },
|
useValue: { queryParamMap: queryParamMapState.asObservable() },
|
||||||
@@ -240,6 +243,33 @@ describe('StoreLayoutComponent', () => {
|
|||||||
expect((fixture.nativeElement as HTMLElement).querySelector('app-cart')).not.toBeNull();
|
expect((fixture.nativeElement as HTMLElement).querySelector('app-cart')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('disables the cart icon and prevents opening the popup during checkout with a tenant base path', () => {
|
||||||
|
tenantState.set({ ...tenant, base_path: 'fiesta' });
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
vi.spyOn(router, 'url', 'get').mockReturnValue('/fiesta/checkout?purchase=25');
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(StoreLayoutComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
const cartButton = element.querySelector<HTMLButtonElement>('app-cart-icon button');
|
||||||
|
const header = fixture.debugElement.query(By.directive(StoreHeaderComponent));
|
||||||
|
|
||||||
|
expect(cartButton?.disabled).toBe(true);
|
||||||
|
expect(cartButton?.getAttribute('aria-label')).toBe(
|
||||||
|
'Carrito no disponible durante el checkout',
|
||||||
|
);
|
||||||
|
expect(element.querySelector('[data-testid="cart-badge"]')).toBeNull();
|
||||||
|
|
||||||
|
header.componentInstance.cartClick.emit();
|
||||||
|
queryParamMapState.next(convertToParamMap({ openCart: 'true' }));
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect((fixture.componentInstance as any).isCartOpen()).toBe(false);
|
||||||
|
expect(element.querySelector('app-cart')).toBeNull();
|
||||||
|
expect(element.querySelector('.store-layout__cart-overlay')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { Component, computed, DestroyRef, inject, OnInit, signal } from '@angular/core';
|
import { Component, computed, DestroyRef, inject, OnInit, signal } from '@angular/core';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
|
import {
|
||||||
|
ActivatedRoute,
|
||||||
|
NavigationEnd,
|
||||||
|
PRIMARY_OUTLET,
|
||||||
|
Router,
|
||||||
|
RouterOutlet,
|
||||||
|
} from '@angular/router';
|
||||||
|
import { filter } from 'rxjs';
|
||||||
import { TenantService } from '../../services/tenant.service';
|
import { TenantService } from '../../services/tenant.service';
|
||||||
import { CartService } from '../../services/cart/cart.service';
|
import { CartService } from '../../services/cart/cart.service';
|
||||||
import { StoreFooterComponent, StoreFooterSection } from './store-footer/store-footer.component';
|
import { StoreFooterComponent, StoreFooterSection } from './store-footer/store-footer.component';
|
||||||
@@ -37,6 +44,7 @@ export class StoreLayoutComponent implements OnInit {
|
|||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
protected readonly isCartOpen = signal(false);
|
protected readonly isCartOpen = signal(false);
|
||||||
|
protected readonly isCheckoutRoute = signal(this.isCheckoutUrl(this.router.url));
|
||||||
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);
|
||||||
@@ -149,8 +157,22 @@ export class StoreLayoutComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
this.router.events
|
||||||
|
.pipe(
|
||||||
|
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
|
||||||
|
takeUntilDestroyed(this.destroyRef),
|
||||||
|
)
|
||||||
|
.subscribe((event) => {
|
||||||
|
const isCheckoutRoute = this.isCheckoutUrl(event.urlAfterRedirects);
|
||||||
|
this.isCheckoutRoute.set(isCheckoutRoute);
|
||||||
|
|
||||||
|
if (isCheckoutRoute) {
|
||||||
|
this.isCartOpen.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.route.queryParamMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
|
this.route.queryParamMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
|
||||||
if (params.get('openCart') === 'true') {
|
if (params.get('openCart') === 'true' && !this.isCheckoutRoute()) {
|
||||||
this.isCartOpen.set(true);
|
this.isCartOpen.set(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -160,10 +182,24 @@ export class StoreLayoutComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isCheckoutUrl(url: string): boolean {
|
||||||
|
const primarySegments = this.router.parseUrl(url).root.children[PRIMARY_OUTLET]?.segments ?? [];
|
||||||
|
|
||||||
|
return primarySegments[0]?.path === 'checkout';
|
||||||
|
}
|
||||||
|
|
||||||
protected onLoginClick(): void {
|
protected onLoginClick(): void {
|
||||||
void this.router.navigate(['/login']);
|
void this.router.navigate(['/login']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected onCartClick(): void {
|
||||||
|
if (this.isCheckoutRoute()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isCartOpen.update((isOpen) => !isOpen);
|
||||||
|
}
|
||||||
|
|
||||||
protected onSearch(term: string): void {
|
protected onSearch(term: string): void {
|
||||||
void this.router.navigate(['/buscar'], {
|
void this.router.navigate(['/buscar'], {
|
||||||
queryParams: { q: term, page: 1 },
|
queryParams: { q: term, page: 1 },
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
<app-checkout-payment-step
|
<app-checkout-payment-step
|
||||||
[paymentMethods]="paymentMethods"
|
[paymentMethods]="paymentMethods"
|
||||||
[selectedPaymentMethod]="selectedPaymentMethod()"
|
[selectedPaymentMethod]="selectedPaymentMethod()"
|
||||||
|
[paymentMethodDisabled]="hasSubmittedTransfer()"
|
||||||
[copiedTransferField]="copiedTransferField()"
|
[copiedTransferField]="copiedTransferField()"
|
||||||
[transferAccount]="transferAccount()"
|
[transferAccount]="transferAccount()"
|
||||||
[transferDni]="transferDni()"
|
[transferDni]="transferDni()"
|
||||||
@@ -51,7 +52,7 @@
|
|||||||
[allowModify]="true"
|
[allowModify]="true"
|
||||||
[showModifyWhenReadonly]="true"
|
[showModifyWhenReadonly]="true"
|
||||||
[modifyAsAction]="true"
|
[modifyAsAction]="true"
|
||||||
[editingDisabled]="isCancellingPurchase()"
|
[editingDisabled]="isPurchaseModificationDisabled()"
|
||||||
backgroundColor="transparent"
|
backgroundColor="transparent"
|
||||||
(modify)="onModifyPurchase()"
|
(modify)="onModifyPurchase()"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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';
|
||||||
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
|
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
|
||||||
|
import { of } from 'rxjs';
|
||||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { AuthService } from '../../../../core/services/auth/auth.service';
|
import { AuthService } from '../../../../core/services/auth/auth.service';
|
||||||
@@ -11,6 +12,7 @@ import { CatalogService } from '../../../../core/services/catalog/catalog.servic
|
|||||||
import { GlobalLoadingService } from '../../../../core/services/global-loading/global-loading.service';
|
import { GlobalLoadingService } from '../../../../core/services/global-loading/global-loading.service';
|
||||||
import { TenantService } from '../../../../core/services/tenant.service';
|
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 { 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';
|
||||||
|
|
||||||
@@ -30,6 +32,7 @@ describe('CheckoutPageComponent payment validation', () => {
|
|||||||
start: ReturnType<typeof vi.fn>;
|
start: ReturnType<typeof vi.fn>;
|
||||||
stop: ReturnType<typeof vi.fn>;
|
stop: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
|
let cartServiceStub: { loadCart: 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<
|
||||||
@@ -62,7 +65,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' }),
|
submitPurchaseForReview: vi.fn().mockResolvedValue({ status: 'in_review' }),
|
||||||
getPurchase: vi.fn().mockResolvedValue({ status: 'pending_payment' }),
|
getPurchase: vi.fn().mockResolvedValue({ status: 'pending_payment' }),
|
||||||
withCustomLoading: vi.fn(),
|
withCustomLoading: vi.fn(),
|
||||||
};
|
};
|
||||||
@@ -70,6 +73,9 @@ describe('CheckoutPageComponent payment validation', () => {
|
|||||||
routerStub = { navigate: vi.fn() };
|
routerStub = { navigate: vi.fn() };
|
||||||
toastServiceStub = { danger: vi.fn() };
|
toastServiceStub = { danger: vi.fn() };
|
||||||
globalLoadingServiceStub = { start: vi.fn(), stop: vi.fn() };
|
globalLoadingServiceStub = { start: vi.fn(), stop: vi.fn() };
|
||||||
|
cartServiceStub = {
|
||||||
|
loadCart: vi.fn().mockReturnValue(of({ id: 30, items: [], subtotal: '0.00' })),
|
||||||
|
};
|
||||||
routeQueryParamMap = convertToParamMap({});
|
routeQueryParamMap = convertToParamMap({});
|
||||||
authUserState = signal(null);
|
authUserState = signal(null);
|
||||||
tenantState = signal({
|
tenantState = signal({
|
||||||
@@ -92,6 +98,7 @@ describe('CheckoutPageComponent payment validation', () => {
|
|||||||
{ provide: AuthService, useValue: { user: authUserState } },
|
{ provide: AuthService, useValue: { user: authUserState } },
|
||||||
{ provide: GlobalLoadingService, useValue: globalLoadingServiceStub },
|
{ provide: GlobalLoadingService, useValue: globalLoadingServiceStub },
|
||||||
{ provide: ToastService, useValue: toastServiceStub },
|
{ provide: ToastService, useValue: toastServiceStub },
|
||||||
|
{ provide: CartService, useValue: cartServiceStub },
|
||||||
{
|
{
|
||||||
provide: ActivatedRoute,
|
provide: ActivatedRoute,
|
||||||
useValue: {
|
useValue: {
|
||||||
@@ -211,6 +218,25 @@ describe('CheckoutPageComponent payment validation', () => {
|
|||||||
expect(routerStub.navigate).not.toHaveBeenCalled();
|
expect(routerStub.navigate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prevents modifying the purchase after Ya transferí is clicked', async () => {
|
||||||
|
const { component } = createComponent();
|
||||||
|
component.selectedPaymentMethod.set('transfer');
|
||||||
|
|
||||||
|
await component.onComplete();
|
||||||
|
|
||||||
|
expect(component.hasSubmittedTransfer()).toBe(true);
|
||||||
|
expect(component.isPurchaseModificationDisabled()).toBe(true);
|
||||||
|
|
||||||
|
await component.selectPaymentMethod('qr');
|
||||||
|
await component.onModifyPurchase();
|
||||||
|
|
||||||
|
expect(component.selectedPaymentMethod()).toBe('transfer');
|
||||||
|
expect(checkoutServiceStub.generatePaymentIntent).not.toHaveBeenCalled();
|
||||||
|
expect(checkoutServiceStub.cancelPurchase).not.toHaveBeenCalled();
|
||||||
|
expect(globalLoadingServiceStub.start).not.toHaveBeenCalled();
|
||||||
|
expect(routerStub.navigate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('navigates after a transfer is confirmed as paid', async () => {
|
it('navigates after a transfer is confirmed as paid', async () => {
|
||||||
checkoutServiceStub.getPurchase.mockResolvedValue({ status: 'paid' });
|
checkoutServiceStub.getPurchase.mockResolvedValue({ status: 'paid' });
|
||||||
const { component } = createComponent();
|
const { component } = createComponent();
|
||||||
@@ -251,19 +277,11 @@ describe('CheckoutPageComponent payment validation', () => {
|
|||||||
expect(component.transferValidationStatus()).toBe('error');
|
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 component is destroyed', async () => {
|
||||||
const first = createComponent();
|
const checkout = createComponent();
|
||||||
first.component.selectedPaymentMethod.set('transfer');
|
checkout.component.selectedPaymentMethod.set('transfer');
|
||||||
first.component.onComplete();
|
await checkout.component.onComplete();
|
||||||
checkoutServiceStub.generatePaymentIntent.mockResolvedValueOnce({});
|
checkout.fixture.destroy();
|
||||||
await first.component.selectPaymentMethod('qr');
|
|
||||||
await vi.advanceTimersByTimeAsync(12_000);
|
|
||||||
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
const second = createComponent();
|
|
||||||
second.component.selectedPaymentMethod.set('transfer');
|
|
||||||
second.component.onComplete();
|
|
||||||
second.fixture.destroy();
|
|
||||||
await vi.advanceTimersByTimeAsync(12_000);
|
await vi.advanceTimersByTimeAsync(12_000);
|
||||||
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
|
expect(checkoutServiceStub.getPurchase).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -494,6 +512,7 @@ describe('CheckoutPageComponent payment validation', () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
expect(checkoutServiceStub.cancelPurchase).toHaveBeenCalledWith('tenant-test', 25);
|
expect(checkoutServiceStub.cancelPurchase).toHaveBeenCalledWith('tenant-test', 25);
|
||||||
|
expect(cartServiceStub.loadCart).toHaveBeenCalledOnce();
|
||||||
expect(globalLoadingServiceStub.start).toHaveBeenCalledOnce();
|
expect(globalLoadingServiceStub.start).toHaveBeenCalledOnce();
|
||||||
expect(globalLoadingServiceStub.stop).not.toHaveBeenCalled();
|
expect(globalLoadingServiceStub.stop).not.toHaveBeenCalled();
|
||||||
expect(component.createdPurchaseId()).toBeNull();
|
expect(component.createdPurchaseId()).toBeNull();
|
||||||
@@ -508,7 +527,6 @@ describe('CheckoutPageComponent payment validation', () => {
|
|||||||
expect(globalLoadingServiceStub.stop).toHaveBeenCalledOnce();
|
expect(globalLoadingServiceStub.stop).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
await component.canDeactivate();
|
await component.canDeactivate();
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates customer data on the existing purchase before payment', async () => {
|
it('updates customer data on the existing purchase before payment', async () => {
|
||||||
@@ -551,6 +569,7 @@ describe('CheckoutPageComponent payment validation', () => {
|
|||||||
await expect(component.canDeactivate()).resolves.toBe(true);
|
await expect(component.canDeactivate()).resolves.toBe(true);
|
||||||
|
|
||||||
expect(checkoutServiceStub.cancelPurchase).toHaveBeenCalledWith('tenant-test', 25);
|
expect(checkoutServiceStub.cancelPurchase).toHaveBeenCalledWith('tenant-test', 25);
|
||||||
|
expect(cartServiceStub.loadCart).toHaveBeenCalledOnce();
|
||||||
expect(component.createdPurchaseId()).toBeNull();
|
expect(component.createdPurchaseId()).toBeNull();
|
||||||
expect(globalLoadingServiceStub.start).toHaveBeenCalledOnce();
|
expect(globalLoadingServiceStub.start).toHaveBeenCalledOnce();
|
||||||
expect(globalLoadingServiceStub.stop).toHaveBeenCalledOnce();
|
expect(globalLoadingServiceStub.stop).toHaveBeenCalledOnce();
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
import { HttpErrorResponse } from '@angular/common/http';
|
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 { startWith } from 'rxjs';
|
import { firstValueFrom, startWith } from 'rxjs';
|
||||||
|
|
||||||
import { TenantService } from '../../../../core/services/tenant.service';
|
import { TenantService } from '../../../../core/services/tenant.service';
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
import { AuthService } from '../../../../core/services/auth/auth.service';
|
import { AuthService } from '../../../../core/services/auth/auth.service';
|
||||||
import { GlobalLoadingService } from '../../../../core/services/global-loading/global-loading.service';
|
import { GlobalLoadingService } from '../../../../core/services/global-loading/global-loading.service';
|
||||||
import { ToastService } from '../../../../core/services/toast.service';
|
import { ToastService } from '../../../../core/services/toast.service';
|
||||||
|
import { CartService } from '../../../../core/services/cart/cart.service';
|
||||||
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';
|
||||||
import { StepperComponent } from '../../../../shared/components/stepper/stepper.component';
|
import { StepperComponent } from '../../../../shared/components/stepper/stepper.component';
|
||||||
@@ -66,6 +67,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
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);
|
||||||
|
private readonly cartService = inject(CartService);
|
||||||
private readonly qrPollingIntervalMs = 5_000;
|
private readonly qrPollingIntervalMs = 5_000;
|
||||||
private readonly qrPollingMaxAttempts = 9;
|
private readonly qrPollingMaxAttempts = 9;
|
||||||
private readonly transferPollingIntervalMs = 3_000;
|
private readonly transferPollingIntervalMs = 3_000;
|
||||||
@@ -122,6 +124,10 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
protected readonly isUpdatingPurchase = signal(false);
|
protected readonly isUpdatingPurchase = signal(false);
|
||||||
protected readonly isCancellingPurchase = signal(false);
|
protected readonly isCancellingPurchase = signal(false);
|
||||||
|
protected readonly hasSubmittedTransfer = signal(false);
|
||||||
|
protected readonly isPurchaseModificationDisabled = computed(
|
||||||
|
() => this.isCancellingPurchase() || this.hasSubmittedTransfer(),
|
||||||
|
);
|
||||||
protected readonly createdPurchaseId = signal<number | null>(null);
|
protected readonly createdPurchaseId = signal<number | null>(null);
|
||||||
protected readonly isGeneratingIntent = signal(false);
|
protected readonly isGeneratingIntent = signal(false);
|
||||||
protected readonly isPaymentLoading = computed(() => this.isGeneratingIntent());
|
protected readonly isPaymentLoading = computed(() => this.isGeneratingIntent());
|
||||||
@@ -187,7 +193,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected async onModifyPurchase(): Promise<void> {
|
protected async onModifyPurchase(): Promise<void> {
|
||||||
if (this.isCancellingPurchase()) {
|
if (this.isPurchaseModificationDisabled()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,6 +288,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await this.checkoutService.cancelPurchase(tenant.codigo, purchaseId);
|
await this.checkoutService.cancelPurchase(tenant.codigo, purchaseId);
|
||||||
|
await firstValueFrom(this.cartService.loadCart());
|
||||||
this.createdPurchaseId.set(null);
|
this.createdPurchaseId.set(null);
|
||||||
this.createdPurchase.set(null);
|
this.createdPurchase.set(null);
|
||||||
|
|
||||||
@@ -297,7 +304,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected async selectPaymentMethod(method: PaymentMethod): Promise<void> {
|
protected async selectPaymentMethod(method: PaymentMethod): Promise<void> {
|
||||||
if (this.navigationStarted) {
|
if (this.navigationStarted || this.hasSubmittedTransfer()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,6 +356,10 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected async generateTransferIntent(dni: string): Promise<void> {
|
protected async generateTransferIntent(dni: string): Promise<void> {
|
||||||
|
if (this.hasSubmittedTransfer()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const purchaseId = this.createdPurchaseId();
|
const purchaseId = this.createdPurchaseId();
|
||||||
const tenant = this.tenantService.tenant();
|
const tenant = this.tenantService.tenant();
|
||||||
|
|
||||||
@@ -413,6 +424,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.stopTransferPolling();
|
this.stopTransferPolling();
|
||||||
|
this.hasSubmittedTransfer.set(true);
|
||||||
this.transferValidationStatus.set('checking');
|
this.transferValidationStatus.set('checking');
|
||||||
this.transferPollingAttempts = 0;
|
this.transferPollingAttempts = 0;
|
||||||
|
|
||||||
@@ -637,6 +649,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
(purchase.status === 'pending_payment' && purchase.expires_at === null) ||
|
(purchase.status === 'pending_payment' && purchase.expires_at === null) ||
|
||||||
|
purchase.status === 'in_review' ||
|
||||||
purchase.status === 'paid' ||
|
purchase.status === 'paid' ||
|
||||||
purchase.status === 'cancelled' ||
|
purchase.status === 'cancelled' ||
|
||||||
purchase.status === 'rejected' ||
|
purchase.status === 'rejected' ||
|
||||||
|
|||||||
@@ -7,13 +7,18 @@
|
|||||||
|
|
||||||
<div class="payment-methods__list" role="radiogroup" aria-label="Metodo de pago">
|
<div class="payment-methods__list" role="radiogroup" aria-label="Metodo de pago">
|
||||||
@for (method of paymentMethods(); track method.id) {
|
@for (method of paymentMethods(); track method.id) {
|
||||||
<label class="payment-method" [class.is-selected]="selectedPaymentMethod() === method.id">
|
<label
|
||||||
|
class="payment-method"
|
||||||
|
[class.is-selected]="selectedPaymentMethod() === method.id"
|
||||||
|
[class.is-disabled]="paymentMethodDisabled()"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
class="payment-method__radio"
|
class="payment-method__radio"
|
||||||
type="radio"
|
type="radio"
|
||||||
name="payment-method"
|
name="payment-method"
|
||||||
[value]="method.id"
|
[value]="method.id"
|
||||||
[checked]="selectedPaymentMethod() === method.id"
|
[checked]="selectedPaymentMethod() === method.id"
|
||||||
|
[disabled]="paymentMethodDisabled()"
|
||||||
(change)="selectPaymentMethod(method.id)"
|
(change)="selectPaymentMethod(method.id)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -63,10 +63,14 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 0.2s ease;
|
transition: color 0.2s ease;
|
||||||
|
|
||||||
&:hover {
|
&:not(.is-disabled):hover {
|
||||||
color: #4f4f4f;
|
color: #4f4f4f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.is-disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
&.is-selected {
|
&.is-selected {
|
||||||
color: var(--tenant-primary, #6376f3);
|
color: var(--tenant-primary, #6376f3);
|
||||||
}
|
}
|
||||||
@@ -79,6 +83,10 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.is-disabled &__radio {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
&__label {
|
&__label {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -91,11 +99,13 @@
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(-4px);
|
transform: translateX(-4px);
|
||||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
transition:
|
||||||
|
opacity 0.2s ease,
|
||||||
|
transform 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-selected &__chevron,
|
&.is-selected &__chevron,
|
||||||
&:hover &__chevron {
|
&:not(.is-disabled):hover &__chevron {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
@@ -140,6 +150,4 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,11 +17,12 @@ import {
|
|||||||
imports: [CheckoutPaymentQrComponent, CheckoutPaymentTransferComponent],
|
imports: [CheckoutPaymentQrComponent, CheckoutPaymentTransferComponent],
|
||||||
templateUrl: './checkout-payment-step.component.html',
|
templateUrl: './checkout-payment-step.component.html',
|
||||||
styleUrl: './checkout-payment-step.component.scss',
|
styleUrl: './checkout-payment-step.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class CheckoutPaymentStepComponent {
|
export class CheckoutPaymentStepComponent {
|
||||||
readonly paymentMethods = input.required<ReadonlyArray<PaymentMethodOption>>();
|
readonly paymentMethods = input.required<ReadonlyArray<PaymentMethodOption>>();
|
||||||
readonly selectedPaymentMethod = input.required<PaymentMethod>();
|
readonly selectedPaymentMethod = input.required<PaymentMethod>();
|
||||||
|
readonly paymentMethodDisabled = input<boolean>(false);
|
||||||
readonly copiedTransferField = input<TransferField | null>(null);
|
readonly copiedTransferField = input<TransferField | null>(null);
|
||||||
readonly transferAccount = input<TransferAccount | null>(null);
|
readonly transferAccount = input<TransferAccount | null>(null);
|
||||||
readonly transferDni = input<string>('');
|
readonly transferDni = input<string>('');
|
||||||
@@ -40,6 +41,10 @@ export class CheckoutPaymentStepComponent {
|
|||||||
readonly generateTransferIntent = output<string>();
|
readonly generateTransferIntent = output<string>();
|
||||||
|
|
||||||
protected selectPaymentMethod(method: PaymentMethod): void {
|
protected selectPaymentMethod(method: PaymentMethod): void {
|
||||||
|
if (this.paymentMethodDisabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.paymentMethodChange.emit(method);
|
this.paymentMethodChange.emit(method);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="disabled()"
|
[disabled]="disabled()"
|
||||||
|
[class.cart-icon--disabled]="disabled()"
|
||||||
[attr.aria-label]="ariaLabel()"
|
[attr.aria-label]="ariaLabel()"
|
||||||
|
[attr.aria-disabled]="disabled()"
|
||||||
class="cart-icon"
|
class="cart-icon"
|
||||||
>
|
>
|
||||||
<div class="cart-icon__container">
|
<div class="cart-icon__container">
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
color: #666666;
|
color: #666666;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: color 0.15s ease-in-out, transform 0.1s ease-in-out;
|
transition:
|
||||||
|
color 0.15s ease-in-out,
|
||||||
|
transform 0.1s ease-in-out;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|
||||||
// Active state subtle scale down
|
// Active state subtle scale down
|
||||||
@@ -29,10 +31,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Disabled state
|
// Disabled state
|
||||||
&:disabled {
|
&:disabled,
|
||||||
color: #A0A0A0;
|
&.cart-icon--disabled {
|
||||||
|
color: #b8b8b8;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +63,11 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cart-icon:disabled .cart-icon__glyph,
|
||||||
|
.cart-icon--disabled .cart-icon__glyph {
|
||||||
|
color: #b8b8b8;
|
||||||
|
}
|
||||||
|
|
||||||
.cart-icon__badge {
|
.cart-icon__badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -8px;
|
top: -8px;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { CartIconComponent } from './cart-icon.component';
|
|||||||
describe('CartIconComponent', () => {
|
describe('CartIconComponent', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [CartIconComponent]
|
imports: [CartIconComponent],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ describe('CartIconComponent', () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
fixture,
|
fixture,
|
||||||
element: fixture.nativeElement as HTMLElement
|
element: fixture.nativeElement as HTMLElement,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,9 +47,11 @@ describe('CartIconComponent', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('disables the button when disabled is true', () => {
|
it('disables the button when disabled is true', () => {
|
||||||
const { element } = setup(undefined, true);
|
const { element } = setup(3, true);
|
||||||
const button = element.querySelector('button');
|
const button = element.querySelector('button');
|
||||||
expect(button?.disabled).toBe(true);
|
expect(button?.disabled).toBe(true);
|
||||||
|
expect(button?.classList).toContain('cart-icon--disabled');
|
||||||
|
expect(element.querySelector('[data-testid="cart-badge"]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('enables the button when disabled is false', () => {
|
it('enables the button when disabled is false', () => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/co
|
|||||||
imports: [],
|
imports: [],
|
||||||
templateUrl: './cart-icon.component.html',
|
templateUrl: './cart-icon.component.html',
|
||||||
styleUrl: './cart-icon.component.scss',
|
styleUrl: './cart-icon.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class CartIconComponent {
|
export class CartIconComponent {
|
||||||
readonly quantity = input<number | null | undefined>(undefined);
|
readonly quantity = input<number | null | undefined>(undefined);
|
||||||
@@ -13,6 +13,10 @@ export class CartIconComponent {
|
|||||||
readonly ariaLabel = input<string>('Carrito de compras');
|
readonly ariaLabel = input<string>('Carrito de compras');
|
||||||
|
|
||||||
protected readonly hasQuantity = computed(() => {
|
protected readonly hasQuantity = computed(() => {
|
||||||
|
if (this.disabled()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const q = this.quantity();
|
const q = this.quantity();
|
||||||
return q !== null && q !== undefined && q > 0;
|
return q !== null && q !== undefined && q > 0;
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user