feat(cart): implement add to cart functionality and display empty cart message

This commit is contained in:
2026-07-20 17:11:06 -03:00
parent 98e899c5bf
commit dafaa85480
8 changed files with 142 additions and 36 deletions

View File

@@ -1,15 +1,16 @@
<article class="w-100 p-3 rounded-0 cart-item">
<div class="position-relative overflow-hidden cart-item-media">
@if (discountPercentage() && discountPercentage()! > 0) {
<span class="position-absolute top-0 start-0 z-1 rounded-0 cart-item-discount-badge">-{{ discountPercentage() }}%</span>
}
<article
class="w-100 p-3 rounded-0 cart-item"
[class.cart-item-with-media]="imageUrl()"
>
@if (imageUrl()) {
<div class="position-relative overflow-hidden cart-item-media">
@if (discountPercentage() && discountPercentage()! > 0) {
<span class="position-absolute top-0 start-0 z-1 rounded-0 cart-item-discount-badge">-{{ discountPercentage() }}%</span>
}
@if (imageUrl()) {
<img class="w-100 h-100 object-fit-cover d-block" [src]="imageUrl()" [alt]="product()" />
} @else {
<div class="w-100 h-100 cart-item-placeholder" aria-hidden="true"></div>
}
</div>
</div>
}
<div class="d-grid min-w-0 cart-item-content">
<div class="d-grid align-items-start cart-item-top-row">

View File

@@ -29,11 +29,15 @@
.cart-item {
display: grid;
grid-template-columns: var(--item-media-size) minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr);
gap: 0.75rem;
background-color: #f5f5f5;
}
.cart-item-with-media {
grid-template-columns: var(--item-media-size) minmax(0, 1fr);
}
.cart-item-media {
width: var(--item-media-size);
aspect-ratio: 1 / 1;
@@ -63,12 +67,6 @@
line-height: 1;
}
.cart-item-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #d2d2d2, #ececec);
}
.cart-item-content {
gap: 0.5rem;
}

View File

@@ -34,27 +34,31 @@
(quantityChange)="onItemQuantityChange(idx, $event)"
(remove)="onItemRemove(idx)"
/>
} @empty {
<p class="cart-empty-message" role="status">El carrito está vacío</p>
}
</div>
<footer class="d-grid gap-1 bg-transparent cart-footer">
<div class="d-flex align-items-baseline justify-content-between gap-3">
<span class="cart-summary-text-muted">Subtotal:</span>
<span class="cart-summary-text-muted-bold">{{ formattedSubtotal() }}</span>
</div>
@if (items().length > 0) {
<footer class="d-grid gap-1 bg-transparent cart-footer">
<div class="d-flex align-items-baseline justify-content-between gap-3">
<span class="cart-summary-text-muted">Subtotal:</span>
<span class="cart-summary-text-muted-bold">{{ formattedSubtotal() }}</span>
</div>
<div class="d-flex align-items-baseline justify-content-between gap-3">
<span class="cart-summary-text-muted">Descuento:</span>
<span class="cart-summary-text-muted-bold">{{ formattedDiscount() }}</span>
</div>
<div class="d-flex align-items-baseline justify-content-between gap-3">
<span class="cart-summary-text-muted">Descuento:</span>
<span class="cart-summary-text-muted-bold">{{ formattedDiscount() }}</span>
</div>
<div class="d-flex align-items-baseline justify-content-between gap-3 mt-1">
<span class="cart-total-text">TOTAL:</span>
<span class="cart-total-text">{{ formattedTotal() }}</span>
</div>
<div class="d-flex align-items-baseline justify-content-between gap-3 mt-1">
<span class="cart-total-text">TOTAL:</span>
<span class="cart-total-text">{{ formattedTotal() }}</span>
</div>
<div class="cart-actions mt-3">
<ng-content />
</div>
</footer>
<div class="cart-actions mt-3">
<ng-content />
</div>
</footer>
}
</section>

View File

@@ -31,8 +31,6 @@
display: block;
}
&::-webkit-scrollbar {
width: 6px;
}
@@ -43,6 +41,15 @@
}
}
.cart-empty-message {
align-self: center;
margin: 0;
padding: 24px;
color: #a0a0a0;
font-size: 14px;
text-align: center;
}
.cart-footer {
padding: 8px 22px 14px 24px;
}

View File

@@ -24,6 +24,44 @@ describe('CartComponent', () => {
TestBed.resetTestingModule();
});
it('shows an empty cart message when there are no items', 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.detectChanges();
const emptyMessage = fixture.debugElement.query(By.css('.cart-empty-message'));
expect(emptyMessage).not.toBeNull();
expect(emptyMessage.nativeElement.textContent.trim()).toBe('El carrito está vacío');
expect(fixture.debugElement.query(By.css('app-cart-item'))).toBeNull();
expect(fixture.debugElement.query(By.css('.cart-footer'))).toBeNull();
});
it('opens a confirm delete modal before removing an item', async () => {
const removeItem = vi.fn().mockReturnValue(of({ message: 'Producto eliminado.' }));
const openConfirmDelete = vi.fn().mockReturnValue(of(true));
@@ -71,6 +109,8 @@ describe('CartComponent', () => {
]);
fixture.detectChanges();
expect(fixture.debugElement.query(By.css('.cart-item-media'))).toBeNull();
fixture.debugElement.query(By.css('app-cart-item')).triggerEventHandler('remove');
expect(openConfirmDelete).toHaveBeenCalledWith({