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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)"
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('.');
|
||||
|
||||
@@ -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()"
|
||||
>
|
||||
+
|
||||
|
||||
@@ -60,3 +60,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quantity-selector--disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user