feat(cart): implement add to cart functionality and display empty cart message
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user