feat: enhance checkout process with purchase editing and status handling

- Implemented purchase editing functionality in CheckoutPageComponent, allowing users to modify items in their purchase.
- Added a new guard (checkoutPendingPurchaseGuard) to prevent navigation away from the checkout page while a purchase is in progress.
- Updated the login page to handle return URLs after authentication.
- Enhanced product detail page to support direct purchases with a new buyNow method.
- Introduced new UI elements and logic to handle purchase status, including expired and rejected states in PurchaseStatusPageComponent.
- Improved cart component to allow editing of item quantities with a toggle button.
- Added quantity selector enhancements to disable controls when necessary.
- Updated tests to cover new functionalities and ensure proper behavior of components.
This commit is contained in:
2026-07-27 12:49:00 -03:00
parent 9dcedc9382
commit 902f65d8d0
31 changed files with 1253 additions and 219 deletions

View File

@@ -34,20 +34,25 @@
}
</div>
<div class="d-flex justify-content-end align-items-center mt-auto cart-item-actions">
<app-quantity-selector
size="small"
[quantity]="quantity()"
(quantityChange)="onQuantityChange($event)"
(increase)="onIncrease()"
(decrease)="onDecrease()"
/>
<app-icon-button
variant="trash"
class="cart-item-remove-btn"
(click)="onRemove()"
/>
</div>
@if (!readonly()) {
<div class="d-flex justify-content-end align-items-center mt-auto cart-item-actions">
<app-quantity-selector
size="small"
[quantity]="quantity()"
[disabled]="quantityDisabled()"
(quantityChange)="onQuantityChange($event)"
(increase)="onIncrease()"
(decrease)="onDecrease()"
/>
@if (!quantityDisabled() && showRemove()) {
<app-icon-button
variant="trash"
class="cart-item-remove-btn"
(click)="onRemove()"
/>
}
</div>
}
</div>
</article>

View File

@@ -23,6 +23,9 @@ export class CartItemComponent {
readonly discountPercentage = input<number | null>(null);
readonly attributes = input<CartItemAttribute[]>([]);
readonly quantity = input<number>(1);
readonly readonly = input<boolean>(false);
readonly quantityDisabled = input<boolean>(false);
readonly showRemove = input<boolean>(true);
readonly quantityChange = output<number>();
readonly remove = output<void>();
@@ -30,6 +33,7 @@ export class CartItemComponent {
readonly decrease = output<void>();
protected onQuantityChange(newQuantity: number): void {
if (this.quantityDisabled()) return;
this.quantityChange.emit(newQuantity);
}

View File

@@ -5,16 +5,30 @@
<header class="d-flex align-items-center p-3 justify-content-between bg-transparent cart-header">
<h2 class="m-0 text-uppercase fw-bold cart-title">{{ title() }}</h2>
@if (showClose()) {
<button
class="btn btn-link p-0 border-0 d-inline-flex align-items-center justify-content-center cart-close-btn"
type="button"
aria-label="Cerrar carrito"
(click)="closed.emit()"
>
<i class="fa-solid fa-xmark"></i>
</button>
}
<div class="d-flex align-items-center cart-header-actions">
@if (!readonly() && allowEditing() && items().length > 0) {
<button
class="btn btn-link p-0 border-0 cart-edit-btn"
type="button"
[attr.aria-pressed]="editing()"
[disabled]="editingDisabled()"
(click)="toggleEditing()"
>
{{ editing() ? 'Listo' : 'Modificar' }}
</button>
}
@if (showClose()) {
<button
class="btn btn-link p-0 border-0 d-inline-flex align-items-center justify-content-center cart-close-btn"
type="button"
aria-label="Cerrar carrito"
(click)="closed.emit()"
>
<i class="fa-solid fa-xmark"></i>
</button>
}
</div>
</header>
<div class="d-grid w-100 flex-grow-1 overflow-y-auto cart-items-container">
@@ -31,6 +45,9 @@
[discountPercentage]="item.discountPercentage"
[attributes]="item.attributes"
[quantity]="getItemQuantity(item)"
[readonly]="readonly()"
[quantityDisabled]="editingDisabled() || (allowEditing() && !editing())"
[showRemove]="allowRemove()"
(quantityChange)="onItemQuantityChange(idx, $event)"
(remove)="onItemRemove(idx)"
/>

View File

@@ -21,6 +21,17 @@
color: #a0a0a0;
}
.cart-header-actions {
gap: 14px;
}
.cart-edit-btn {
color: var(--bs-primary);
font-size: 11px;
font-weight: 500;
text-decoration: none;
}
.cart-items-container {
min-height: 0;
gap: 0;

View File

@@ -306,4 +306,105 @@ describe('CartComponent', () => {
vi.useRealTimers();
});
it('enables quantity editing only while Modificar mode is active', 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('allowEditing', true);
const editingChange = vi.fn();
fixture.componentInstance.editing.subscribe(editingChange);
fixture.detectChanges();
const editButton = fixture.debugElement.query(By.css('.cart-edit-btn'));
expect(editButton.nativeElement.textContent.trim()).toBe('Modificar');
expect(
fixture.debugElement.query(By.css('app-cart-item')).componentInstance.quantityDisabled(),
).toBe(true);
editButton.nativeElement.click();
fixture.detectChanges();
expect(editButton.nativeElement.textContent.trim()).toBe('Listo');
expect(
fixture.debugElement.query(By.css('app-cart-item')).componentInstance.quantityDisabled(),
).toBe(false);
expect(editingChange).toHaveBeenCalledWith(true);
editButton.nativeElement.click();
fixture.detectChanges();
expect(editButton.nativeElement.textContent.trim()).toBe('Modificar');
expect(editingChange).toHaveBeenLastCalledWith(false);
});
it('allows editing directly when the optional Modificar toggle is disabled', 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.detectChanges();
expect(fixture.debugElement.query(By.css('.cart-edit-btn'))).toBeNull();
expect(
fixture.debugElement.query(By.css('app-cart-item')).componentInstance.quantityDisabled(),
).toBe(false);
});
});

