From c24a8b946d246d6366404f7b669fc0628d177bf6 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 21 Aug 2026 14:19:32 -0300 Subject: [PATCH] feat(catalog): refresh availability after cart changes --- .../category-items-page.component.spec.ts | 11 ++++ .../category-items-page.component.ts | 40 ++++++++----- .../product-detail-page.component.spec.ts | 22 ++++++- .../product-detail-page.component.ts | 34 +++++++++++ .../product-detail-page.resolver.spec.ts | 1 + .../search-page/search-page.component.spec.ts | 11 ++++ .../search-page/search-page.component.ts | 59 ++++++++++++------- .../store-home-page.component.spec.ts | 31 +++++++++- .../store-home-page.component.ts | 26 ++++++-- 9 files changed, 190 insertions(+), 45 deletions(-) diff --git a/src/app/features/store/pages/category-items-page/category-items-page.component.spec.ts b/src/app/features/store/pages/category-items-page/category-items-page.component.spec.ts index 0d2293d..780a1c6 100644 --- a/src/app/features/store/pages/category-items-page/category-items-page.component.spec.ts +++ b/src/app/features/store/pages/category-items-page/category-items-page.component.spec.ts @@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CartService } from '../../../../core/services/cart/cart.service'; import { CatalogService } from '../../../../core/services/catalog/catalog.service'; +import { CatalogAvailabilityService } from '../../../../core/services/catalog/catalog-availability.service'; import { Tenant } from '../../../../core/services/tenant.interface'; import { TenantService } from '../../../../core/services/tenant.service'; import { ToastService } from '../../../../core/services/toast.service'; @@ -107,6 +108,16 @@ describe('CategoryItemsPageComponent', () => { expect(Array.isArray(productList.items())).toBe(false); }); + it('reloads the current category page when catalog availability changes', () => { + const fixture = TestBed.createComponent(CategoryItemsPageComponent); + fixture.detectChanges(); + + TestBed.inject(CatalogAvailabilityService).notifyAvailabilityChanged(); + + expect(getCategoryItems).toHaveBeenCalledTimes(2); + expect(getCategoryItems).toHaveBeenLastCalledWith(7, { page: 1 }); + }); + it('does not request the API when the category id is invalid', () => { TestBed.overrideProvider(ActivatedRoute, { useValue: { diff --git a/src/app/features/store/pages/category-items-page/category-items-page.component.ts b/src/app/features/store/pages/category-items-page/category-items-page.component.ts index eec8a59..4af5b4f 100644 --- a/src/app/features/store/pages/category-items-page/category-items-page.component.ts +++ b/src/app/features/store/pages/category-items-page/category-items-page.component.ts @@ -16,6 +16,7 @@ import { finalize, map, of, + startWith, switchMap, tap, } from 'rxjs'; @@ -28,6 +29,7 @@ import { CategoryItemsResponse, } from '../../../../core/services/catalog/catalog.interface'; import { CatalogService } from '../../../../core/services/catalog/catalog.service'; +import { CatalogAvailabilityService } from '../../../../core/services/catalog/catalog-availability.service'; import { TenantService } from '../../../../core/services/tenant.service'; import { ToastService } from '../../../../core/services/toast.service'; import { @@ -50,6 +52,7 @@ interface CategoryRouteState { }) export class CategoryItemsPageComponent { private readonly cartService = inject(CartService); + private readonly catalogAvailabilityService = inject(CatalogAvailabilityService); private readonly catalogService = inject(CatalogService); private readonly injector = inject(Injector); private readonly destroyRef = inject(DestroyRef); @@ -65,22 +68,29 @@ export class CategoryItemsPageComponent { protected readonly paginatedLayout: CatalogGroupLayout = 'paginated'; constructor() { - combineLatest([this.route.paramMap, this.route.queryParamMap]) - .pipe( - map( - ([params, queryParams]): CategoryRouteState => ({ - categoryId: this.parsePositiveInteger(params.get('id')), - page: this.parsePositiveInteger(queryParams.get('page'), 1), - }), - ), - distinctUntilChanged( - (previous, current) => - previous.categoryId === current.categoryId && previous.page === current.page, - ), - tap(() => { - this.results.set(null); - this.error.set(null); + const routeState$ = combineLatest([this.route.paramMap, this.route.queryParamMap]).pipe( + map( + ([params, queryParams]): CategoryRouteState => ({ + categoryId: this.parsePositiveInteger(params.get('id')), + page: this.parsePositiveInteger(queryParams.get('page'), 1), }), + ), + distinctUntilChanged( + (previous, current) => + previous.categoryId === current.categoryId && previous.page === current.page, + ), + tap(() => { + this.results.set(null); + this.error.set(null); + }), + ); + + combineLatest([ + routeState$, + this.catalogAvailabilityService.availabilityChanged$.pipe(startWith(undefined)), + ]) + .pipe( + map(([routeState]) => routeState), switchMap(({ categoryId, page }) => { if (categoryId === 0) { this.error.set('La categoría solicitada no es válida.'); diff --git a/src/app/features/store/pages/product-detail-page/product-detail-page.component.spec.ts b/src/app/features/store/pages/product-detail-page/product-detail-page.component.spec.ts index 7796679..5e020c8 100644 --- a/src/app/features/store/pages/product-detail-page/product-detail-page.component.spec.ts +++ b/src/app/features/store/pages/product-detail-page/product-detail-page.component.spec.ts @@ -9,6 +9,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { CatalogItemDetail } from '../../../../core/services/catalog/catalog.interface'; import { CatalogService } from '../../../../core/services/catalog/catalog.service'; +import { CatalogAvailabilityService } from '../../../../core/services/catalog/catalog-availability.service'; import { CartService } from '../../../../core/services/cart/cart.service'; import { ToastService } from '../../../../core/services/toast.service'; import { ProductDetailPageComponent } from './product-detail-page.component'; @@ -176,6 +177,21 @@ describe('ProductDetailPageComponent', () => { expect(element.querySelector('.product-carousel__discount-badge')).toBeNull(); }); + it('reloads product availability when the cart changes', async () => { + catalogServiceStub.getCatalogItem.mockReturnValue( + of({ ...mockProduct, maximum_addable_quantity: 4 }), + ); + await configureTestingModule(); + const fixture = TestBed.createComponent(ProductDetailPageComponent); + fixture.detectChanges(); + + TestBed.inject(CatalogAvailabilityService).notifyAvailabilityChanged(); + fixture.detectChanges(); + + expect(catalogServiceStub.getCatalogItem).toHaveBeenCalledWith(1, undefined); + expect(fixture.componentInstance['selectedVariantMax']()).toBe(4); + }); + it('shows error message if the resolver cannot load the product', async () => { resolveProductError(PRODUCT_DETAIL_ERROR_MESSAGE); await configureTestingModule(); @@ -510,9 +526,9 @@ describe('ProductDetailPageComponent', () => { await configureTestingModule(); const fixture = TestBed.createComponent(ProductDetailPageComponent); fixture.detectChanges(); - const buyButton = Array.from(fixture.nativeElement.querySelectorAll('app-button button')).find( - (button) => button.textContent?.trim() === 'Comprar', - ) as HTMLButtonElement; + const buyButton = Array.from( + fixture.nativeElement.querySelectorAll('app-button button') as NodeListOf, + ).find((button) => button.textContent?.trim() === 'Comprar') as HTMLButtonElement; buyButton.click(); await Promise.resolve(); diff --git a/src/app/features/store/pages/product-detail-page/product-detail-page.component.ts b/src/app/features/store/pages/product-detail-page/product-detail-page.component.ts index 831d7f3..0e869a6 100644 --- a/src/app/features/store/pages/product-detail-page/product-detail-page.component.ts +++ b/src/app/features/store/pages/product-detail-page/product-detail-page.component.ts @@ -17,6 +17,7 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { Subscription } from 'rxjs'; import { CatalogService } from '../../../../core/services/catalog/catalog.service'; +import { CatalogAvailabilityService } from '../../../../core/services/catalog/catalog-availability.service'; import { ToastService } from '../../../../core/services/toast.service'; import { CartService } from '../../../../core/services/cart/cart.service'; import { @@ -51,6 +52,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly catalogService = inject(CatalogService); + private readonly catalogAvailabilityService = inject(CatalogAvailabilityService); private readonly toastService = inject(ToastService); private readonly cartService = inject(CartService); private readonly checkoutService = inject(CheckoutService); @@ -64,6 +66,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { private routeSub: Subscription | null = null; private productSub: Subscription | null = null; + private availabilityChangedSub: Subscription | null = null; private carouselResizeObserver: ResizeObserver | null = null; private observedCarouselPreview: HTMLElement | null = null; private measurementTimer: ReturnType | null = null; @@ -150,6 +153,10 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { } ngOnInit(): void { + this.availabilityChangedSub = this.catalogAvailabilityService.availabilityChanged$.subscribe( + () => this.refreshProductAvailability(), + ); + this.routeSub = this.route.data.subscribe((data) => { const resolvedData = data['productDetailData'] as ProductDetailResolvedData | undefined; @@ -160,6 +167,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { + this.availabilityChangedSub?.unsubscribe(); this.routeSub?.unsubscribe(); this.productSub?.unsubscribe(); this.carouselResizeObserver?.disconnect(); @@ -196,6 +204,32 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { }); } + private refreshProductAvailability(): void { + const currentProduct = this.product(); + if (!currentProduct) { + return; + } + + const variantId = this.selectedVariant()?.id; + this.productSub?.unsubscribe(); + this.productSub = this.catalogService + .withCustomLoading() + .getCatalogItem(currentProduct.id, variantId) + .subscribe({ + next: (product) => { + this.applyProduct(product, false); + + const maximum = this.selectedVariantMax(); + if (maximum !== null && this.quantity() > maximum) { + this.quantity.set(Math.max(1, maximum)); + } + }, + error: () => { + // Keep the last known availability if the silent refresh fails. + }, + }); + } + private applyResolvedData(resolvedData: ProductDetailResolvedData): void { this.productSub?.unsubscribe(); this.loading.set(false); diff --git a/src/app/features/store/pages/product-detail-page/product-detail-page.resolver.spec.ts b/src/app/features/store/pages/product-detail-page/product-detail-page.resolver.spec.ts index cac7e4d..06656d9 100644 --- a/src/app/features/store/pages/product-detail-page/product-detail-page.resolver.spec.ts +++ b/src/app/features/store/pages/product-detail-page/product-detail-page.resolver.spec.ts @@ -15,6 +15,7 @@ import { describe('productDetailResolver', () => { const product: CatalogItemDetail = { id: 1, + type: 'product', category_id: 10, brand_id: null, slug: 'auriculares-bluetooth', diff --git a/src/app/features/store/pages/search-page/search-page.component.spec.ts b/src/app/features/store/pages/search-page/search-page.component.spec.ts index 686be74..e7a7e05 100644 --- a/src/app/features/store/pages/search-page/search-page.component.spec.ts +++ b/src/app/features/store/pages/search-page/search-page.component.spec.ts @@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CartService } from '../../../../core/services/cart/cart.service'; import { CatalogService } from '../../../../core/services/catalog/catalog.service'; +import { CatalogAvailabilityService } from '../../../../core/services/catalog/catalog-availability.service'; import { Tenant } from '../../../../core/services/tenant.interface'; import { TenantService } from '../../../../core/services/tenant.service'; import { ToastService } from '../../../../core/services/toast.service'; @@ -108,6 +109,16 @@ describe('SearchPageComponent', () => { ); }); + it('repeats the current search when catalog availability changes', () => { + const fixture = TestBed.createComponent(SearchPageComponent); + fixture.detectChanges(); + + TestBed.inject(CatalogAvailabilityService).notifyAvailabilityChanged(); + + expect(searchCatalog).toHaveBeenCalledTimes(2); + expect(searchCatalog).toHaveBeenLastCalledWith({ q: 'running', page: 1 }); + }); + it('renders the search title and query subtitle with the category header layout', () => { const fixture = TestBed.createComponent(SearchPageComponent); fixture.detectChanges(); diff --git a/src/app/features/store/pages/search-page/search-page.component.ts b/src/app/features/store/pages/search-page/search-page.component.ts index 0a1ac9d..8715d9c 100644 --- a/src/app/features/store/pages/search-page/search-page.component.ts +++ b/src/app/features/store/pages/search-page/search-page.component.ts @@ -9,7 +9,17 @@ import { signal, } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { catchError, distinctUntilChanged, finalize, map, of, switchMap, tap } from 'rxjs'; +import { + catchError, + combineLatest, + distinctUntilChanged, + finalize, + map, + of, + startWith, + switchMap, + tap, +} from 'rxjs'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { CartService } from '../../../../core/services/cart/cart.service'; @@ -22,6 +32,7 @@ import { CatalogProductLayout, } from '../../../../core/services/catalog/catalog.interface'; import { CatalogService } from '../../../../core/services/catalog/catalog.service'; +import { CatalogAvailabilityService } from '../../../../core/services/catalog/catalog-availability.service'; import { ApiPaginatedResponse } from '../../../../core/services/api-paginated-response.interface'; import { TenantService } from '../../../../core/services/tenant.service'; import { ToastService } from '../../../../core/services/toast.service'; @@ -46,6 +57,7 @@ interface SearchRouteState { export class SearchPageComponent { private readonly minSearchLength = 3; private readonly cartService = inject(CartService); + private readonly catalogAvailabilityService = inject(CatalogAvailabilityService); private readonly catalogService = inject(CatalogService); private readonly injector = inject(Injector); private readonly destroyRef = inject(DestroyRef); @@ -86,26 +98,33 @@ export class SearchPageComponent { }); constructor() { - this.route.queryParamMap - .pipe( - map( - (params): SearchRouteState => ({ - query: params.get('q')?.trim() ?? '', - page: this.parsePage(params.get('page')), - }), - ), - distinctUntilChanged( - (previous, current) => previous.query === current.query && previous.page === current.page, - ), - tap(({ query }) => { - this.query.set(query); - this.results.set(null); - this.error.set( - query.length >= this.minSearchLength - ? null - : `Ingresá al menos ${this.minSearchLength} caracteres para buscar.`, - ); + const routeState$ = this.route.queryParamMap.pipe( + map( + (params): SearchRouteState => ({ + query: params.get('q')?.trim() ?? '', + page: this.parsePage(params.get('page')), }), + ), + distinctUntilChanged( + (previous, current) => previous.query === current.query && previous.page === current.page, + ), + tap(({ query }) => { + this.query.set(query); + this.results.set(null); + this.error.set( + query.length >= this.minSearchLength + ? null + : `Ingresá al menos ${this.minSearchLength} caracteres para buscar.`, + ); + }), + ); + + combineLatest([ + routeState$, + this.catalogAvailabilityService.availabilityChanged$.pipe(startWith(undefined)), + ]) + .pipe( + map(([routeState]) => routeState), switchMap(({ query, page }) => { if (query.length < this.minSearchLength) { return of(null); diff --git a/src/app/features/store/pages/store-home-page/store-home-page.component.spec.ts b/src/app/features/store/pages/store-home-page/store-home-page.component.spec.ts index 6027fcb..60367d9 100644 --- a/src/app/features/store/pages/store-home-page/store-home-page.component.spec.ts +++ b/src/app/features/store/pages/store-home-page/store-home-page.component.spec.ts @@ -12,6 +12,7 @@ import { CatalogFeaturedItem, } from '../../../../core/services/catalog/catalog.interface'; import { CatalogService } from '../../../../core/services/catalog/catalog.service'; +import { CatalogAvailabilityService } from '../../../../core/services/catalog/catalog-availability.service'; import { Tenant } from '../../../../core/services/tenant.interface'; import { TenantService } from '../../../../core/services/tenant.service'; import { ToastService } from '../../../../core/services/toast.service'; @@ -78,12 +79,14 @@ function provideActivatedRoute(catalogData: StoreHomeCatalogResolvedData) { const pageOneItems: CatalogFeaturedItem[] = [ { id: 1, + type: 'product', nombre: 'Auriculares Bluetooth', precio: '24999.00', image: '/catalog/auriculares.jpg', }, { id: 2, + type: 'product', nombre: 'Teclado Mecanico', precio: '18999.00', image: null, @@ -159,6 +162,30 @@ describe('StoreHomePageComponent', () => { ); }); + it('reloads the catalog when availability changes', async () => { + const refreshedCatalog = createCatalog(); + const catalogServiceStub = { + getCatalog: vi.fn().mockReturnValue(of(refreshedCatalog)), + getFeaturedGroupItems: vi.fn(), + withCustomLoading: vi.fn(), + }; + catalogServiceStub.withCustomLoading.mockReturnValue(catalogServiceStub); + + await TestBed.configureTestingModule({ + imports: [StoreHomePageComponent], + providers: [ + provideActivatedRoute({ response: createCatalog(), error: null }), + { provide: CatalogService, useValue: catalogServiceStub }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(StoreHomePageComponent); + fixture.detectChanges(); + TestBed.inject(CatalogAvailabilityService).notifyAvailabilityChanged(); + + expect(catalogServiceStub.getCatalog).toHaveBeenCalledOnce(); + }); + it('renders the carousel URLs received in tenant extras', async () => { const carousel = ['https://example.com/carousel-1.webp', 'https://example.com/carousel-2.webp']; @@ -378,7 +405,9 @@ describe('StoreHomePageComponent', () => { }); it('requests another page for the selected featured group', async () => { - const pageTwoItems = [{ id: 3, nombre: 'Mouse Gamer', precio: '15999.00', image: null }]; + const pageTwoItems: CatalogFeaturedItem[] = [ + { id: 3, type: 'product', nombre: 'Mouse Gamer', precio: '15999.00', image: null }, + ]; const catalogServiceStub = { getCatalog: vi.fn(), getFeaturedGroupItems: vi.fn().mockReturnValue(of(createItemsPage(pageTwoItems, 2, 2))), diff --git a/src/app/features/store/pages/store-home-page/store-home-page.component.ts b/src/app/features/store/pages/store-home-page/store-home-page.component.ts index d4bf72b..e9fa018 100644 --- a/src/app/features/store/pages/store-home-page/store-home-page.component.ts +++ b/src/app/features/store/pages/store-home-page/store-home-page.component.ts @@ -15,6 +15,7 @@ import { finalize, Subscription } from 'rxjs'; import { CartService } from '../../../../core/services/cart/cart.service'; import { AuthService } from '../../../../core/services/auth/auth.service'; import { CatalogFeaturedGroup } from '../../../../core/services/catalog/catalog.interface'; +import { CatalogAvailabilityService } from '../../../../core/services/catalog/catalog-availability.service'; import { CatalogService } from '../../../../core/services/catalog/catalog.service'; import { TenantService } from '../../../../core/services/tenant.service'; import { ToastService } from '../../../../core/services/toast.service'; @@ -49,6 +50,7 @@ import { }) export class StoreHomePageComponent implements OnInit, OnDestroy { private readonly cartService = inject(CartService); + private readonly catalogAvailabilityService = inject(CatalogAvailabilityService); private readonly catalogService = inject(CatalogService); private readonly injector = inject(Injector); private readonly route = inject(ActivatedRoute); @@ -96,9 +98,13 @@ export class StoreHomePageComponent implements OnInit, OnDestroy { protected readonly hasMainCarouselImages = computed(() => this.mainCarouselImages().length > 0); private catalogRequestSubscription: Subscription | null = null; + private availabilityChangedSubscription: Subscription | null = null; private readonly groupRequestSubscriptions = new Map(); ngOnInit(): void { + this.availabilityChangedSubscription = + this.catalogAvailabilityService.availabilityChanged$.subscribe(() => this.loadCatalog(true)); + const resolvedData = this.route.snapshot.data['catalogData'] as | StoreHomeCatalogResolvedData | undefined; @@ -112,6 +118,7 @@ export class StoreHomePageComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { + this.availabilityChangedSubscription?.unsubscribe(); this.catalogRequestSubscription?.unsubscribe(); this.groupRequestSubscriptions.forEach((subscription) => subscription.unsubscribe()); } @@ -264,20 +271,27 @@ export class StoreHomePageComponent implements OnInit, OnDestroy { }); } - private loadCatalog(): void { - this.loading.set(true); - this.error.set(null); + private loadCatalog(silent = false): void { + if (!silent) { + this.loading.set(true); + this.error.set(null); + } this.catalogRequestSubscription?.unsubscribe(); this.catalogRequestSubscription = this.catalogService .withCustomLoading() .getCatalog() .subscribe({ - next: (catalog) => this.catalog.set(catalog), + next: (catalog) => { + this.catalog.set(catalog); + this.error.set(null); + }, error: () => { - this.catalog.set([]); + if (!silent) { + this.catalog.set([]); + this.error.set(STORE_HOME_PRODUCTS_ERROR_MESSAGE); + } this.loading.set(false); - this.error.set(STORE_HOME_PRODUCTS_ERROR_MESSAGE); }, complete: () => this.loading.set(false), });