Compare commits

...

12 Commits

21 changed files with 451 additions and 549 deletions

View File

@@ -236,7 +236,6 @@ export class StoreLayoutComponent implements OnInit {
cart_id: cart.id, cart_id: cart.id,
}); });
this.cartService.clearCart();
this.isCartOpen.set(false); this.isCartOpen.set(false);
await this.router.navigate(['/checkout'], { await this.router.navigate(['/checkout'], {
queryParams: { purchase: purchase.id }, queryParams: { purchase: purchase.id },

View File

@@ -7,11 +7,13 @@ import { CartService } from './cart.service';
import { TenantService } from '../tenant.service'; import { TenantService } from '../tenant.service';
import { Cart } from './cart.interface'; import { Cart } from './cart.interface';
import { LOADING_MODE } from '../global-loading/loading-mode'; import { LOADING_MODE } from '../global-loading/loading-mode';
import { CatalogAvailabilityService } from '../catalog/catalog-availability.service';
describe('CartService', () => { describe('CartService', () => {
let service: CartService; let service: CartService;
let httpMock: HttpTestingController; let httpMock: HttpTestingController;
let tenantServiceMock: any; let tenantServiceMock: any;
let catalogAvailabilityService: CatalogAvailabilityService;
const mockCart: Cart = { const mockCart: Cart = {
id: 123, id: 123,
@@ -53,6 +55,7 @@ describe('CartService', () => {
}); });
service = TestBed.inject(CartService); service = TestBed.inject(CartService);
catalogAvailabilityService = TestBed.inject(CatalogAvailabilityService);
httpMock = TestBed.inject(HttpTestingController); httpMock = TestBed.inject(HttpTestingController);
}); });
@@ -101,6 +104,9 @@ describe('CartService', () => {
}); });
it('should add item and update signal', () => { it('should add item and update signal', () => {
const availabilityChanged = vi.fn();
catalogAvailabilityService.availabilityChanged$.subscribe(availabilityChanged);
service.addItem(5, 10, 2).subscribe((res) => { service.addItem(5, 10, 2).subscribe((res) => {
expect(res.data).toEqual(mockCart); expect(res.data).toEqual(mockCart);
expect(service.cart()).toEqual(mockCart); expect(service.cart()).toEqual(mockCart);
@@ -115,9 +121,13 @@ describe('CartService', () => {
}); });
expect(req.request.withCredentials).toBe(true); expect(req.request.withCredentials).toBe(true);
req.flush({ data: mockCart }); req.flush({ data: mockCart });
expect(availabilityChanged).toHaveBeenCalledOnce();
}); });
it('should update item quantity and update signal', () => { it('should update item quantity and update signal', () => {
const availabilityChanged = vi.fn();
catalogAvailabilityService.availabilityChanged$.subscribe(availabilityChanged);
const updatedCart = { ...mockCart, subtotal: '30.00' }; const updatedCart = { ...mockCart, subtotal: '30.00' };
updatedCart.items[0].cantidad = 3; updatedCart.items[0].cantidad = 3;
@@ -131,9 +141,13 @@ describe('CartService', () => {
expect(req.request.body).toEqual({ cantidad: 3 }); expect(req.request.body).toEqual({ cantidad: 3 });
expect(req.request.withCredentials).toBe(true); expect(req.request.withCredentials).toBe(true);
req.flush({ data: updatedCart }); req.flush({ data: updatedCart });
expect(availabilityChanged).toHaveBeenCalledOnce();
}); });
it('should remove item and update signal', () => { it('should remove item and update signal', () => {
const availabilityChanged = vi.fn();
catalogAvailabilityService.availabilityChanged$.subscribe(availabilityChanged);
const emptyCart: Cart = { const emptyCart: Cart = {
id: 123, id: 123,
tenant_codigo: 'acme', tenant_codigo: 'acme',
@@ -151,5 +165,18 @@ describe('CartService', () => {
expect(req.request.method).toBe('DELETE'); expect(req.request.method).toBe('DELETE');
expect(req.request.withCredentials).toBe(true); expect(req.request.withCredentials).toBe(true);
req.flush({ data: emptyCart }); req.flush({ data: emptyCart });
expect(availabilityChanged).toHaveBeenCalledOnce();
});
it('does not notify an availability change when a cart mutation fails', () => {
const availabilityChanged = vi.fn();
catalogAvailabilityService.availabilityChanged$.subscribe(availabilityChanged);
service.removeItem(10).subscribe({ error: vi.fn() });
const req = httpMock.expectOne('http://api.test/tenants/acme/cart/items/10');
req.flush({ message: 'Error' }, { status: 500, statusText: 'Server Error' });
expect(availabilityChanged).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -3,6 +3,7 @@ import { catchError, map, Observable, tap } from 'rxjs';
import { ApiResponse } from '../api-response.interface'; import { ApiResponse } from '../api-response.interface';
import { BaseApiService } from '../base-api.service'; import { BaseApiService } from '../base-api.service';
import { CatalogAvailabilityService } from '../catalog/catalog-availability.service';
import { TenantService } from '../tenant.service'; import { TenantService } from '../tenant.service';
import { Cart } from './cart.interface'; import { Cart } from './cart.interface';
@@ -10,6 +11,7 @@ import { Cart } from './cart.interface';
providedIn: 'root', providedIn: 'root',
}) })
export class CartService extends BaseApiService { export class CartService extends BaseApiService {
private readonly catalogAvailabilityService = inject(CatalogAvailabilityService);
private readonly tenantService = inject(TenantService); private readonly tenantService = inject(TenantService);
private readonly cartState = signal<Cart | null>(null); private readonly cartState = signal<Cart | null>(null);
@@ -60,6 +62,7 @@ export class CartService extends BaseApiService {
.pipe( .pipe(
tap((response) => { tap((response) => {
this.cartState.set(response.data); this.cartState.set(response.data);
this.catalogAvailabilityService.notifyAvailabilityChanged();
this.isUpdatingState.set(false); this.isUpdatingState.set(false);
}), }),
catchError((error) => { catchError((error) => {
@@ -93,6 +96,7 @@ export class CartService extends BaseApiService {
.pipe( .pipe(
tap((response) => { tap((response) => {
this.cartState.set(response.data); this.cartState.set(response.data);
this.catalogAvailabilityService.notifyAvailabilityChanged();
this.isUpdatingState.set(false); this.isUpdatingState.set(false);
}), }),
catchError((error) => { catchError((error) => {
@@ -111,6 +115,7 @@ export class CartService extends BaseApiService {
.pipe( .pipe(
tap((response) => { tap((response) => {
this.cartState.set(response.data); this.cartState.set(response.data);
this.catalogAvailabilityService.notifyAvailabilityChanged();
this.isUpdatingState.set(false); this.isUpdatingState.set(false);
}), }),
catchError((error) => { catchError((error) => {

View File

@@ -0,0 +1,15 @@
import { Injectable } from '@angular/core';
import { Observable, Subject } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class CatalogAvailabilityService {
private readonly availabilityChangedSubject = new Subject<void>();
readonly availabilityChanged$: Observable<void> = this.availabilityChangedSubject.asObservable();
notifyAvailabilityChanged(): void {
this.availabilityChangedSubject.next();
}
}

View File

@@ -3,7 +3,6 @@ import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { BaseApiService } from './base-api.service'; import { BaseApiService } from './base-api.service';
import { CartItemVariant } from './cart/cart.interface';
export interface UpdatePurchaseCustomerPayload { export interface UpdatePurchaseCustomerPayload {
dni: string; dni: string;
@@ -74,7 +73,6 @@ export interface PurchaseDetailItemResponse {
line_total: string; line_total: string;
source_catalog_item_id: number | null; source_catalog_item_id: number | null;
source_variant_id: number | null; source_variant_id: number | null;
variants?: CartItemVariant[];
item_details: { item_details: {
nombre: string; nombre: string;
descripcion: string | null; descripcion: string | null;
@@ -97,7 +95,7 @@ export interface PurchaseDetailResponse extends PurchaseStatusResponse {
telefono: string | null; telefono: string | null;
nombre_apellido: string | null; nombre_apellido: string | null;
email: string | null; email: string | null;
items_source: 'purchase' | 'cart'; items_source: 'purchase';
items: PurchaseDetailItemResponse[]; items: PurchaseDetailItemResponse[];
tickets_count?: number; tickets_count?: number;
has_generated_tickets?: boolean; has_generated_tickets?: boolean;
@@ -171,104 +169,6 @@ export class CheckoutService extends BaseApiService {
return purchase; return purchase;
} }
async updateItemQuantity(
tenantCode: string,
purchaseId: number,
itemId: number,
quantity: number,
cartId: number | null = null,
itemsSource: 'purchase' | 'cart' = 'purchase',
): Promise<PurchaseDetailResponse> {
if (itemsSource === 'cart' && cartId !== null) {
await firstValueFrom(
this.http.patch(
`${environment.url}tenants/${tenantCode}/checkout-carts/${cartId}/items/${itemId}`,
{ cantidad: quantity },
),
);
return this.getPurchase(tenantCode, purchaseId);
}
const response = await firstValueFrom(
this.http.patch<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/items/${itemId}`,
{ quantity },
),
);
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
if (!purchase) {
throw new Error('Error al actualizar la cantidad del producto.');
}
return purchase;
}
async updateItemVariant(
tenantCode: string,
purchaseId: number,
itemId: number,
variantId: number,
quantity: number,
cartId: number | null = null,
itemsSource: 'purchase' | 'cart' = 'purchase',
): Promise<PurchaseDetailResponse> {
if (itemsSource === 'cart' && cartId !== null) {
await firstValueFrom(
this.http.patch(
`${environment.url}tenants/${tenantCode}/checkout-carts/${cartId}/items/${itemId}`,
{ cantidad: quantity, variant_id: variantId },
),
);
} else {
await firstValueFrom(
this.http.patch(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/items/${itemId}`,
{ quantity, variant_id: variantId },
),
);
}
return this.getPurchase(tenantCode, purchaseId);
}
async removeItem(
tenantCode: string,
purchaseId: number,
itemId: number,
cartId: number | null = null,
itemsSource: 'purchase' | 'cart' = 'purchase',
): Promise<PurchaseDetailResponse> {
const url =
itemsSource === 'cart' && cartId !== null
? `${environment.url}tenants/${tenantCode}/checkout-carts/${cartId}/items/${itemId}`
: `${environment.url}tenants/${tenantCode}/compras/${purchaseId}/items/${itemId}`;
await firstValueFrom(this.http.delete(url));
return this.getPurchase(tenantCode, purchaseId);
}
async prepareItemEditing(
tenantCode: string,
purchaseId: number,
): Promise<PurchaseDetailResponse> {
const response = await firstValueFrom(
this.http.post<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
`${environment.url}tenants/${tenantCode}/compras/${purchaseId}/edit-items`,
{},
),
);
const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
if (!purchase) {
throw new Error('Error al preparar la compra para editarla.');
}
return purchase;
}
async completePurchase(tenantCode: string, purchaseId: number): Promise<PurchaseStatusResponse> { async completePurchase(tenantCode: string, purchaseId: number): Promise<PurchaseStatusResponse> {
const response = await firstValueFrom( const response = await firstValueFrom(
this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>( this.http.post<{ data?: PurchaseStatusResponse } | PurchaseStatusResponse>(

View File

@@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CartService } from '../../../../core/services/cart/cart.service'; import { CartService } from '../../../../core/services/cart/cart.service';
import { CatalogService } from '../../../../core/services/catalog/catalog.service'; import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { CatalogAvailabilityService } from '../../../../core/services/catalog/catalog-availability.service';
import { Tenant } from '../../../../core/services/tenant.interface'; import { Tenant } from '../../../../core/services/tenant.interface';
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';
@@ -107,6 +108,16 @@ describe('CategoryItemsPageComponent', () => {
expect(Array.isArray(productList.items())).toBe(false); expect(Array.isArray(productList.items())).toBe(false);
}); });
it('reloads the current category page when catalog availability changes', () => {
const fixture = TestBed.createComponent(CategoryItemsPageComponent);
fixture.detectChanges();
TestBed.inject(CatalogAvailabilityService).notifyAvailabilityChanged();
expect(getCategoryItems).toHaveBeenCalledTimes(2);
expect(getCategoryItems).toHaveBeenLastCalledWith(7, { page: 1 });
});
it('does not request the API when the category id is invalid', () => { it('does not request the API when the category id is invalid', () => {
TestBed.overrideProvider(ActivatedRoute, { TestBed.overrideProvider(ActivatedRoute, {
useValue: { useValue: {

View File

@@ -16,6 +16,7 @@ import {
finalize, finalize,
map, map,
of, of,
startWith,
switchMap, switchMap,
tap, tap,
} from 'rxjs'; } from 'rxjs';
@@ -28,6 +29,7 @@ import {
CategoryItemsResponse, CategoryItemsResponse,
} from '../../../../core/services/catalog/catalog.interface'; } from '../../../../core/services/catalog/catalog.interface';
import { CatalogService } from '../../../../core/services/catalog/catalog.service'; import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { CatalogAvailabilityService } from '../../../../core/services/catalog/catalog-availability.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 { import {
@@ -50,6 +52,7 @@ interface CategoryRouteState {
}) })
export class CategoryItemsPageComponent { export class CategoryItemsPageComponent {
private readonly cartService = inject(CartService); private readonly cartService = inject(CartService);
private readonly catalogAvailabilityService = inject(CatalogAvailabilityService);
private readonly catalogService = inject(CatalogService); private readonly catalogService = inject(CatalogService);
private readonly injector = inject(Injector); private readonly injector = inject(Injector);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
@@ -65,8 +68,7 @@ export class CategoryItemsPageComponent {
protected readonly paginatedLayout: CatalogGroupLayout = 'paginated'; protected readonly paginatedLayout: CatalogGroupLayout = 'paginated';
constructor() { constructor() {
combineLatest([this.route.paramMap, this.route.queryParamMap]) const routeState$ = combineLatest([this.route.paramMap, this.route.queryParamMap]).pipe(
.pipe(
map( map(
([params, queryParams]): CategoryRouteState => ({ ([params, queryParams]): CategoryRouteState => ({
categoryId: this.parsePositiveInteger(params.get('id')), categoryId: this.parsePositiveInteger(params.get('id')),
@@ -81,6 +83,14 @@ export class CategoryItemsPageComponent {
this.results.set(null); this.results.set(null);
this.error.set(null); this.error.set(null);
}), }),
);
combineLatest([
routeState$,
this.catalogAvailabilityService.availabilityChanged$.pipe(startWith(undefined)),
])
.pipe(
map(([routeState]) => routeState),
switchMap(({ categoryId, page }) => { switchMap(({ categoryId, page }) => {
if (categoryId === 0) { if (categoryId === 0) {
this.error.set('La categoría solicitada no es válida.'); this.error.set('La categoría solicitada no es válida.');

View File

@@ -5,12 +5,7 @@
</div> </div>
} @else { } @else {
<div class="checkout-page"> <div class="checkout-page">
<div <div class="checkout-page__stepper-col">
class="checkout-page__stepper-col"
[class.checkout-page__stepper-col--editing]="isEditingItems()"
[attr.aria-hidden]="isEditingItems()"
[attr.inert]="isEditingItems() ? '' : null"
>
<app-stepper #stepper [initialStepIndex]="checkoutStepIndex()"> <app-stepper #stepper [initialStepIndex]="checkoutStepIndex()">
<app-step label="Datos" [isValid]="isStep1Valid()"> <app-step label="Datos" [isValid]="isStep1Valid()">
<app-checkout-data-step <app-checkout-data-step
@@ -45,12 +40,6 @@
</app-stepper> </app-stepper>
</div> </div>
@if (isEditingItems()) {
<div class="checkout-page__editing-notice" role="status">
<p>Terminá de modificar las cantidades para continuar con el pago.</p>
</div>
}
<div class="checkout-page__cart-col"> <div class="checkout-page__cart-col">
<app-cart <app-cart
title="COMPRA" title="COMPRA"
@@ -58,23 +47,13 @@
[subtotal]="cartSubtotal()" [subtotal]="cartSubtotal()"
[discount]="cartDiscount()" [discount]="cartDiscount()"
[total]="cartTotal()" [total]="cartTotal()"
[readonly]="!canModifyCart()" [readonly]="true"
[allowModify]="true" [allowModify]="true"
[showModifyWhenReadonly]="true" [showModifyWhenReadonly]="true"
[allowUpdateQuantity]="canUpdateCartQuantity()" [modifyAsAction]="true"
[allowUpdateVariant]="canUpdateCartVariant()" [editingDisabled]="isCancellingPurchase()"
[requireEditingMode]="true"
[allowDelete]="canDeleteCartItems()"
[persistQuantityChanges]="false"
[persistVariantChanges]="false"
[persistDeleteChanges]="false"
[editing]="isEditingItems()"
[editingDisabled]="isUpdatingItem() || isPreparingItemEdit()"
backgroundColor="transparent" backgroundColor="transparent"
(editingChange)="onEditingItemsChange($event)" (modify)="onModifyPurchase()"
(itemQuantityChange)="onPurchaseItemQuantityChange($event)"
(itemVariantChange)="onPurchaseItemVariantChange($event)"
(itemRemove)="onPurchaseItemRemove($event)"
/> />
</div> </div>
</div> </div>

View File

@@ -11,8 +11,7 @@
order: 1; order: 1;
} }
.checkout-page__stepper-col, .checkout-page__stepper-col {
.checkout-page__editing-notice {
order: 2; order: 2;
} }
} }
@@ -21,27 +20,6 @@
min-width: 0; min-width: 0;
border-radius: 4px; border-radius: 4px;
min-height: 420px; min-height: 420px;
&--editing {
display: none;
}
}
&__editing-notice {
display: grid;
min-height: 420px;
place-items: center;
padding: 2rem;
border-radius: 4px;
background: #f5f5f5;
color: #666666;
text-align: center;
p {
max-width: 360px;
margin: 0;
font-size: 14px;
}
} }
&__cart-col { &__cart-col {

View File

@@ -3,13 +3,12 @@ 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';
import { CartService } from '../../../../core/services/cart/cart.service';
import { CheckoutService } from '../../../../core/services/checkout.service'; import { CheckoutService } from '../../../../core/services/checkout.service';
import { CatalogService } from '../../../../core/services/catalog/catalog.service'; import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { 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 { CartEditingPolicy } from '../../../../core/services/tenant.interface'; import { CartEditingPolicy } from '../../../../core/services/tenant.interface';
@@ -19,24 +18,18 @@ describe('CheckoutPageComponent payment validation', () => {
let checkoutServiceStub: { let checkoutServiceStub: {
startCheckout: ReturnType<typeof vi.fn>; startCheckout: ReturnType<typeof vi.fn>;
updateCustomerData: ReturnType<typeof vi.fn>; updateCustomerData: ReturnType<typeof vi.fn>;
updateItemQuantity: ReturnType<typeof vi.fn>;
updateItemVariant: ReturnType<typeof vi.fn>;
removeItem: ReturnType<typeof vi.fn>;
prepareItemEditing: ReturnType<typeof vi.fn>;
cancelPurchase: ReturnType<typeof vi.fn>; cancelPurchase: ReturnType<typeof vi.fn>;
generatePaymentIntent: ReturnType<typeof vi.fn>; generatePaymentIntent: ReturnType<typeof vi.fn>;
submitPurchaseForReview: ReturnType<typeof vi.fn>; submitPurchaseForReview: ReturnType<typeof vi.fn>;
getPurchase: ReturnType<typeof vi.fn>; getPurchase: ReturnType<typeof vi.fn>;
withCustomLoading: ReturnType<typeof vi.fn>; withCustomLoading: ReturnType<typeof vi.fn>;
}; };
let cartServiceStub: {
cart: ReturnType<typeof signal>;
isUpdating: ReturnType<typeof signal<boolean>>;
loadCart: ReturnType<typeof vi.fn>;
clearCart: ReturnType<typeof vi.fn>;
};
let routerStub: { navigate: ReturnType<typeof vi.fn> }; let routerStub: { navigate: ReturnType<typeof vi.fn> };
let toastServiceStub: { danger: ReturnType<typeof vi.fn> }; let toastServiceStub: { danger: ReturnType<typeof vi.fn> };
let globalLoadingServiceStub: {
start: ReturnType<typeof vi.fn>;
stop: 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<
@@ -65,10 +58,6 @@ describe('CheckoutPageComponent payment validation', () => {
total: '0.00', total: '0.00',
}), }),
updateCustomerData: vi.fn(), updateCustomerData: vi.fn(),
updateItemQuantity: vi.fn(),
updateItemVariant: vi.fn(),
removeItem: vi.fn(),
prepareItemEditing: vi.fn(),
cancelPurchase: vi.fn().mockResolvedValue({ status: 'cancelled' }), cancelPurchase: vi.fn().mockResolvedValue({ status: 'cancelled' }),
generatePaymentIntent: vi.fn().mockResolvedValue({ generatePaymentIntent: vi.fn().mockResolvedValue({
qr_data: { qr_code: 'qr-value' }, qr_data: { qr_code: 'qr-value' },
@@ -78,29 +67,9 @@ describe('CheckoutPageComponent payment validation', () => {
withCustomLoading: vi.fn(), withCustomLoading: vi.fn(),
}; };
checkoutServiceStub.withCustomLoading.mockReturnValue(checkoutServiceStub); checkoutServiceStub.withCustomLoading.mockReturnValue(checkoutServiceStub);
cartServiceStub = {
cart: signal({
id: 10,
tenant_codigo: 'tenant-test',
status: 'active',
items: [],
subtotal: '0.00',
}),
isUpdating: signal(false),
loadCart: vi.fn().mockReturnValue(of({})),
clearCart: vi.fn(),
};
cartServiceStub.clearCart.mockImplementation(() => {
cartServiceStub.cart.set({
id: null,
tenant_codigo: 'tenant-test',
status: 'active',
items: [],
subtotal: '0.00',
});
});
routerStub = { navigate: vi.fn() }; routerStub = { navigate: vi.fn() };
toastServiceStub = { danger: vi.fn() }; toastServiceStub = { danger: vi.fn() };
globalLoadingServiceStub = { start: vi.fn(), stop: vi.fn() };
routeQueryParamMap = convertToParamMap({}); routeQueryParamMap = convertToParamMap({});
authUserState = signal(null); authUserState = signal(null);
tenantState = signal({ tenantState = signal({
@@ -119,9 +88,9 @@ describe('CheckoutPageComponent payment validation', () => {
providers: [ providers: [
{ provide: CheckoutService, useValue: checkoutServiceStub }, { provide: CheckoutService, useValue: checkoutServiceStub },
{ provide: CatalogService, useValue: { getCatalogItem: vi.fn() } }, { provide: CatalogService, useValue: { getCatalogItem: vi.fn() } },
{ provide: CartService, useValue: cartServiceStub },
{ provide: TenantService, useValue: { tenant: tenantState } }, { provide: TenantService, useValue: { tenant: tenantState } },
{ provide: AuthService, useValue: { user: authUserState } }, { provide: AuthService, useValue: { user: authUserState } },
{ provide: GlobalLoadingService, useValue: globalLoadingServiceStub },
{ provide: ToastService, useValue: toastServiceStub }, { provide: ToastService, useValue: toastServiceStub },
{ {
provide: ActivatedRoute, provide: ActivatedRoute,
@@ -168,7 +137,6 @@ describe('CheckoutPageComponent payment validation', () => {
await vi.advanceTimersByTimeAsync(5_000); await vi.advanceTimersByTimeAsync(5_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(2); expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(2);
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]); expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
expect(checkoutServiceStub.generatePaymentIntent).toHaveBeenCalledTimes(1); expect(checkoutServiceStub.generatePaymentIntent).toHaveBeenCalledTimes(1);
}); });
@@ -240,7 +208,6 @@ describe('CheckoutPageComponent payment validation', () => {
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(4); expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(4);
expect(checkoutServiceStub.getPurchase).toHaveBeenLastCalledWith('tenant-test', 25); expect(checkoutServiceStub.getPurchase).toHaveBeenLastCalledWith('tenant-test', 25);
expect(component.transferValidationStatus()).toBe('error'); expect(component.transferValidationStatus()).toBe('error');
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(routerStub.navigate).not.toHaveBeenCalled(); expect(routerStub.navigate).not.toHaveBeenCalled();
}); });
@@ -253,7 +220,6 @@ describe('CheckoutPageComponent payment validation', () => {
await vi.advanceTimersByTimeAsync(3_000); await vi.advanceTimersByTimeAsync(3_000);
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledWith('tenant-test', 25); expect(checkoutServiceStub.getPurchase).toHaveBeenCalledWith('tenant-test', 25);
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]); expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
}); });
@@ -268,7 +234,6 @@ describe('CheckoutPageComponent payment validation', () => {
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(4); expect(checkoutServiceStub.getPurchase).toHaveBeenCalledTimes(4);
expect(component.transferValidationStatus()).toBe('error'); expect(component.transferValidationStatus()).toBe('error');
expect(cartServiceStub.clearCart).not.toHaveBeenCalled();
expect(routerStub.navigate).not.toHaveBeenCalled(); expect(routerStub.navigate).not.toHaveBeenCalled();
}); });
@@ -477,83 +442,30 @@ describe('CheckoutPageComponent payment validation', () => {
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]); expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout/status', 25]);
}); });
it('updates a purchase item while editing and refreshes checkout totals', async () => { it('shows the API error in a toast when cancelling the purchase fails', async () => {
const updatedPurchase = { const message = 'La compra venció. Iniciá una nueva compra.';
id: 25, checkoutServiceStub.cancelPurchase.mockRejectedValue({
items: [], error: { code: 'purchase.expired', message },
subtotal: '300.00', });
total: '300.00',
};
checkoutServiceStub.updateItemQuantity.mockResolvedValue(updatedPurchase);
const { component } = createComponent(); const { component } = createComponent();
component.isEditingItems.set(true);
await component.onPurchaseItemQuantityChange({ await component.onModifyPurchase();
item: {
cartItemId: 91, expect(toastServiceStub.danger).toHaveBeenCalledWith(message);
imageUrl: null, expect(routerStub.navigate).not.toHaveBeenCalled();
product: 'Remera', expect(globalLoadingServiceStub.start).toHaveBeenCalledOnce();
originalPrice: null, expect(globalLoadingServiceStub.stop).toHaveBeenCalledOnce();
discountedPrice: 100, expect(component.isCancellingPurchase()).toBe(false);
discountPercentage: null,
attributes: [],
quantity: 2,
},
quantity: 3,
}); });
expect(checkoutServiceStub.updateItemQuantity).toHaveBeenCalledWith( it('cancels the current purchase and opens the active cart when Modificar is clicked', async () => {
'tenant-test', let finishNavigation!: (navigated: boolean) => void;
25, routerStub.navigate.mockReturnValue(
91, new Promise<boolean>((resolve) => {
3, finishNavigation = resolve;
null, }),
'purchase',
); );
expect(component.createdPurchase()).toBe(updatedPurchase);
expect(component.isUpdatingItem()).toBe(false);
});
it('keeps the payment step selected while editing and regenerates payment afterward', async () => {
const editablePurchase = {
id: 25,
status: 'created',
items: [],
subtotal: '100.00',
total: '100.00',
};
checkoutServiceStub.prepareItemEditing.mockResolvedValue(editablePurchase);
const { component } = createComponent(); const { component } = createComponent();
component.stepper = { currentStepIndex: signal(1) };
const selectPaymentMethod = vi
.spyOn(component, 'selectPaymentMethod')
.mockResolvedValue(undefined);
await component.onEditingItemsChange(true);
expect(checkoutServiceStub.prepareItemEditing).toHaveBeenCalledWith('tenant-test', 25);
expect(component.isEditingItems()).toBe(true);
expect(component.createdPurchase()).toBe(editablePurchase);
await component.onEditingItemsChange(false);
expect(component.stepper.currentStepIndex()).toBe(1);
expect(selectPaymentMethod).toHaveBeenCalledWith('qr');
expect(component.isEditingItems()).toBe(false);
});
it('shows Modificar and returns to the home cart when checkout editing is disabled', async () => {
tenantState.set({
codigo: 'tenant-test',
checkout_editing_policy: {
code: 'disabled',
allow_modify: false,
allow_delete: false,
allow_update_quantity: false,
allow_update_variant: false,
},
});
const { fixture, component } = createComponent();
component.createdPurchase.set({ component.createdPurchase.set({
id: 25, id: 25,
status: 'created', status: 'created',
@@ -577,23 +489,26 @@ describe('CheckoutPageComponent payment validation', () => {
subtotal: '200.00', subtotal: '200.00',
total: '200.00', total: '200.00',
}); });
fixture.detectChanges(); const modification = component.onModifyPurchase();
await Promise.resolve();
await Promise.resolve();
const modifyButton = (fixture.nativeElement as HTMLElement).querySelector('.cart-edit-btn'); expect(checkoutServiceStub.cancelPurchase).toHaveBeenCalledWith('tenant-test', 25);
expect(modifyButton?.textContent?.trim()).toBe('Modificar'); expect(globalLoadingServiceStub.start).toHaveBeenCalledOnce();
expect( expect(globalLoadingServiceStub.stop).not.toHaveBeenCalled();
(fixture.nativeElement as HTMLElement).querySelector('app-quantity-selector'), expect(component.createdPurchaseId()).toBeNull();
).toBeNull(); expect(component.createdPurchase()).toBeNull();
await component.onEditingItemsChange(true);
expect(component.canUpdateCartQuantity()).toBe(false);
expect(component.canModifyCart()).toBe(false);
expect(component.isEditingItems()).toBe(false);
expect(checkoutServiceStub.prepareItemEditing).not.toHaveBeenCalled();
expect(routerStub.navigate).toHaveBeenCalledWith(['/'], { expect(routerStub.navigate).toHaveBeenCalledWith(['/'], {
queryParams: { openCart: true }, queryParams: { openCart: true },
}); });
finishNavigation(true);
await modification;
expect(globalLoadingServiceStub.stop).toHaveBeenCalledOnce();
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 () => {
@@ -630,14 +545,26 @@ describe('CheckoutPageComponent payment validation', () => {
expect(component.stepper.next).toHaveBeenCalledOnce(); expect(component.stepper.next).toHaveBeenCalledOnce();
}); });
it('keeps the checkout purchase intact when navigating away', async () => { it('cancels the current purchase when navigating away from checkout', async () => {
const { component } = createComponent(); const { component } = createComponent();
await expect(component.canDeactivate()).resolves.toBe(true); await expect(component.canDeactivate()).resolves.toBe(true);
expect(checkoutServiceStub.cancelPurchase).not.toHaveBeenCalled(); expect(checkoutServiceStub.cancelPurchase).toHaveBeenCalledWith('tenant-test', 25);
expect(cartServiceStub.loadCart).toHaveBeenCalled(); expect(component.createdPurchaseId()).toBeNull();
expect(cartServiceStub.clearCart).not.toHaveBeenCalled(); expect(globalLoadingServiceStub.start).toHaveBeenCalledOnce();
expect(globalLoadingServiceStub.stop).toHaveBeenCalledOnce();
});
it('prevents leaving checkout when the purchase cannot be cancelled', async () => {
checkoutServiceStub.cancelPurchase.mockRejectedValue({
error: { message: 'No se pudo cancelar la compra.' },
});
const { component } = createComponent();
await expect(component.canDeactivate()).resolves.toBe(false);
expect(toastServiceStub.danger).toHaveBeenCalledWith('No se pudo cancelar la compra.');
expect(component.createdPurchaseId()).toBe(25); expect(component.createdPurchaseId()).toBe(25);
}); });

View File

@@ -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 { firstValueFrom, startWith } from 'rxjs'; import { startWith } from 'rxjs';
import { TenantService } from '../../../../core/services/tenant.service'; import { TenantService } from '../../../../core/services/tenant.service';
import { import {
@@ -20,9 +20,8 @@ import {
PurchaseDetailResponse, PurchaseDetailResponse,
} from '../../../../core/services/checkout.service'; } from '../../../../core/services/checkout.service';
import { AuthService } from '../../../../core/services/auth/auth.service'; import { AuthService } from '../../../../core/services/auth/auth.service';
import { CartService } from '../../../../core/services/cart/cart.service'; import { GlobalLoadingService } from '../../../../core/services/global-loading/global-loading.service';
import { ToastService } from '../../../../core/services/toast.service'; import { ToastService } from '../../../../core/services/toast.service';
import { BankAccount } from '../../../../core/services/tenant.interface';
import { CartComponent, CartItemMock } from '../../../../shared/components/cart/cart.component'; import { 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';
@@ -65,9 +64,9 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
private readonly tenantService = inject(TenantService); private readonly tenantService = inject(TenantService);
private readonly checkoutService = inject(CheckoutService); private readonly checkoutService = inject(CheckoutService);
private readonly authService = inject(AuthService); private readonly authService = inject(AuthService);
private readonly cartService = inject(CartService); private readonly globalLoadingService = inject(GlobalLoadingService);
private readonly qrPollingIntervalMs = 5_000;
private readonly toastService = inject(ToastService); private readonly toastService = inject(ToastService);
private readonly qrPollingIntervalMs = 5_000;
private readonly qrPollingMaxAttempts = 9; private readonly qrPollingMaxAttempts = 9;
private readonly transferPollingIntervalMs = 3_000; private readonly transferPollingIntervalMs = 3_000;
private readonly transferPollingMaxAttempts = 4; private readonly transferPollingMaxAttempts = 4;
@@ -80,6 +79,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
private transferPollingRunId = 0; private transferPollingRunId = 0;
private paymentMethodRequestId = 0; private paymentMethodRequestId = 0;
private navigationStarted = false; private navigationStarted = false;
private cancelPurchasePromise: Promise<boolean> | null = null;
@ViewChild(StepperComponent) stepper!: StepperComponent; @ViewChild(StepperComponent) stepper!: StepperComponent;
@@ -93,19 +93,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 canUpdateCartQuantity = computed(
() => this.tenantService.tenant()?.checkout_editing_policy?.allow_update_quantity ?? false,
);
protected readonly canUpdateCartVariant = computed(
() => this.tenantService.tenant()?.checkout_editing_policy?.allow_update_variant ?? false,
);
protected readonly canDeleteCartItems = computed(
() => this.tenantService.tenant()?.checkout_editing_policy?.allow_delete ?? false,
);
protected readonly canModifyCart = computed(
() => this.tenantService.tenant()?.checkout_editing_policy?.allow_modify ?? false,
);
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;
@@ -134,9 +121,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
protected readonly transferDni = signal<string>(''); protected readonly transferDni = signal<string>('');
protected readonly isUpdatingPurchase = signal(false); protected readonly isUpdatingPurchase = signal(false);
protected readonly isEditingItems = signal(false); protected readonly isCancellingPurchase = signal(false);
protected readonly isUpdatingItem = signal(false);
protected readonly isPreparingItemEdit = signal(false);
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());
@@ -198,191 +183,29 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
})), })),
quantity: item.quantity, quantity: item.quantity,
variantId: item.source_variant_id, variantId: item.source_variant_id,
variants: item.variants,
}; };
} }
protected async onEditingItemsChange(editing: boolean): Promise<void> { protected async onModifyPurchase(): Promise<void> {
if (this.isUpdatingItem() || this.isPreparingItemEdit()) { if (this.isCancellingPurchase()) {
return; return;
} }
if (editing && !this.canModifyCart()) { this.globalLoadingService.start();
const tenant = this.tenantService.tenant();
const purchaseId = this.createdPurchaseId();
if (!tenant || !purchaseId) {
return;
}
this.isPreparingItemEdit.set(true);
try { try {
await this.checkoutService.cancelPurchase(tenant.codigo, purchaseId); const cancelled = await this.cancelCurrentPurchase();
this.createdPurchaseId.set(null); if (!cancelled) return;
this.createdPurchase.set(null);
await firstValueFrom(this.cartService.loadCart());
await this.router.navigate(['/'], { await this.router.navigate(['/'], {
queryParams: { openCart: true }, queryParams: { openCart: true },
}); });
} catch (error) {
this.handleCheckoutError(error, 'No se pudo restaurar el carrito para editarlo.');
} finally { } finally {
this.isPreparingItemEdit.set(false); this.globalLoadingService.stop();
}
return;
}
if (!editing) {
this.isEditingItems.set(false);
if (this.stepper?.currentStepIndex() === 1) {
void this.selectPaymentMethod(this.selectedPaymentMethod());
}
return;
}
this.isEditingItems.set(true);
this.stopQrPolling();
this.stopTransferPolling();
this.qrData.set(null);
this.qrPaymentStatus.set('idle');
this.transferAccount.set(null);
this.transferValidationStatus.set('idle');
const tenant = this.tenantService.tenant();
const purchaseId = this.createdPurchaseId();
if (!tenant || !purchaseId) {
this.isEditingItems.set(false);
return;
}
this.isPreparingItemEdit.set(true);
try {
const purchase = await this.checkoutService.prepareItemEditing(tenant.codigo, purchaseId);
this.createdPurchase.set(purchase);
} catch (error) {
this.handleCheckoutError(error, 'No se pudo preparar la compra para editarla.');
this.isEditingItems.set(false);
} finally {
this.isPreparingItemEdit.set(false);
}
}
protected async onPurchaseItemQuantityChange(event: {
item: CartItemMock;
quantity: number;
}): Promise<void> {
const tenant = this.tenantService.tenant();
const purchaseId = this.createdPurchaseId();
const itemId = event.item.cartItemId;
if (
!this.canUpdateCartQuantity() ||
!tenant ||
!purchaseId ||
!itemId ||
this.isUpdatingItem() ||
!this.isEditingItems()
) {
return;
}
this.isUpdatingItem.set(true);
try {
const purchase = await this.checkoutService.updateItemQuantity(
tenant.codigo,
purchaseId,
itemId,
event.quantity,
this.createdPurchase()?.cart_id ?? null,
this.createdPurchase()?.items_source ?? 'purchase',
);
this.createdPurchase.set(purchase);
} catch (error) {
this.handleCheckoutError(error, 'No se pudo actualizar la cantidad del producto.');
} finally {
this.isUpdatingItem.set(false);
}
}
protected async onPurchaseItemVariantChange(event: {
item: CartItemMock;
variantId: number;
}): Promise<void> {
const tenant = this.tenantService.tenant();
const purchase = this.createdPurchase();
const itemId = event.item.cartItemId;
if (
!this.canUpdateCartVariant() ||
!tenant ||
!purchase ||
!itemId ||
this.isUpdatingItem() ||
!this.isEditingItems()
) {
return;
}
this.isUpdatingItem.set(true);
try {
this.createdPurchase.set(
await this.checkoutService.updateItemVariant(
tenant.codigo,
purchase.id,
itemId,
event.variantId,
event.item.quantity,
purchase.cart_id,
purchase.items_source,
),
);
} catch (error) {
this.handleCheckoutError(error, 'No se pudo actualizar la variante del producto.');
} finally {
this.isUpdatingItem.set(false);
}
}
protected async onPurchaseItemRemove(event: { item: CartItemMock }): Promise<void> {
const tenant = this.tenantService.tenant();
const purchase = this.createdPurchase();
const itemId = event.item.cartItemId;
if (
!this.canDeleteCartItems() ||
!tenant ||
!purchase ||
!itemId ||
this.isUpdatingItem() ||
!this.isEditingItems()
) {
return;
}
this.isUpdatingItem.set(true);
try {
this.createdPurchase.set(
await this.checkoutService.removeItem(
tenant.codigo,
purchase.id,
itemId,
purchase.cart_id,
purchase.items_source,
),
);
} catch (error) {
this.handleCheckoutError(error, 'No se pudo eliminar el producto de la compra.');
} finally {
this.isUpdatingItem.set(false);
} }
} }
protected async onStep1Continue(): Promise<void> { protected async onStep1Continue(): Promise<void> {
if (this.form.invalid || this.isUpdatingPurchase() || this.isEditingItems()) return; if (this.form.invalid || this.isUpdatingPurchase()) return;
const tenant = this.tenantService.tenant(); const tenant = this.tenantService.tenant();
const purchaseId = this.createdPurchaseId(); const purchaseId = this.createdPurchaseId();
@@ -405,39 +228,76 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
// Auto trigger intent for default option // Auto trigger intent for default option
void this.selectPaymentMethod(this.selectedPaymentMethod()); void this.selectPaymentMethod(this.selectedPaymentMethod());
} catch (error) { } catch (error) {
this.handleCheckoutError(error, 'No se pudieron actualizar los datos de la compra.'); console.error('Failed to create purchase:', error);
this.showRequestError(error, 'No se pudieron actualizar los datos de la compra.');
} finally { } finally {
this.isUpdatingPurchase.set(false); this.isUpdatingPurchase.set(false);
} }
} }
protected async onCancel(): Promise<void> { protected async onCancel(): Promise<void> {
if (await this.canDeactivate()) {
void this.router.navigate(['/']); void this.router.navigate(['/']);
} }
}
public async canDeactivate(): Promise<boolean> { public async canDeactivate(): Promise<boolean> {
this.stopQrPolling(); this.stopQrPolling();
this.stopTransferPolling(); this.stopTransferPolling();
if (this.cancelPurchasePromise) {
return this.cancelPurchasePromise;
}
if (this.navigationStarted) { if (this.navigationStarted) {
return true; return true;
} }
// The checkout cart remains attached to its purchase. Once the user adds a this.globalLoadingService.start();
// new item, the cart API creates a separate active cart automatically.
try { try {
await firstValueFrom(this.cartService.loadCart()); return await this.cancelCurrentPurchase();
} catch (error) { } finally {
console.error('Failed to load the active cart after leaving checkout:', error); this.globalLoadingService.stop();
}
} }
private cancelCurrentPurchase(): Promise<boolean> {
if (this.cancelPurchasePromise) {
return this.cancelPurchasePromise;
}
this.cancelPurchasePromise = this.performPurchaseCancellation().finally(() => {
this.cancelPurchasePromise = null;
});
return this.cancelPurchasePromise;
}
private async performPurchaseCancellation(): Promise<boolean> {
const tenant = this.tenantService.tenant();
const purchaseId = this.createdPurchaseId();
if (!tenant || !purchaseId) {
return true; return true;
} }
this.isCancellingPurchase.set(true);
try {
await this.checkoutService.cancelPurchase(tenant.codigo, purchaseId);
this.createdPurchaseId.set(null);
this.createdPurchase.set(null);
this.navigationStarted = true;
return true;
} catch (error) {
console.error('Failed to cancel the current purchase:', error);
this.showRequestError(error, 'No se pudo cancelar la compra.');
return false;
} finally {
this.isCancellingPurchase.set(false);
}
}
protected async selectPaymentMethod(method: PaymentMethod): Promise<void> { protected async selectPaymentMethod(method: PaymentMethod): Promise<void> {
if (this.navigationStarted || this.isEditingItems()) { if (this.navigationStarted) {
return; return;
} }
@@ -481,17 +341,14 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
this.startQrPolling(); this.startQrPolling();
} }
} catch (error) { } catch (error) {
this.handleCheckoutError(error, 'No se pudo generar la intenci\u00f3n de pago.'); console.error('Failed to generate payment intent:', error);
this.showRequestError(error, 'No se pudo generar el pago.');
} finally { } finally {
this.isGeneratingIntent.set(false); this.isGeneratingIntent.set(false);
} }
} }
protected async generateTransferIntent(dni: string): Promise<void> { protected async generateTransferIntent(dni: string): Promise<void> {
if (this.isEditingItems()) {
return;
}
const purchaseId = this.createdPurchaseId(); const purchaseId = this.createdPurchaseId();
const tenant = this.tenantService.tenant(); const tenant = this.tenantService.tenant();
@@ -518,7 +375,8 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
}); });
} }
} catch (error) { } catch (error) {
this.handleCheckoutError(error, 'No se pudo generar la intenci\u00f3n de transferencia.'); console.error('Failed to generate transfer payment intent:', error);
this.showRequestError(error, 'No se pudo generar el pago por transferencia.');
} finally { } finally {
this.isGeneratingIntent.set(false); this.isGeneratingIntent.set(false);
} }
@@ -549,7 +407,6 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
!purchaseId || !purchaseId ||
!tenant || !tenant ||
this.navigationStarted || this.navigationStarted ||
this.isEditingItems() ||
this.transferValidationStatus() === 'checking' this.transferValidationStatus() === 'checking'
) { ) {
return; return;
@@ -579,7 +436,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
} catch (error) { } catch (error) {
const expired = this.handleCheckoutError( const expired = this.handleCheckoutError(
error, error,
'No se pudo enviar la compra a revisi\u00f3n.', 'No se pudo enviar el pago para su validación.',
); );
if (!expired && runId === this.transferPollingRunId) { if (!expired && runId === this.transferPollingRunId) {
@@ -771,6 +628,8 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
return; return;
} }
this.createdPurchaseId.set(purchaseId);
try { try {
const purchase = await this.checkoutService const purchase = await this.checkoutService
.withCustomLoading() .withCustomLoading()
@@ -811,30 +670,41 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
} }
} catch (error) { } catch (error) {
console.error('Failed to load purchase:', error); console.error('Failed to load purchase:', error);
this.showRequestError(error, 'No se pudo cargar la compra.');
void this.router.navigate(['/']); void this.router.navigate(['/']);
} }
} }
private showRequestError(error: unknown, fallbackMessage: string): void {
const payload =
typeof error === 'object' && error !== null && 'error' in error
? (error as { error?: { message?: unknown } }).error
: undefined;
const message =
typeof payload?.message === 'string' && payload.message.trim()
? payload.message
: fallbackMessage;
this.toastService.danger(message);
}
private handleCheckoutError(error: unknown, fallbackMessage: string): boolean { private handleCheckoutError(error: unknown, fallbackMessage: string): boolean {
if (!(error instanceof HttpErrorResponse)) { if (!(error instanceof HttpErrorResponse)) {
this.toastService.danger(fallbackMessage); this.toastService.danger(fallbackMessage);
return false; return false;
} }
const response = error.error as ApiErrorResponse | null; const response = error.error as ApiErrorResponse | null;
if (response?.code === 'purchase.expired' || this.hasExpiredPurchase()) { if (response?.code === 'purchase.expired' || this.hasExpiredPurchase()) {
this.navigationStarted = true;
this.stopQrPolling(); this.stopQrPolling();
this.stopTransferPolling(); this.stopTransferPolling();
this.toastService.danger( this.toastService.danger(
response?.code === 'purchase.expired' && response.message response?.code === 'purchase.expired' && response.message
? response.message ? response.message
: 'La compra venci\u00f3. Inici\u00e1 una nueva compra.', : 'La compra venció. Iniciá una nueva compra.',
); );
void this.router.navigate(['/']); void this.router.navigate(['/']);
return true; return true;
} }
@@ -843,7 +713,6 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
: undefined; : undefined;
this.toastService.danger(validationMessage ?? response?.message ?? fallbackMessage); this.toastService.danger(validationMessage ?? response?.message ?? fallbackMessage);
return false; return false;
} }

View File

@@ -9,6 +9,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { CatalogItemDetail } from '../../../../core/services/catalog/catalog.interface'; import { CatalogItemDetail } from '../../../../core/services/catalog/catalog.interface';
import { CatalogService } from '../../../../core/services/catalog/catalog.service'; import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { CatalogAvailabilityService } from '../../../../core/services/catalog/catalog-availability.service';
import { CartService } from '../../../../core/services/cart/cart.service'; import { CartService } from '../../../../core/services/cart/cart.service';
import { ToastService } from '../../../../core/services/toast.service'; import { ToastService } from '../../../../core/services/toast.service';
import { ProductDetailPageComponent } from './product-detail-page.component'; import { ProductDetailPageComponent } from './product-detail-page.component';
@@ -176,6 +177,21 @@ describe('ProductDetailPageComponent', () => {
expect(element.querySelector('.product-carousel__discount-badge')).toBeNull(); expect(element.querySelector('.product-carousel__discount-badge')).toBeNull();
}); });
it('reloads product availability when the cart changes', async () => {
catalogServiceStub.getCatalogItem.mockReturnValue(
of({ ...mockProduct, maximum_addable_quantity: 4 }),
);
await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent);
fixture.detectChanges();
TestBed.inject(CatalogAvailabilityService).notifyAvailabilityChanged();
fixture.detectChanges();
expect(catalogServiceStub.getCatalogItem).toHaveBeenCalledWith(1, undefined);
expect(fixture.componentInstance['selectedVariantMax']()).toBe(4);
});
it('shows error message if the resolver cannot load the product', async () => { it('shows error message if the resolver cannot load the product', async () => {
resolveProductError(PRODUCT_DETAIL_ERROR_MESSAGE); resolveProductError(PRODUCT_DETAIL_ERROR_MESSAGE);
await configureTestingModule(); await configureTestingModule();
@@ -510,9 +526,9 @@ describe('ProductDetailPageComponent', () => {
await configureTestingModule(); await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent); const fixture = TestBed.createComponent(ProductDetailPageComponent);
fixture.detectChanges(); fixture.detectChanges();
const buyButton = Array.from(fixture.nativeElement.querySelectorAll('app-button button')).find( const buyButton = Array.from(
(button) => button.textContent?.trim() === 'Comprar', fixture.nativeElement.querySelectorAll('app-button button') as NodeListOf<HTMLButtonElement>,
) as HTMLButtonElement; ).find((button) => button.textContent?.trim() === 'Comprar') as HTMLButtonElement;
buyButton.click(); buyButton.click();
await Promise.resolve(); await Promise.resolve();

View File

@@ -17,6 +17,7 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { CatalogService } from '../../../../core/services/catalog/catalog.service'; import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { CatalogAvailabilityService } from '../../../../core/services/catalog/catalog-availability.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 { import {
@@ -51,6 +52,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly catalogService = inject(CatalogService); private readonly catalogService = inject(CatalogService);
private readonly catalogAvailabilityService = inject(CatalogAvailabilityService);
private readonly toastService = inject(ToastService); private readonly toastService = inject(ToastService);
private readonly cartService = inject(CartService); private readonly cartService = inject(CartService);
private readonly checkoutService = inject(CheckoutService); private readonly checkoutService = inject(CheckoutService);
@@ -64,6 +66,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
private routeSub: Subscription | null = null; private routeSub: Subscription | null = null;
private productSub: Subscription | null = null; private productSub: Subscription | null = null;
private availabilityChangedSub: Subscription | null = null;
private carouselResizeObserver: ResizeObserver | null = null; private carouselResizeObserver: ResizeObserver | null = null;
private observedCarouselPreview: HTMLElement | null = null; private observedCarouselPreview: HTMLElement | null = null;
private measurementTimer: ReturnType<typeof setTimeout> | null = null; private measurementTimer: ReturnType<typeof setTimeout> | null = null;
@@ -150,6 +153,10 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
} }
ngOnInit(): void { ngOnInit(): void {
this.availabilityChangedSub = this.catalogAvailabilityService.availabilityChanged$.subscribe(
() => this.refreshProductAvailability(),
);
this.routeSub = this.route.data.subscribe((data) => { this.routeSub = this.route.data.subscribe((data) => {
const resolvedData = data['productDetailData'] as ProductDetailResolvedData | undefined; const resolvedData = data['productDetailData'] as ProductDetailResolvedData | undefined;
@@ -160,6 +167,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this.availabilityChangedSub?.unsubscribe();
this.routeSub?.unsubscribe(); this.routeSub?.unsubscribe();
this.productSub?.unsubscribe(); this.productSub?.unsubscribe();
this.carouselResizeObserver?.disconnect(); this.carouselResizeObserver?.disconnect();
@@ -196,6 +204,32 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
}); });
} }
private refreshProductAvailability(): void {
const currentProduct = this.product();
if (!currentProduct) {
return;
}
const variantId = this.selectedVariant()?.id;
this.productSub?.unsubscribe();
this.productSub = this.catalogService
.withCustomLoading()
.getCatalogItem(currentProduct.id, variantId)
.subscribe({
next: (product) => {
this.applyProduct(product, false);
const maximum = this.selectedVariantMax();
if (maximum !== null && this.quantity() > maximum) {
this.quantity.set(Math.max(1, maximum));
}
},
error: () => {
// Keep the last known availability if the silent refresh fails.
},
});
}
private applyResolvedData(resolvedData: ProductDetailResolvedData): void { private applyResolvedData(resolvedData: ProductDetailResolvedData): void {
this.productSub?.unsubscribe(); this.productSub?.unsubscribe();
this.loading.set(false); this.loading.set(false);

View File

@@ -15,6 +15,7 @@ import {
describe('productDetailResolver', () => { describe('productDetailResolver', () => {
const product: CatalogItemDetail = { const product: CatalogItemDetail = {
id: 1, id: 1,
type: 'product',
category_id: 10, category_id: 10,
brand_id: null, brand_id: null,
slug: 'auriculares-bluetooth', slug: 'auriculares-bluetooth',

View File

@@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CartService } from '../../../../core/services/cart/cart.service'; import { CartService } from '../../../../core/services/cart/cart.service';
import { CatalogService } from '../../../../core/services/catalog/catalog.service'; import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { CatalogAvailabilityService } from '../../../../core/services/catalog/catalog-availability.service';
import { Tenant } from '../../../../core/services/tenant.interface'; import { Tenant } from '../../../../core/services/tenant.interface';
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';
@@ -108,6 +109,16 @@ describe('SearchPageComponent', () => {
); );
}); });
it('repeats the current search when catalog availability changes', () => {
const fixture = TestBed.createComponent(SearchPageComponent);
fixture.detectChanges();
TestBed.inject(CatalogAvailabilityService).notifyAvailabilityChanged();
expect(searchCatalog).toHaveBeenCalledTimes(2);
expect(searchCatalog).toHaveBeenLastCalledWith({ q: 'running', page: 1 });
});
it('renders the search title and query subtitle with the category header layout', () => { it('renders the search title and query subtitle with the category header layout', () => {
const fixture = TestBed.createComponent(SearchPageComponent); const fixture = TestBed.createComponent(SearchPageComponent);
fixture.detectChanges(); fixture.detectChanges();

View File

@@ -9,7 +9,17 @@ import {
signal, signal,
} from '@angular/core'; } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { catchError, distinctUntilChanged, finalize, map, of, switchMap, tap } from 'rxjs'; import {
catchError,
combineLatest,
distinctUntilChanged,
finalize,
map,
of,
startWith,
switchMap,
tap,
} from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CartService } from '../../../../core/services/cart/cart.service'; import { CartService } from '../../../../core/services/cart/cart.service';
@@ -22,6 +32,7 @@ import {
CatalogProductLayout, CatalogProductLayout,
} from '../../../../core/services/catalog/catalog.interface'; } from '../../../../core/services/catalog/catalog.interface';
import { CatalogService } from '../../../../core/services/catalog/catalog.service'; import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { CatalogAvailabilityService } from '../../../../core/services/catalog/catalog-availability.service';
import { ApiPaginatedResponse } from '../../../../core/services/api-paginated-response.interface'; import { ApiPaginatedResponse } from '../../../../core/services/api-paginated-response.interface';
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';
@@ -46,6 +57,7 @@ interface SearchRouteState {
export class SearchPageComponent { export class SearchPageComponent {
private readonly minSearchLength = 3; private readonly minSearchLength = 3;
private readonly cartService = inject(CartService); private readonly cartService = inject(CartService);
private readonly catalogAvailabilityService = inject(CatalogAvailabilityService);
private readonly catalogService = inject(CatalogService); private readonly catalogService = inject(CatalogService);
private readonly injector = inject(Injector); private readonly injector = inject(Injector);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
@@ -86,8 +98,7 @@ export class SearchPageComponent {
}); });
constructor() { constructor() {
this.route.queryParamMap const routeState$ = this.route.queryParamMap.pipe(
.pipe(
map( map(
(params): SearchRouteState => ({ (params): SearchRouteState => ({
query: params.get('q')?.trim() ?? '', query: params.get('q')?.trim() ?? '',
@@ -106,6 +117,14 @@ export class SearchPageComponent {
: `Ingresá al menos ${this.minSearchLength} caracteres para buscar.`, : `Ingresá al menos ${this.minSearchLength} caracteres para buscar.`,
); );
}), }),
);
combineLatest([
routeState$,
this.catalogAvailabilityService.availabilityChanged$.pipe(startWith(undefined)),
])
.pipe(
map(([routeState]) => routeState),
switchMap(({ query, page }) => { switchMap(({ query, page }) => {
if (query.length < this.minSearchLength) { if (query.length < this.minSearchLength) {
return of(null); return of(null);

View File

@@ -12,6 +12,7 @@ import {
CatalogFeaturedItem, CatalogFeaturedItem,
} from '../../../../core/services/catalog/catalog.interface'; } from '../../../../core/services/catalog/catalog.interface';
import { CatalogService } from '../../../../core/services/catalog/catalog.service'; import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { CatalogAvailabilityService } from '../../../../core/services/catalog/catalog-availability.service';
import { Tenant } from '../../../../core/services/tenant.interface'; import { Tenant } from '../../../../core/services/tenant.interface';
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';
@@ -78,12 +79,14 @@ function provideActivatedRoute(catalogData: StoreHomeCatalogResolvedData) {
const pageOneItems: CatalogFeaturedItem[] = [ const pageOneItems: CatalogFeaturedItem[] = [
{ {
id: 1, id: 1,
type: 'product',
nombre: 'Auriculares Bluetooth', nombre: 'Auriculares Bluetooth',
precio: '24999.00', precio: '24999.00',
image: '/catalog/auriculares.jpg', image: '/catalog/auriculares.jpg',
}, },
{ {
id: 2, id: 2,
type: 'product',
nombre: 'Teclado Mecanico', nombre: 'Teclado Mecanico',
precio: '18999.00', precio: '18999.00',
image: null, image: null,
@@ -159,6 +162,30 @@ describe('StoreHomePageComponent', () => {
); );
}); });
it('reloads the catalog when availability changes', async () => {
const refreshedCatalog = createCatalog();
const catalogServiceStub = {
getCatalog: vi.fn().mockReturnValue(of(refreshedCatalog)),
getFeaturedGroupItems: vi.fn(),
withCustomLoading: vi.fn(),
};
catalogServiceStub.withCustomLoading.mockReturnValue(catalogServiceStub);
await TestBed.configureTestingModule({
imports: [StoreHomePageComponent],
providers: [
provideActivatedRoute({ response: createCatalog(), error: null }),
{ provide: CatalogService, useValue: catalogServiceStub },
],
}).compileComponents();
const fixture = TestBed.createComponent(StoreHomePageComponent);
fixture.detectChanges();
TestBed.inject(CatalogAvailabilityService).notifyAvailabilityChanged();
expect(catalogServiceStub.getCatalog).toHaveBeenCalledOnce();
});
it('renders the carousel URLs received in tenant extras', async () => { it('renders the carousel URLs received in tenant extras', async () => {
const carousel = ['https://example.com/carousel-1.webp', 'https://example.com/carousel-2.webp']; const carousel = ['https://example.com/carousel-1.webp', 'https://example.com/carousel-2.webp'];
@@ -378,7 +405,9 @@ describe('StoreHomePageComponent', () => {
}); });
it('requests another page for the selected featured group', async () => { it('requests another page for the selected featured group', async () => {
const pageTwoItems = [{ id: 3, nombre: 'Mouse Gamer', precio: '15999.00', image: null }]; const pageTwoItems: CatalogFeaturedItem[] = [
{ id: 3, type: 'product', nombre: 'Mouse Gamer', precio: '15999.00', image: null },
];
const catalogServiceStub = { const catalogServiceStub = {
getCatalog: vi.fn(), getCatalog: vi.fn(),
getFeaturedGroupItems: vi.fn().mockReturnValue(of(createItemsPage(pageTwoItems, 2, 2))), getFeaturedGroupItems: vi.fn().mockReturnValue(of(createItemsPage(pageTwoItems, 2, 2))),

View File

@@ -15,6 +15,7 @@ import { finalize, Subscription } from 'rxjs';
import { CartService } from '../../../../core/services/cart/cart.service'; import { CartService } from '../../../../core/services/cart/cart.service';
import { AuthService } from '../../../../core/services/auth/auth.service'; import { AuthService } from '../../../../core/services/auth/auth.service';
import { CatalogFeaturedGroup } from '../../../../core/services/catalog/catalog.interface'; import { CatalogFeaturedGroup } from '../../../../core/services/catalog/catalog.interface';
import { CatalogAvailabilityService } from '../../../../core/services/catalog/catalog-availability.service';
import { CatalogService } from '../../../../core/services/catalog/catalog.service'; import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { TenantService } from '../../../../core/services/tenant.service'; import { TenantService } from '../../../../core/services/tenant.service';
import { ToastService } from '../../../../core/services/toast.service'; import { ToastService } from '../../../../core/services/toast.service';
@@ -49,6 +50,7 @@ import {
}) })
export class StoreHomePageComponent implements OnInit, OnDestroy { export class StoreHomePageComponent implements OnInit, OnDestroy {
private readonly cartService = inject(CartService); private readonly cartService = inject(CartService);
private readonly catalogAvailabilityService = inject(CatalogAvailabilityService);
private readonly catalogService = inject(CatalogService); private readonly catalogService = inject(CatalogService);
private readonly injector = inject(Injector); private readonly injector = inject(Injector);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
@@ -96,9 +98,13 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
protected readonly hasMainCarouselImages = computed(() => this.mainCarouselImages().length > 0); protected readonly hasMainCarouselImages = computed(() => this.mainCarouselImages().length > 0);
private catalogRequestSubscription: Subscription | null = null; private catalogRequestSubscription: Subscription | null = null;
private availabilityChangedSubscription: Subscription | null = null;
private readonly groupRequestSubscriptions = new Map<number, Subscription>(); private readonly groupRequestSubscriptions = new Map<number, Subscription>();
ngOnInit(): void { ngOnInit(): void {
this.availabilityChangedSubscription =
this.catalogAvailabilityService.availabilityChanged$.subscribe(() => this.loadCatalog(true));
const resolvedData = this.route.snapshot.data['catalogData'] as const resolvedData = this.route.snapshot.data['catalogData'] as
| StoreHomeCatalogResolvedData | StoreHomeCatalogResolvedData
| undefined; | undefined;
@@ -112,6 +118,7 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this.availabilityChangedSubscription?.unsubscribe();
this.catalogRequestSubscription?.unsubscribe(); this.catalogRequestSubscription?.unsubscribe();
this.groupRequestSubscriptions.forEach((subscription) => subscription.unsubscribe()); this.groupRequestSubscriptions.forEach((subscription) => subscription.unsubscribe());
} }
@@ -264,20 +271,27 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
}); });
} }
private loadCatalog(): void { private loadCatalog(silent = false): void {
if (!silent) {
this.loading.set(true); this.loading.set(true);
this.error.set(null); this.error.set(null);
}
this.catalogRequestSubscription?.unsubscribe(); this.catalogRequestSubscription?.unsubscribe();
this.catalogRequestSubscription = this.catalogService this.catalogRequestSubscription = this.catalogService
.withCustomLoading() .withCustomLoading()
.getCatalog() .getCatalog()
.subscribe({ .subscribe({
next: (catalog) => this.catalog.set(catalog), next: (catalog) => {
this.catalog.set(catalog);
this.error.set(null);
},
error: () => { error: () => {
if (!silent) {
this.catalog.set([]); this.catalog.set([]);
this.loading.set(false);
this.error.set(STORE_HOME_PRODUCTS_ERROR_MESSAGE); this.error.set(STORE_HOME_PRODUCTS_ERROR_MESSAGE);
}
this.loading.set(false);
}, },
complete: () => this.loading.set(false), complete: () => this.loading.set(false),
}); });

View File

@@ -9,17 +9,17 @@
@if ( @if (
allowModify() && allowModify() &&
(!readonly() || showModifyWhenReadonly()) && (!readonly() || showModifyWhenReadonly()) &&
requireEditingMode() && (requireEditingMode() || modifyAsAction()) &&
items().length > 0 items().length > 0
) { ) {
<button <button
class="btn btn-link p-0 border-0 cart-edit-btn" class="btn btn-link p-0 border-0 cart-edit-btn"
type="button" type="button"
[attr.aria-pressed]="editing()" [attr.aria-pressed]="modifyAsAction() ? null : editing()"
[disabled]="editingDisabled()" [disabled]="editingDisabled()"
(click)="toggleEditing()" (click)="toggleEditing()"
> >
{{ editing() ? 'Listo' : 'Modificar' }} {{ !modifyAsAction() && editing() ? 'Listo' : 'Modificar' }}
</button> </button>
} }

View File

@@ -367,6 +367,57 @@ describe('CartComponent', () => {
expect(editingChange).toHaveBeenLastCalledWith(false); expect(editingChange).toHaveBeenLastCalledWith(false);
}); });
it('emits Modificar as an action without toggling to Listo', async () => {
await TestBed.configureTestingModule({
imports: [CartComponent],
providers: [
{
provide: CartService,
useValue: {
cart: signal(null).asReadonly(),
updateItemQuantity: vi.fn(),
removeItem: vi.fn(),
},
},
{ provide: ModalService, useValue: {} },
{
provide: ToastService,
useValue: { success: vi.fn(), info: vi.fn(), danger: vi.fn() },
},
],
}).compileComponents();
const fixture = TestBed.createComponent(CartComponent);
fixture.componentRef.setInput('items', [
{
cartItemId: 10,
imageUrl: null,
product: 'Producto de prueba',
originalPrice: null,
discountedPrice: 1000,
discountPercentage: null,
attributes: [],
quantity: 1,
},
]);
fixture.componentRef.setInput('readonly', true);
fixture.componentRef.setInput('showModifyWhenReadonly', true);
fixture.componentRef.setInput('modifyAsAction', true);
const modify = vi.fn();
fixture.componentInstance.modify.subscribe(modify);
fixture.detectChanges();
const modifyButton = fixture.debugElement.query(By.css('.cart-edit-btn'));
expect(modifyButton.nativeElement.textContent.trim()).toBe('Modificar');
modifyButton.nativeElement.click();
fixture.detectChanges();
expect(modify).toHaveBeenCalledOnce();
expect(modifyButton.nativeElement.textContent.trim()).toBe('Modificar');
expect(fixture.componentInstance.editing()).toBe(false);
});
it('allows editing directly when the optional Modificar toggle is disabled', async () => { it('allows editing directly when the optional Modificar toggle is disabled', async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [CartComponent], imports: [CartComponent],

View File

@@ -56,6 +56,7 @@ export class CartComponent {
readonly allowUpdateQuantity = input<boolean>(true); readonly allowUpdateQuantity = input<boolean>(true);
readonly allowModify = input<boolean>(true); readonly allowModify = input<boolean>(true);
readonly showModifyWhenReadonly = input<boolean>(false); readonly showModifyWhenReadonly = input<boolean>(false);
readonly modifyAsAction = input<boolean>(false);
readonly requireEditingMode = input<boolean>(false); readonly requireEditingMode = input<boolean>(false);
readonly allowUpdateVariant = input<boolean>(true); readonly allowUpdateVariant = input<boolean>(true);
readonly allowDelete = input<boolean>(true); readonly allowDelete = input<boolean>(true);
@@ -66,6 +67,7 @@ export class CartComponent {
readonly editing = model<boolean>(false); readonly editing = model<boolean>(false);
readonly closed = output<void>(); readonly closed = output<void>();
readonly modify = output<void>();
readonly itemQuantityChange = output<{ readonly itemQuantityChange = output<{
item: CartItemMock; item: CartItemMock;
index: number; index: number;
@@ -257,6 +259,11 @@ export class CartComponent {
return; return;
} }
if (this.modifyAsAction()) {
this.modify.emit();
return;
}
const editing = !this.editing(); const editing = !this.editing();
this.editing.set(editing); this.editing.set(editing);
} }