View File

@@ -4,6 +4,7 @@ import {
computed,
inject,
input,
model,
output,
signal,
} from '@angular/core';
@@ -48,11 +49,21 @@ export class CartComponent {
readonly discount = input<number>(0);
readonly total = input<number>(0);
readonly backgroundColor = input<string>('#ffffff');
readonly readonly = input<boolean>(false);
readonly allowEditing = input<boolean>(false);
readonly allowRemove = input<boolean>(true);
readonly persistQuantityChanges = input<boolean>(true);
readonly editingDisabled = input<boolean>(false);
readonly editing = model<boolean>(false);
readonly closed = output<void>();
readonly itemQuantityChange = output<{
item: CartItemMock;
index: number;
quantity: number;
}>();
protected readonly quantityOverrides = signal<Record<number, number>>({});
constructor() {
this.quantityUpdates$
.pipe(
@@ -103,6 +114,20 @@ export class CartComponent {
protected onItemQuantityChange(index: number, newQuantity: number): void {
const mockItem = this.items()[index];
if (!mockItem) {
return;
}
this.itemQuantityChange.emit({
item: mockItem,
index,
quantity: newQuantity,
});
if (!this.persistQuantityChanges()) {
return;
}
const cartItemId = mockItem?.cartItemId;
if (cartItemId) {
this.quantityOverrides.update((overrides) => ({
@@ -153,6 +178,15 @@ export class CartComponent {
protected readonly formattedDiscount = computed(() => this.formatCurrency(this.discount()));
protected readonly formattedTotal = computed(() => this.formatCurrency(this.total()));
protected toggleEditing(): void {
if (this.editingDisabled()) {
return;
}
const editing = !this.editing();
this.editing.set(editing);
}
private formatCurrency(value: number): string {
const rounded = Math.round(value);
const parts = rounded.toString().split('.');

View File

@@ -1,13 +1,15 @@
<div
class="quantity-selector d-inline-flex align-items-center overflow-hidden rounded"
[class.quantity-selector--small]="size() === 'small'"
[class.quantity-selector--disabled]="disabled()"
aria-label="Selector de cantidad"
[attr.aria-disabled]="disabled()"
>
<button
type="button"
class="quantity-selector__button border-0 p-0 fw-bold lh-1"
aria-label="Disminuir cantidad"
[disabled]="quantity() <= min()"
[disabled]="disabled() || quantity() <= min()"
(click)="onDecrease()"
>
-
@@ -22,7 +24,7 @@
type="button"
class="quantity-selector__button border-0 p-0 fw-bold lh-1"
aria-label="Aumentar cantidad"
[disabled]="atMaximum()"
[disabled]="disabled() || atMaximum()"
(click)="onIncrease()"
>
+

View File

@@ -60,3 +60,7 @@
}
}
}
.quantity-selector--disabled {
opacity: 0.55;
}

View File

@@ -52,4 +52,21 @@ describe('QuantitySelectorComponent', () => {
expect(decreaseButton.disabled).toBe(true);
expect(fixture.componentInstance.quantity()).toBe(1);
});
it('disables both controls and ignores quantity changes when disabled', () => {
const fixture = TestBed.createComponent(QuantitySelectorComponent);
fixture.componentRef.setInput('quantity', 2);
fixture.componentRef.setInput('disabled', true);
fixture.detectChanges();
const buttons = fixture.nativeElement.querySelectorAll('button') as NodeListOf<HTMLButtonElement>;
expect(Array.from(buttons).every((button) => button.disabled)).toBe(true);
expect(fixture.nativeElement.querySelector('[aria-disabled="true"]')).not.toBeNull();
(fixture.componentInstance as any).onIncrease();
(fixture.componentInstance as any).onDecrease();
expect(fixture.componentInstance.quantity()).toBe(2);
});
});

View File

@@ -13,6 +13,7 @@ export class QuantitySelectorComponent {
readonly min = input<number>(1);
readonly max = input<number | null>(100);
readonly size = input<'small' | 'medium'>('medium');
readonly disabled = input<boolean>(false);
protected readonly atMaximum = computed(() => {
const max = this.max();
@@ -23,14 +24,14 @@ export class QuantitySelectorComponent {
readonly decrease = output<void>();
protected onDecrease(): void {
if (this.quantity() > this.min()) {
if (!this.disabled() && this.quantity() > this.min()) {
this.quantity.set(this.quantity() - 1);
this.decrease.emit();
}
}
protected onIncrease(): void {
if (!this.atMaximum()) {
if (!this.disabled() && !this.atMaximum()) {
this.quantity.set(this.quantity() + 1);
this.increase.emit();
}