diff --git a/src/app/core/services/checkout.service.ts b/src/app/core/services/checkout.service.ts index 7899eda..80e537a 100644 --- a/src/app/core/services/checkout.service.ts +++ b/src/app/core/services/checkout.service.ts @@ -17,6 +17,32 @@ export interface DirectCheckoutItem { cantidad: number; } +export interface UnavailableCheckoutItem { + index: number; + catalog_item_id: number; + variant_id: number | null; + requested_quantity: number; + available_quantity: number; + message: string; +} + +export interface InsufficientStockResponse { + code: 'purchase.insufficient_stock'; + message: string; + errors: Record; + unavailable_items: UnavailableCheckoutItem[]; +} + +export function isInsufficientStockResponse(value: unknown): value is InsufficientStockResponse { + if (typeof value !== 'object' || value === null) return false; + + const response = value as Partial; + + return ( + response.code === 'purchase.insufficient_stock' && Array.isArray(response.unavailable_items) + ); +} + export type StartCheckoutPayload = | { cart_id: number; diff --git a/src/app/features/store/pages/store-home-page/store-home-page.component.html b/src/app/features/store/pages/store-home-page/store-home-page.component.html index 619ba61..4f415ed 100644 --- a/src/app/features/store/pages/store-home-page/store-home-page.component.html +++ b/src/app/features/store/pages/store-home-page/store-home-page.component.html @@ -30,6 +30,7 @@ [items]="group.items" [loading]="isGroupLoading(group.id)" [loadImages]="!hasMainCarouselImages() || mainCarouselReady()" + [unavailableVariantIds]="unavailableVariantIds()" (buy)="onBuyProduct($event)" (addToCart)="onAddToCart($event)" (pageChange)="onPageChange(group.id, $event)" 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 52bd9ca..c09b554 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 @@ -18,7 +18,10 @@ import { CatalogFeaturedGroup } from '../../../../core/services/catalog/catalog. import { CatalogService } from '../../../../core/services/catalog/catalog.service'; import { TenantService } from '../../../../core/services/tenant.service'; import { ToastService } from '../../../../core/services/toast.service'; -import { CheckoutService } from '../../../../core/services/checkout.service'; +import { + CheckoutService, + isInsufficientStockResponse, +} from '../../../../core/services/checkout.service'; import { ProductListComponent, ProductListCartEvent, @@ -60,6 +63,7 @@ export class StoreHomePageComponent implements OnInit, OnDestroy { protected readonly error = signal(null); protected readonly mainCarouselReady = signal(false); protected readonly creatingDirectPurchase = signal(false); + protected readonly unavailableVariantIds = signal>(new Set()); protected readonly mainCarouselImages = computed(() => this.tenant()?.extras?.carousel ?? []); protected readonly heroConfig = computed(() => this.tenant()?.extras?.heroConfig ?? null); protected readonly additionalInfo = computed( @@ -200,6 +204,17 @@ export class StoreHomePageComponent implements OnInit, OnDestroy { await this.router.navigate(['/checkout'], { queryParams: { purchase: purchase.id } }); } catch (error) { console.error('Failed to create direct purchase:', error); + + if (error instanceof HttpErrorResponse && isInsufficientStockResponse(error.error)) { + const unavailableIds = error.error.unavailable_items + .map((item) => item.variant_id) + .filter((variantId): variantId is number => variantId !== null); + + this.unavailableVariantIds.update((current) => new Set([...current, ...unavailableIds])); + this.toastService.danger(error.error.message); + return; + } + this.toastService.danger('No se pudo iniciar la compra directa.'); } finally { this.creatingDirectPurchase.set(false); diff --git a/src/app/shared/components/product-list/product-list.component.html b/src/app/shared/components/product-list/product-list.component.html index 8f10cba..f53c79d 100644 --- a/src/app/shared/components/product-list/product-list.component.html +++ b/src/app/shared/components/product-list/product-list.component.html @@ -34,6 +34,7 @@ [price]="price(item)" [imageUrl]="loadImages() ? (item.image ?? null) : null" [variants]="item.variants ?? []" + [unavailableVariantIds]="unavailableVariantIds()" [disabled]="loading()" (buy)="emitTicketBuy(item, $event)" /> diff --git a/src/app/shared/components/product-list/product-list.component.spec.ts b/src/app/shared/components/product-list/product-list.component.spec.ts index 0dd7ad7..02db057 100644 --- a/src/app/shared/components/product-list/product-list.component.spec.ts +++ b/src/app/shared/components/product-list/product-list.component.spec.ts @@ -8,6 +8,8 @@ import { CatalogGroupLayout, } from '../../../core/services/catalog/catalog.interface'; import { ProductListComponent, ProductListItem, ProductListLayout } from './product-list.component'; +import { ProductTicketSelectorComponent } from '../product-ticket-selector/product-ticket-selector.component'; +import { By } from '@angular/platform-browser'; describe('ProductListComponent', () => { const items: ProductListItem[] = [ @@ -274,6 +276,25 @@ describe('ProductListComponent', () => { }); }); + it('removes unavailable variants from the ticket selector', async () => { + const ticket: ProductListItem = { + ...items[0], + variants: [ + { id: 401, stock_tecnico: 1, values: { asiento: { value: '1', label: '1' } } }, + { id: 402, stock_tecnico: 1, values: { asiento: { value: '2', label: '2' } } }, + ], + }; + const fixture = await render('ticket_selector', [ticket], 'single'); + fixture.componentRef.setInput('unavailableVariantIds', new Set([401])); + await fixture.whenStable(); + fixture.detectChanges(); + + const selector = fixture.debugElement.query(By.directive(ProductTicketSelectorComponent)) + .componentInstance as ProductTicketSelectorComponent; + + expect(selector['selectableVariants']().map((variant) => variant.id)).toEqual([402]); + }); + it('renders carousel groups with the reusable carousel', async () => { const fixture = await render('column_with_image', items, 'carousel'); const element = fixture.nativeElement as HTMLElement; diff --git a/src/app/shared/components/product-list/product-list.component.ts b/src/app/shared/components/product-list/product-list.component.ts index c5b1027..8865653 100644 --- a/src/app/shared/components/product-list/product-list.component.ts +++ b/src/app/shared/components/product-list/product-list.component.ts @@ -67,6 +67,7 @@ export class ProductListComponent { readonly items = input.required(); readonly loading = input(false); readonly loadImages = input(true); + readonly unavailableVariantIds = input>(new Set()); readonly buy = output(); readonly addToCart = output(); diff --git a/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.ts b/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.ts index db9fc8c..4cb66d1 100644 --- a/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.ts +++ b/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.ts @@ -1,4 +1,12 @@ -import { ChangeDetectionStrategy, Component, computed, input, output, signal } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + input, + output, + signal, +} from '@angular/core'; import { ButtonComponent } from '../button/button.component'; import { IconButtonComponent } from '../icon-button/icon-button.component'; @@ -30,6 +38,7 @@ export class ProductTicketSelectorComponent { readonly price = input(0); readonly imageUrl = input(null); readonly variants = input([]); + readonly unavailableVariantIds = input>(new Set()); readonly disabled = input(false); readonly buy = output(); @@ -38,7 +47,11 @@ export class ProductTicketSelectorComponent { private nextRowId = 2; protected readonly selectableVariants = computed(() => - this.variants().filter((variant) => variant.stock_tecnico == null || variant.stock_tecnico > 0), + this.variants().filter( + (variant) => + !this.unavailableVariantIds().has(variant.id as number) && + (variant.stock_tecnico == null || variant.stock_tecnico > 0), + ), ); protected readonly hasSelection = computed( () => this.rows().length > 0 && this.rows().every((row) => row.variantId !== null), @@ -47,8 +60,7 @@ export class ProductTicketSelectorComponent { () => this.rows().length < this.selectableVariants().length, ); protected readonly priceRange = computed(() => { - const prices = this.variants() - .filter((variant) => variant.stock_tecnico == null || variant.stock_tecnico > 0) + const prices = this.selectableVariants() .map((variant) => Number(variant.precio ?? this.price())) .filter(Number.isFinite); @@ -66,6 +78,24 @@ export class ProductTicketSelectorComponent { }; }); + constructor() { + effect(() => { + const unavailable = this.unavailableVariantIds(); + + if (!this.rows().some((row) => row.variantId !== null && unavailable.has(row.variantId))) { + return; + } + + this.rows.update((rows) => + rows.map((row) => + row.variantId !== null && unavailable.has(row.variantId) + ? { ...row, variantId: null } + : row, + ), + ); + }); + } + protected onBuy(): void { const variantIds = this.rows().flatMap((row) => row.variantId === null ? [] : [row.variantId],