diff --git a/src/app/core/services/catalog/catalog-availability.ts b/src/app/core/services/catalog/catalog-availability.ts index b913750..4a09da8 100644 --- a/src/app/core/services/catalog/catalog-availability.ts +++ b/src/app/core/services/catalog/catalog-availability.ts @@ -1,45 +1,80 @@ -import { CatalogAvailability } from './catalog.interface'; +import { CatalogAction, CatalogAvailability } from './catalog.interface'; + +const ALL_CATALOG_ACTIONS: CatalogAction[] = [ + 'select_variant', + 'change_quantity', + 'add_to_cart', + 'buy_now', +]; export const AVAILABLE_CATALOG_AVAILABILITY: CatalogAvailability = { - subject: 'product', + state: 'visible', maximum_quantity: null, - restrictions: [], - capabilities: { - display: true, - select_variant: true, - change_quantity: true, - add_to_cart: true, - buy_now: true, - }, + allowed_actions: ALL_CATALOG_ACTIONS, + reasons: [], }; -export function createCatalogAvailability( - maximumQuantity: number | null, - subject: CatalogAvailability['subject'] = 'product', -): CatalogAvailability { +export function createCatalogAvailability(maximumQuantity: number | null): CatalogAvailability { const unavailable = maximumQuantity === 0; + if (unavailable) { + return { + state: 'hidden', + reasons: [ + { + code: 'out_of_stock', + message: 'Este producto no tiene stock disponible.', + }, + ], + }; + } + return { - subject, + state: 'visible', maximum_quantity: maximumQuantity, - restrictions: unavailable - ? [ - { - code: 'out_of_stock', - message: 'Este producto no tiene stock disponible.', - }, - ] - : [], - capabilities: { - display: !unavailable, - select_variant: !unavailable, - change_quantity: !unavailable, - add_to_cart: !unavailable, - buy_now: !unavailable, - }, + allowed_actions: [...ALL_CATALOG_ACTIONS], + reasons: [], }; } export function primaryAvailabilityMessage(availability: CatalogAvailability): string | null { - return availability.restrictions[0]?.message ?? null; + return availability.reasons[0]?.message ?? null; +} + +export function allowsCatalogAction( + availability: CatalogAvailability, + action: CatalogAction, +): boolean { + return availability.state === 'visible' && availability.allowed_actions.includes(action); +} + +export function maximumCatalogQuantity(availability: CatalogAvailability): number | null { + return availability.state === 'visible' ? availability.maximum_quantity : 0; +} + +export function combineCatalogAvailability( + product: CatalogAvailability, + variant?: CatalogAvailability | null, +): CatalogAvailability { + if (!variant) return product; + + const reasons = [...product.reasons, ...variant.reasons]; + if (product.state === 'hidden' || variant.state === 'hidden') { + return { state: 'hidden', reasons }; + } + + return { + state: 'visible', + maximum_quantity: minimumNullable(product.maximum_quantity, variant.maximum_quantity), + allowed_actions: product.allowed_actions.filter((action) => + variant.allowed_actions.includes(action), + ), + reasons, + }; +} + +function minimumNullable(left: number | null, right: number | null): number | null { + if (left === null) return right; + if (right === null) return left; + return Math.min(left, right); } diff --git a/src/app/core/services/catalog/catalog.interface.ts b/src/app/core/services/catalog/catalog.interface.ts index cbcbf5d..43c6174 100644 --- a/src/app/core/services/catalog/catalog.interface.ts +++ b/src/app/core/services/catalog/catalog.interface.ts @@ -54,20 +54,19 @@ export interface CatalogRestriction { message: string; } -export interface CatalogCapabilities { - display: boolean; - select_variant: boolean; - change_quantity: boolean; - add_to_cart: boolean; - buy_now: boolean; -} +export type CatalogAction = 'select_variant' | 'change_quantity' | 'add_to_cart' | 'buy_now'; -export interface CatalogAvailability { - subject: 'product' | 'variant'; - maximum_quantity: number | null; - restrictions: CatalogRestriction[]; - capabilities: CatalogCapabilities; -} +export type CatalogAvailability = + | { + state: 'hidden'; + reasons: CatalogRestriction[]; + } + | { + state: 'visible'; + maximum_quantity: number | null; + allowed_actions: CatalogAction[]; + reasons: CatalogRestriction[]; + }; export interface CatalogVariantOption { value: string; diff --git a/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.ts b/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.ts index 6c27f76..760ac16 100644 --- a/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.ts +++ b/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.ts @@ -15,6 +15,7 @@ import { ProductAttribute, ProductAttributeOption, } from '../../../../core/services/catalog/catalog.interface'; +import { allowsCatalogAction } from '../../../../core/services/catalog/catalog-availability'; @Component({ selector: 'app-product-attribute-selector', @@ -242,7 +243,7 @@ export class ProductAttributeSelectorComponent { } private isVariantAvailable(variant: CatalogItemVariant): boolean { - return variant.availability.capabilities.select_variant; + return allowsCatalogAction(variant.availability, 'select_variant'); } private findFirstHexValue(value: unknown): string | null { diff --git a/src/app/features/store/pages/product-detail-page/product-detail-page.component.html b/src/app/features/store/pages/product-detail-page/product-detail-page.component.html index 9bc1c04..fc59efc 100644 --- a/src/app/features/store/pages/product-detail-page/product-detail-page.component.html +++ b/src/app/features/store/pages/product-detail-page/product-detail-page.component.html @@ -44,7 +44,7 @@ [variants]="prod.variants" [selectedVariant]="prod.selected_variant ?? null" [inventoryPolicy]="prod.inventory_policy" - [disabled]="!prod.availability.capabilities.select_variant" + [disabled]="!allows(prod.availability, 'select_variant')" (variantChange)="onVariantChange($event)" /> @@ -61,7 +61,7 @@
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 98f48ed..918b838 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 @@ -34,6 +34,9 @@ import { TenantService } from '../../../../core/services/tenant.service'; import { AuthService } from '../../../../core/services/auth/auth.service'; import { AVAILABLE_CATALOG_AVAILABILITY, + allowsCatalogAction, + combineCatalogAvailability, + maximumCatalogQuantity, primaryAvailabilityMessage, } from '../../../../core/services/catalog/catalog-availability'; @@ -105,7 +108,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { const variant = this.selectedVariant(); if (!prod) return AVAILABLE_CATALOG_AVAILABILITY; - return variant?.availability ?? prod.availability; + return combineCatalogAvailability(prod.availability, variant?.availability); }); protected readonly selectedVariantMax = computed(() => { const prod = this.product(); @@ -113,7 +116,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { if (!prod) return 0; if (!variant && prod.variants.length > 0) return 0; - return this.effectiveAvailability().maximum_quantity; + return maximumCatalogQuantity(this.effectiveAvailability()); }); protected readonly hasPurchasableSelection = computed(() => { const prod = this.product(); @@ -122,14 +125,19 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { return prod.variants.length === 0 || this.selectedVariant() !== null; }); protected readonly canAddToCart = computed( - () => this.hasPurchasableSelection() && this.effectiveAvailability().capabilities.add_to_cart, + () => + this.hasPurchasableSelection() && + allowsCatalogAction(this.effectiveAvailability(), 'add_to_cart'), ); protected readonly canBuyNow = computed( - () => this.hasPurchasableSelection() && this.effectiveAvailability().capabilities.buy_now, + () => + this.hasPurchasableSelection() && + allowsCatalogAction(this.effectiveAvailability(), 'buy_now'), ); protected readonly restrictionMessage = computed(() => primaryAvailabilityMessage(this.effectiveAvailability()), ); + protected readonly allows = allowsCatalogAction; protected readonly descriptionExpanded = signal(false); protected readonly descriptionMaxHeight = signal(0); protected readonly descriptionHasOverflow = signal(false); @@ -235,8 +243,12 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { this.quantity.set(Math.max(1, maximum)); } }, - error: () => { - // Keep the last known availability if the silent refresh fails. + error: (error: HttpErrorResponse) => { + if (error.status === 404) { + this.product.set(null); + this.selectedVariant.set(null); + this.error.set('Este producto ya no está disponible.'); + } }, }); } @@ -308,8 +320,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { const variant = this.selectedVariant(); if (!currentProduct || !this.canAddToCart()) { this.toastService.danger( - this.effectiveAvailability().restrictions[0]?.message ?? - 'Por favor, selecciona una variante.', + this.effectiveAvailability().reasons[0]?.message ?? 'Por favor, selecciona una variante.', ); return; } @@ -342,8 +353,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { if (!currentProduct || !this.canBuyNow()) { this.toastService.danger( - this.effectiveAvailability().restrictions[0]?.message ?? - 'Por favor, selecciona una variante.', + this.effectiveAvailability().reasons[0]?.message ?? 'Por favor, selecciona una variante.', ); return; } 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 4186ca9..7530c13 100644 --- a/src/app/shared/components/product-list/product-list.component.html +++ b/src/app/shared/components/product-list/product-list.component.html @@ -39,7 +39,7 @@ [price]="price(item)" [imageUrl]="loadImages() ? (item.image ?? null) : null" [unavailableMessage]="availabilityMessage(item)" - [disabled]="loading() || !itemAvailability(item).capabilities.buy_now" + [disabled]="loading() || !allows(itemAvailability(item), 'buy_now')" (buy)="emitTicketBuy(item, $event)" /> } 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 05a1d44..9dbdfc5 100644 --- a/src/app/shared/components/product-list/product-list.component.ts +++ b/src/app/shared/components/product-list/product-list.component.ts @@ -20,6 +20,7 @@ import { } from '../../../core/services/catalog/catalog.interface'; import { AVAILABLE_CATALOG_AVAILABILITY, + allowsCatalogAction, primaryAvailabilityMessage, } from '../../../core/services/catalog/catalog-availability'; import { CarouselComponent } from '../carousel/carousel.component'; @@ -78,6 +79,7 @@ export class ProductListComponent { readonly buy = output(); readonly addToCart = output(); readonly pageChange = output(); + protected readonly allows = allowsCatalogAction; protected readonly effectiveLayout = computed(() => this.mobile() && this.layout() === 'row' ? 'column_with_cart' : this.layout(), diff --git a/src/app/shared/components/product-row-card/product-row-card.component.html b/src/app/shared/components/product-row-card/product-row-card.component.html index 350736f..6335908 100644 --- a/src/app/shared/components/product-row-card/product-row-card.component.html +++ b/src/app/shared/components/product-row-card/product-row-card.component.html @@ -21,14 +21,14 @@
@@ -41,7 +41,7 @@
Comprar @@ -53,7 +53,7 @@ [disabled]=" saving() || !hasPurchasableSelection() || - !effectiveAvailability().capabilities.add_to_cart + !allows(effectiveAvailability(), 'add_to_cart') " (click)="onAddToCart()" > diff --git a/src/app/shared/components/product-row-card/product-row-card.component.ts b/src/app/shared/components/product-row-card/product-row-card.component.ts index fb50d3d..2bf7af8 100644 --- a/src/app/shared/components/product-row-card/product-row-card.component.ts +++ b/src/app/shared/components/product-row-card/product-row-card.component.ts @@ -17,6 +17,9 @@ import { import { CatalogAvailability } from '../../../core/services/catalog/catalog.interface'; import { AVAILABLE_CATALOG_AVAILABILITY, + allowsCatalogAction, + combineCatalogAvailability, + maximumCatalogQuantity, primaryAvailabilityMessage, } from '../../../core/services/catalog/catalog-availability'; @@ -63,24 +66,22 @@ export class ProductRowCardComponent { return Number.isFinite(variantPrice) ? variantPrice : this.price(); }); - protected readonly effectiveAvailability = computed( - () => - this.selectedVariantData()?.availability ?? - this.availability() ?? - AVAILABLE_CATALOG_AVAILABILITY, + protected readonly effectiveAvailability = computed(() => + combineCatalogAvailability(this.availability(), this.selectedVariantData()?.availability), ); protected readonly hasVariants = computed(() => this.variants().length > 0); protected readonly hasPurchasableSelection = computed( () => !this.hasVariants() || this.selectedVariantData() !== undefined, ); - protected readonly effectiveMaximum = computed( - () => this.effectiveAvailability().maximum_quantity, + protected readonly effectiveMaximum = computed(() => + maximumCatalogQuantity(this.effectiveAvailability()), ); protected readonly effectiveUnavailableMessage = computed(() => primaryAvailabilityMessage(this.effectiveAvailability()), ); readonly formattedPrice = computed(() => this.formatCurrency(this.effectivePrice())); + protected readonly allows = allowsCatalogAction; constructor() { effect(() => { @@ -96,7 +97,7 @@ export class ProductRowCardComponent { if ( this.saving() || !this.hasPurchasableSelection() || - !this.effectiveAvailability().capabilities.add_to_cart + !this.allows(this.effectiveAvailability(), 'add_to_cart') ) { return; } @@ -108,7 +109,7 @@ export class ProductRowCardComponent { } protected onBuy(): void { - if (!this.hasPurchasableSelection() || !this.effectiveAvailability().capabilities.buy_now) + if (!this.hasPurchasableSelection() || !this.allows(this.effectiveAvailability(), 'buy_now')) return; this.buy.emit({ 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 95a6e4f..1c66c85 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 @@ -23,7 +23,10 @@ import { CatalogVariantSelector, CatalogVariantValue, } from '../../../core/services/catalog/catalog.interface'; -import { createCatalogAvailability } from '../../../core/services/catalog/catalog-availability'; +import { + allowsCatalogAction, + createCatalogAvailability, +} from '../../../core/services/catalog/catalog-availability'; import { CatalogService } from '../../../core/services/catalog/catalog.service'; import { ModalService } from '../../../core/services/modal.service'; import { ToastService } from '../../../core/services/toast.service'; @@ -356,7 +359,8 @@ export class ProductTicketSelectorComponent { } return variants.filter( ({ id, availability }) => - availability?.capabilities.select_variant !== false && !reservedByOtherRows.has(id), + (availability === undefined || allowsCatalogAction(availability, 'select_variant')) && + !reservedByOtherRows.has(id), ); } @@ -367,7 +371,9 @@ export class ProductTicketSelectorComponent { return { id: item.variant.id, precio: item.variant.precio, - availability: createCatalogAvailability(item.variant.stock_tecnico), + availability: createCatalogAvailability( + item.variant.stock_tecnico === null ? null : item.variant.stock_tecnico + item.cantidad, + ), values: item.variant.values, }; } diff --git a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.html b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.html index 9c1fa8e..8fe9a7e 100644 --- a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.html +++ b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.html @@ -19,7 +19,7 @@
} @else { @@ -29,14 +29,14 @@
@@ -47,7 +47,7 @@ this.formatCurrency(this.effectivePrice())); protected readonly hasVariants = computed(() => this.variants().length > 0); - protected readonly effectiveAvailability = computed( - () => - this.selectedVariantData()?.availability ?? - this.availability() ?? - AVAILABLE_CATALOG_AVAILABILITY, + protected readonly effectiveAvailability = computed(() => + combineCatalogAvailability(this.availability(), this.selectedVariantData()?.availability), ); - protected readonly effectiveMaximum = computed( - () => this.effectiveAvailability().maximum_quantity, + protected readonly effectiveMaximum = computed(() => + maximumCatalogQuantity(this.effectiveAvailability()), ); protected readonly effectiveUnavailableMessage = computed(() => primaryAvailabilityMessage(this.effectiveAvailability()), ); + protected readonly allows = allowsCatalogAction; constructor() { effect(() => { @@ -85,7 +86,7 @@ export class ProductVerticalWithCartCardComponent { } protected onAddToCart(): void { - if (this.saving() || !this.effectiveAvailability().capabilities.add_to_cart) { + if (this.saving() || !this.allows(this.effectiveAvailability(), 'add_to_cart')) { return; } @@ -93,7 +94,7 @@ export class ProductVerticalWithCartCardComponent { } protected onBuy(): void { - if (!this.effectiveAvailability().capabilities.buy_now) return; + if (!this.allows(this.effectiveAvailability(), 'buy_now')) return; this.buy.emit({ quantity: this.quantity(), variant: this.selectedVariant() }); } diff --git a/src/app/shared/components/variant-selector/variant-selector.component.ts b/src/app/shared/components/variant-selector/variant-selector.component.ts index acd7c30..db3cde7 100644 --- a/src/app/shared/components/variant-selector/variant-selector.component.ts +++ b/src/app/shared/components/variant-selector/variant-selector.component.ts @@ -10,6 +10,8 @@ import { untracked, } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { CatalogAvailability } from '../../../core/services/catalog/catalog.interface'; +import { allowsCatalogAction } from '../../../core/services/catalog/catalog-availability'; export interface VariantAttributeOption { value: string; @@ -22,12 +24,7 @@ export type VariantAttributeValue = VariantAttributeScalar | VariantAttributeSca export interface VariantSelectorVariant { id: unknown; values: Record; - availability?: { - capabilities: { - display: boolean; - select_variant: boolean; - }; - }; + availability?: CatalogAvailability; } export interface VariantSelectorSelectionChange { @@ -208,7 +205,9 @@ export class VariantSelectorComponent { private getSelectableVariants(): VariantSelectorVariant[] { return this.variants().filter( - (variant) => variant.availability?.capabilities.select_variant !== false, + (variant) => + variant.availability === undefined || + allowsCatalogAction(variant.availability, 'select_variant'), ); }