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

@@ -21,6 +21,7 @@
[items]="group.items"
[loading]="isGroupLoading(group.id)"
(buy)="onBuyProduct($event)"
(addToCart)="onAddToCart($event)"
(pageChange)="onPageChange(group.id, $event)"
/>
</app-store-section>

View File

@@ -1,14 +1,18 @@
import { TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ActivatedRoute } from '@angular/router';
import { Subject, of } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiPaginatedResponse } from '../../../../core/services/api-paginated-response.interface';
import { CartService } from '../../../../core/services/cart/cart.service';
import {
CatalogFeaturedGroup,
CatalogFeaturedItem,
} from '../../../../core/services/catalog/catalog.interface';
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { ToastService } from '../../../../core/services/toast.service';
import { ProductListComponent } from '../../../../shared/components/product-list/product-list.component';
import { StoreHomePageComponent } from './store-home-page.component';
import {
STORE_HOME_PRODUCTS_ERROR_MESSAGE,
@@ -213,4 +217,34 @@ describe('StoreHomePageComponent', () => {
'No pudimos cargar los productos en este momento.',
);
});
it('adds a product-list cart event to the cart', async () => {
const catalogServiceStub = { getCatalog: vi.fn(), getFeaturedGroupItems: vi.fn() };
const cartServiceStub = {
addItem: vi.fn().mockReturnValue(
of({ data: {}, message: 'Producto agregado correctamente' }),
),
};
const toastServiceStub = { success: vi.fn(), danger: vi.fn() };
await TestBed.configureTestingModule({
imports: [StoreHomePageComponent],
providers: [
provideActivatedRoute({ response: createCatalog(), error: null }),
{ provide: CatalogService, useValue: catalogServiceStub },
{ provide: CartService, useValue: cartServiceStub },
{ provide: ToastService, useValue: toastServiceStub },
],
}).compileComponents();
const fixture = TestBed.createComponent(StoreHomePageComponent);
fixture.detectChanges();
const productList = fixture.debugElement.query(By.directive(ProductListComponent))
.componentInstance as ProductListComponent;
productList.addToCart.emit({ product: pageOneItems[0], quantity: 3, variant: 12 });
expect(cartServiceStub.addItem).toHaveBeenCalledWith(1, 12, 3);
expect(toastServiceStub.success).toHaveBeenCalledWith('Producto agregado correctamente');
});
});

View File

@@ -6,14 +6,18 @@ import {
inject,
signal,
} from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { ActivatedRoute, Router } from '@angular/router';
import { Subscription } from 'rxjs';
import { CartService } from '../../../../core/services/cart/cart.service';
import { CatalogFeaturedGroup } from '../../../../core/services/catalog/catalog.interface';
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
import { TenantService } from '../../../../core/services/tenant.service';
import { ToastService } from '../../../../core/services/toast.service';
import {
ProductListComponent,
ProductListCartEvent,
ProductListItem,
} from '../../../../shared/components/product-list/product-list.component';
import { HeroBannerComponent } from '../../../../shared/components/hero-banner/hero-banner.component';
@@ -31,10 +35,12 @@ import {
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StoreHomePageComponent implements OnInit, OnDestroy {
private readonly cartService = inject(CartService);
private readonly catalogService = inject(CatalogService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly tenantService = inject(TenantService);
private readonly toastService = inject(ToastService);
protected readonly tenant = this.tenantService.tenant;
protected readonly catalog = signal<CatalogFeaturedGroup[]>([]);
@@ -105,6 +111,21 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
this.router.navigate(['/producto', product.id]);
}
protected onAddToCart(event: ProductListCartEvent): void {
this.cartService
.addItem(event.product.id, event.variant ?? null, event.quantity)
.subscribe({
next: (response) => {
this.toastService.success(response.message || 'Producto agregado al carrito');
},
error: (error: HttpErrorResponse) => {
const message =
error.error?.message || 'No se pudo agregar el producto al carrito.';
this.toastService.danger(message);
},
});
}
private loadCatalog(): void {
this.loading.set(true);
this.error.set(null);

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({