From 5ca1f03e203e0fbc7dff6c1be635b995f46203bc Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 24 Aug 2026 14:33:11 -0300 Subject: [PATCH] feat(catalog): consume availability capabilities --- .../services/catalog/catalog-availability.ts | 39 +++++++++++++ .../services/catalog/catalog.interface.ts | 30 +++++++--- .../reutilizables-test-page.component.ts | 21 +++---- .../product-attribute-selector.component.html | 4 +- .../product-attribute-selector.component.ts | 43 ++++++++++----- .../product-detail-page.component.html | 17 ++++-- .../product-detail-page.component.ts | 55 ++++++++++++------- .../product-list/product-list.component.html | 12 ++-- .../product-list/product-list.component.ts | 12 ++++ .../product-row-card.component.html | 15 ++++- .../product-row-card.component.ts | 45 +++++++++------ .../product-ticket-selector.component.ts | 8 ++- ...uct-vertical-with-cart-card.component.html | 21 +++++-- ...oduct-vertical-with-cart-card.component.ts | 36 ++++++------ .../variant-selector.component.ts | 21 +++++-- 15 files changed, 267 insertions(+), 112 deletions(-) create mode 100644 src/app/core/services/catalog/catalog-availability.ts diff --git a/src/app/core/services/catalog/catalog-availability.ts b/src/app/core/services/catalog/catalog-availability.ts new file mode 100644 index 0000000..03d3724 --- /dev/null +++ b/src/app/core/services/catalog/catalog-availability.ts @@ -0,0 +1,39 @@ +import { CatalogAvailability } from './catalog.interface'; + +export const AVAILABLE_CATALOG_AVAILABILITY: CatalogAvailability = { + maximum_quantity: null, + restrictions: [], + capabilities: { + select_variant: true, + change_quantity: true, + add_to_cart: true, + buy_now: true, + }, +}; + +export function createCatalogAvailability(maximumQuantity: number | null): CatalogAvailability { + const unavailable = maximumQuantity === 0; + + return { + maximum_quantity: maximumQuantity, + restrictions: unavailable + ? [ + { + code: 'out_of_stock', + message: 'Este producto no tiene stock disponible.', + scope: 'product', + }, + ] + : [], + capabilities: { + select_variant: !unavailable, + change_quantity: !unavailable, + add_to_cart: !unavailable, + buy_now: !unavailable, + }, + }; +} + +export function primaryAvailabilityMessage(availability: CatalogAvailability): string | null { + return availability.restrictions[0]?.message ?? null; +} diff --git a/src/app/core/services/catalog/catalog.interface.ts b/src/app/core/services/catalog/catalog.interface.ts index 009de6b..98cdd08 100644 --- a/src/app/core/services/catalog/catalog.interface.ts +++ b/src/app/core/services/catalog/catalog.interface.ts @@ -49,6 +49,25 @@ export interface ProductAttribute { export type InventoryPolicy = 'tracked' | 'unlimited'; +export interface CatalogRestriction { + code: 'out_of_stock' | 'user_quota_reached' | string; + message: string; + scope: 'product' | 'variant' | string; +} + +export interface CatalogCapabilities { + select_variant: boolean; + change_quantity: boolean; + add_to_cart: boolean; + buy_now: boolean; +} + +export interface CatalogAvailability { + maximum_quantity: number | null; + restrictions: CatalogRestriction[]; + capabilities: CatalogCapabilities; +} + export interface CatalogVariantOption { value: string; label: string; @@ -66,8 +85,7 @@ export interface CatalogItemVariant { event_date_id?: number | null; event_date_ids?: number[]; event_dates?: string[]; - maximum_addable_quantity?: number | null; - unavailable_message?: string | null; + availability: CatalogAvailability; minimum_use_date?: string | null; maximum_use_date?: string | null; effective_minimum_use_date?: string | null; @@ -99,7 +117,7 @@ export interface CatalogItemDetail { attributes: ProductAttribute[]; variants: CatalogItemVariant[]; selected_variant?: SelectedCatalogItemVariant; - maximum_addable_quantity?: number | null; + availability: CatalogAvailability; images?: string[]; } @@ -114,8 +132,7 @@ export interface CatalogFeaturedItemVariant { id: number; descripcion?: string | null; precio?: string; - maximum_addable_quantity?: number | null; - unavailable_message?: string | null; + availability: CatalogAvailability; values: Record; } @@ -147,8 +164,7 @@ export interface CatalogFeaturedItem { descripcion?: string | null; precio: number | string; image?: string | null; - maximum_addable_quantity?: number | null; - unavailable_message?: string | null; + availability: CatalogAvailability; variants?: CatalogFeaturedItemVariant[]; } diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts index 1e5e77e..c3bbbd2 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts @@ -13,6 +13,7 @@ import { StoreSectionComponent } from '../../../../shared/components/store-secti import { ModalService } from '../../../../core/services/modal.service'; import { TenantService } from '../../../../core/services/tenant.service'; import { ToastService } from '../../../../core/services/toast.service'; +import { createCatalogAvailability } from '../../../../core/services/catalog/catalog-availability'; import { StepperComponent } from '../../../../shared/components/stepper/stepper.component'; import { StepComponent } from '../../../../shared/components/stepper/step.component'; import { CarouselComponent } from '../../../../shared/components/carousel/carousel.component'; @@ -232,7 +233,7 @@ export class ReutilizablesTestPageComponent { { id: 1001, precio: 250000, - maximum_addable_quantity: 1, + availability: createCatalogAvailability(1), values: { tipo: ticketOption('vip_lunch', 'VIP + Lunch'), sector: ticketOption('a', 'Sector A'), @@ -243,7 +244,7 @@ export class ReutilizablesTestPageComponent { { id: 1002, precio: 250000, - maximum_addable_quantity: 1, + availability: createCatalogAvailability(1), values: { tipo: ticketOption('vip_lunch', 'VIP + Lunch'), sector: ticketOption('a', 'Sector A'), @@ -254,7 +255,7 @@ export class ReutilizablesTestPageComponent { { id: 1003, precio: 250000, - maximum_addable_quantity: 1, + availability: createCatalogAvailability(1), values: { tipo: ticketOption('vip_lunch', 'VIP + Lunch'), sector: ticketOption('c', 'Sector C'), @@ -265,7 +266,7 @@ export class ReutilizablesTestPageComponent { { id: 1004, precio: 200000, - maximum_addable_quantity: 1, + availability: createCatalogAvailability(1), values: { tipo: ticketOption('vip_lunch', 'VIP + Lunch'), sector: ticketOption('a', 'Sector A'), @@ -276,7 +277,7 @@ export class ReutilizablesTestPageComponent { { id: 1005, precio: 200000, - maximum_addable_quantity: 0, + availability: createCatalogAvailability(0), values: { tipo: ticketOption('vip_lunch', 'VIP + Lunch'), sector: ticketOption('a', 'Sector A'), @@ -287,7 +288,7 @@ export class ReutilizablesTestPageComponent { { id: 1006, precio: 100000, - maximum_addable_quantity: 1, + availability: createCatalogAvailability(1), values: { tipo: ticketOption('general', 'General'), sector: ticketOption('b', 'Sector B'), @@ -298,7 +299,7 @@ export class ReutilizablesTestPageComponent { { id: 1007, precio: 100000, - maximum_addable_quantity: 1, + availability: createCatalogAvailability(1), values: { tipo: ticketOption('general', 'General'), sector: ticketOption('b', 'Sector B'), @@ -309,7 +310,7 @@ export class ReutilizablesTestPageComponent { { id: 1008, precio: 90000, - maximum_addable_quantity: 1, + availability: createCatalogAvailability(1), values: { tipo: ticketOption('general', 'General'), sector: ticketOption('d', 'Sector D'), @@ -320,7 +321,7 @@ export class ReutilizablesTestPageComponent { { id: 1009, precio: 65000, - maximum_addable_quantity: 1, + availability: createCatalogAvailability(1), values: { tipo: ticketOption('general', 'General'), sector: ticketOption('d', 'Sector D'), @@ -331,7 +332,7 @@ export class ReutilizablesTestPageComponent { { id: 1010, precio: 40000, - maximum_addable_quantity: 1, + availability: createCatalogAvailability(1), values: { tipo: ticketOption('general', 'General'), sector: ticketOption('d', 'Sector D'), diff --git a/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.html b/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.html index 39d5dc2..895f43c 100644 --- a/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.html +++ b/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.html @@ -17,7 +17,7 @@ [attr.aria-label]="option.label" [attr.aria-pressed]="hasSelectedOption(attribute, option)" [title]="option.label" - [disabled]="!availableOptions()[attribute.codigo][option.id]" + [disabled]="disabled() || !availableOptions()[attribute.codigo][option.id]" (click)="selectAttributeOption(attribute, option)" > {{ option.label }} @@ -33,7 +33,7 @@ !availableOptions()[attribute.codigo][option.id] " [attr.aria-pressed]="hasSelectedOption(attribute, option)" - [disabled]="!availableOptions()[attribute.codigo][option.id]" + [disabled]="disabled() || !availableOptions()[attribute.codigo][option.id]" (click)="selectAttributeOption(attribute, option)" > {{ option.label }} 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 0308d57..6c27f76 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 @@ -29,6 +29,7 @@ export class ProductAttributeSelectorComponent { public variants = input([]); public selectedVariant = input(null); public inventoryPolicy = input.required(); + public disabled = input(false); public variantChange = output(); @@ -51,22 +52,15 @@ export class ProductAttributeSelectorComponent { const optionNormalized = this.normalizeText(option.value || option.label); const selectedForAttribute = selections[attribute.codigo] ?? []; - if ( - !attribute.allow_multi_select && - selectedForAttribute.length >= 1 && - !selectedForAttribute.includes(option.id) - ) { - availability[attribute.codigo][option.id] = false; - continue; - } - const isAvailable = variants.some((variant) => { if (!this.isVariantAvailable(variant)) return false; const variantAttrValues = this.getVariantAttributeValues(attribute, variant.values); - const desiredOptionIds = selectedForAttribute.includes(option.id) - ? selectedForAttribute - : [...selectedForAttribute, option.id]; + const desiredOptionIds = attribute.allow_multi_select + ? selectedForAttribute.includes(option.id) + ? selectedForAttribute + : [...selectedForAttribute, option.id] + : [option.id]; const desiredValues = desiredOptionIds .map((id) => attribute.options.find((candidate) => candidate.id === id)) .filter((candidate): candidate is ProductAttributeOption => candidate !== undefined) @@ -137,6 +131,8 @@ export class ProductAttributeSelectorComponent { attribute: ProductAttribute, option: ProductAttributeOption, ): void { + if (this.disabled()) return; + this.selectedAttributeOptions.update((current) => { const selected = current[attribute.codigo] ?? []; const isMultiple = attribute.allow_multi_select ?? false; @@ -186,7 +182,15 @@ export class ProductAttributeSelectorComponent { return []; } - const defaultValues = this.getVariantAttributeValues(attribute, variant.values); + const defaultValues = + attribute.type === 'event_date' + ? ( + variant.event_date_ids ?? + (variant.event_date_id === null || variant.event_date_id === undefined + ? [] + : [variant.event_date_id]) + ).map(String) + : this.getVariantAttributeValues(attribute, variant.values); if (defaultValues.length === 0) { return []; } @@ -215,6 +219,17 @@ export class ProductAttributeSelectorComponent { } } + if (attribute.type === 'event_date') { + const variant = this.variants().find((candidate) => candidate.values === variantAttributes); + const eventDateIds = + variant?.event_date_ids ?? + (variant?.event_date_id === null || variant?.event_date_id === undefined + ? [] + : [variant.event_date_id]); + + return eventDateIds.map(String); + } + return []; } @@ -227,7 +242,7 @@ export class ProductAttributeSelectorComponent { } private isVariantAvailable(variant: CatalogItemVariant): boolean { - return variant.maximum_addable_quantity !== 0; + return variant.availability.capabilities.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 ff6b6ce..9bc1c04 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,6 +44,7 @@ [variants]="prod.variants" [selectedVariant]="prod.selected_variant ?? null" [inventoryPolicy]="prod.inventory_policy" + [disabled]="!prod.availability.capabilities.select_variant" (variantChange)="onVariantChange($event)" /> @@ -53,14 +54,22 @@
- + @if (restrictionMessage(); as message) { +

{{ message }}

+ } + +
@if (addingToCart()) { @@ -75,9 +84,7 @@ @if (variantLoading() || creatingDirectPurchase()) { 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 0e869a6..98f48ed 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 @@ -32,6 +32,10 @@ import { ProductDetailResolvedData } from './product-detail-page.resolver'; import { CheckoutService } from '../../../../core/services/checkout.service'; import { TenantService } from '../../../../core/services/tenant.service'; import { AuthService } from '../../../../core/services/auth/auth.service'; +import { + AVAILABLE_CATALOG_AVAILABILITY, + primaryAvailabilityMessage, +} from '../../../../core/services/catalog/catalog-availability'; @Component({ selector: 'app-product-detail-page', @@ -96,29 +100,36 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { () => this.selectedVariant()?.precio ?? this.product()?.precio, ); protected readonly quantity = signal(1); + protected readonly effectiveAvailability = computed(() => { + const prod = this.product(); + const variant = this.selectedVariant(); + if (!prod) return AVAILABLE_CATALOG_AVAILABILITY; + + return variant?.availability ?? prod.availability; + }); protected readonly selectedVariantMax = computed(() => { const prod = this.product(); const variant = this.selectedVariant(); if (!prod) return 0; + if (!variant && prod.variants.length > 0) return 0; - return variant - ? (variant.maximum_addable_quantity ?? null) - : prod.variants.length === 0 - ? (prod.maximum_addable_quantity ?? null) - : 0; + return this.effectiveAvailability().maximum_quantity; }); - protected readonly selectedVariantAvailable = computed(() => { + protected readonly hasPurchasableSelection = computed(() => { const prod = this.product(); if (!prod) return false; - if (this.selectedVariantMax() === 0) return false; - const variant = this.selectedVariant(); - if (variant) return this.isVariantAvailable(variant); - if (prod.purpose === 'entry') return false; - if (prod.variants.length > 0) return false; - - return this.selectedVariantMax() !== 0; + return prod.variants.length === 0 || this.selectedVariant() !== null; }); + protected readonly canAddToCart = computed( + () => this.hasPurchasableSelection() && this.effectiveAvailability().capabilities.add_to_cart, + ); + protected readonly canBuyNow = computed( + () => this.hasPurchasableSelection() && this.effectiveAvailability().capabilities.buy_now, + ); + protected readonly restrictionMessage = computed(() => + primaryAvailabilityMessage(this.effectiveAvailability()), + ); protected readonly descriptionExpanded = signal(false); protected readonly descriptionMaxHeight = signal(0); protected readonly descriptionHasOverflow = signal(false); @@ -295,8 +306,11 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { protected addToCart(): void { const currentProduct = this.product(); const variant = this.selectedVariant(); - if (!currentProduct || !this.selectedVariantAvailable()) { - this.toastService.danger('Por favor, selecciona una variante.'); + if (!currentProduct || !this.canAddToCart()) { + this.toastService.danger( + this.effectiveAvailability().restrictions[0]?.message ?? + 'Por favor, selecciona una variante.', + ); return; } @@ -326,8 +340,11 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { return; } - if (!currentProduct || !this.selectedVariantAvailable()) { - this.toastService.danger('Por favor, selecciona una variante.'); + if (!currentProduct || !this.canBuyNow()) { + this.toastService.danger( + this.effectiveAvailability().restrictions[0]?.message ?? + 'Por favor, selecciona una variante.', + ); return; } @@ -373,10 +390,6 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy { } } - private isVariantAvailable(variant: CatalogItemVariant): boolean { - return variant.maximum_addable_quantity !== 0; - } - protected toggleDescription(): void { this.descriptionExpanded.update((current) => !current); } 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 b32df87..4186ca9 100644 --- a/src/app/shared/components/product-list/product-list.component.html +++ b/src/app/shared/components/product-list/product-list.component.html @@ -12,8 +12,7 @@ [title]="item.nombre" [description]="item.descripcion ?? ''" [price]="price(item)" - [maximumAddableQuantity]="item.maximum_addable_quantity ?? null" - [unavailableMessage]="item.unavailable_message ?? null" + [availability]="itemAvailability(item)" [variants]="item.variants ?? []" [saving]="savingProductIds().has(item.id)" (buy)="emitRowBuy(item, $event)" @@ -25,8 +24,7 @@ [title]="item.nombre" [description]="item.descripcion ?? ''" [price]="price(item)" - [maximumAddableQuantity]="item.maximum_addable_quantity ?? null" - [unavailableMessage]="item.unavailable_message ?? null" + [availability]="itemAvailability(item)" [variants]="item.variants ?? []" [saving]="savingProductIds().has(item.id)" (buy)="emitColumnBuy(item, $event)" @@ -40,8 +38,8 @@ [description]="item.descripcion ?? ''" [price]="price(item)" [imageUrl]="loadImages() ? (item.image ?? null) : null" - [unavailableMessage]="item.unavailable_message ?? null" - [disabled]="loading() || !!item.unavailable_message" + [unavailableMessage]="availabilityMessage(item)" + [disabled]="loading() || !itemAvailability(item).capabilities.buy_now" (buy)="emitTicketBuy(item, $event)" /> } @@ -50,7 +48,7 @@ [imageUrl]="loadImages() ? (item.image ?? null) : null" [title]="item.nombre" [originalPrice]="price(item)" - [unavailableMessage]="item.unavailable_message ?? null" + [unavailableMessage]="availabilityMessage(item)" [imagePriority]="loadImages() && index < 4" (buy)="emitProductDetailBuy(item)" /> 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 e8c3139..05a1d44 100644 --- a/src/app/shared/components/product-list/product-list.component.ts +++ b/src/app/shared/components/product-list/product-list.component.ts @@ -18,6 +18,10 @@ import { CatalogGroupLayout, CatalogProductLayout, } from '../../../core/services/catalog/catalog.interface'; +import { + AVAILABLE_CATALOG_AVAILABILITY, + primaryAvailabilityMessage, +} from '../../../core/services/catalog/catalog-availability'; import { CarouselComponent } from '../carousel/carousel.component'; import { PaginatorComponent } from '../paginator/paginator.component'; import { ProductColumnWithImageComponent } from '../product-column-with-image/product-column-with-image.component'; @@ -108,6 +112,14 @@ export class ProductListComponent { return Number.isFinite(price) ? price : 0; } + protected availabilityMessage(item: ProductListItem): string | null { + return primaryAvailabilityMessage(this.itemAvailability(item)); + } + + protected itemAvailability(item: ProductListItem) { + return item.availability ?? AVAILABLE_CATALOG_AVAILABILITY; + } + protected emitRowCart( product: ProductListItem, event: { quantity: number; variant: unknown }, 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 f383578..350736f 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,13 +21,14 @@
@@ -38,14 +39,22 @@
- + Comprar
{{ saving() ? 'Guardando' : 'Agregar al carrito' }} 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 ec9e699..fb50d3d 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 @@ -14,13 +14,17 @@ import { VariantSelectorComponent, VariantSelectorVariant, } from '../variant-selector/variant-selector.component'; +import { CatalogAvailability } from '../../../core/services/catalog/catalog.interface'; +import { + AVAILABLE_CATALOG_AVAILABILITY, + primaryAvailabilityMessage, +} from '../../../core/services/catalog/catalog-availability'; export interface Variant extends VariantSelectorVariant { label?: string; descripcion?: string | null; precio?: string | number; - maximum_addable_quantity?: number | null; - unavailable_message?: string | null; + availability?: CatalogAvailability; } @Component({ @@ -36,8 +40,7 @@ export class ProductRowCardComponent { readonly title = input(''); readonly description = input(''); readonly price = input(0); - readonly maximumAddableQuantity = input(null); - readonly unavailableMessage = input(null); + readonly availability = input(AVAILABLE_CATALOG_AVAILABILITY); readonly variants = input([]); readonly saving = input(false); @@ -60,17 +63,22 @@ export class ProductRowCardComponent { return Number.isFinite(variantPrice) ? variantPrice : this.price(); }); - protected readonly effectiveMaximum = computed( - () => this.selectedVariantData()?.maximum_addable_quantity ?? this.maximumAddableQuantity(), + protected readonly effectiveAvailability = computed( + () => + this.selectedVariantData()?.availability ?? + this.availability() ?? + AVAILABLE_CATALOG_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 effectiveUnavailableMessage = computed(() => + primaryAvailabilityMessage(this.effectiveAvailability()), ); - protected readonly unavailable = computed(() => this.effectiveMaximum() === 0); - protected readonly effectiveUnavailableMessage = computed(() => { - const selectedVariant = this.selectedVariantData(); - - return selectedVariant - ? (selectedVariant.unavailable_message ?? null) - : this.unavailableMessage(); - }); readonly formattedPrice = computed(() => this.formatCurrency(this.effectivePrice())); @@ -85,7 +93,11 @@ export class ProductRowCardComponent { } protected onAddToCart(): void { - if (this.saving() || this.unavailable()) { + if ( + this.saving() || + !this.hasPurchasableSelection() || + !this.effectiveAvailability().capabilities.add_to_cart + ) { return; } @@ -96,7 +108,8 @@ export class ProductRowCardComponent { } protected onBuy(): void { - if (this.unavailable()) return; + if (!this.hasPurchasableSelection() || !this.effectiveAvailability().capabilities.buy_now) + return; this.buy.emit({ quantity: this.quantity(), 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 f420a03..95a6e4f 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,6 +23,7 @@ import { CatalogVariantSelector, CatalogVariantValue, } from '../../../core/services/catalog/catalog.interface'; +import { 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'; @@ -353,7 +354,10 @@ export class ProductTicketSelectorComponent { if (reservedVariant !== null && !variants.some(({ id }) => id === reservedVariant.id)) { variants.push(reservedVariant); } - return variants.filter(({ id }) => !reservedByOtherRows.has(id)); + return variants.filter( + ({ id, availability }) => + availability?.capabilities.select_variant !== false && !reservedByOtherRows.has(id), + ); } private reservedVariantData(row: TicketSelectionRow): CatalogFeaturedItemVariant | null { @@ -363,7 +367,7 @@ export class ProductTicketSelectorComponent { return { id: item.variant.id, precio: item.variant.precio, - maximum_addable_quantity: item.variant.stock_tecnico, + availability: createCatalogAvailability(item.variant.stock_tecnico), 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 ceba58a..9c1fa8e 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,12 +29,16 @@
- +
} @@ -42,14 +46,21 @@
Comprar {{ saving() ? 'Guardando' : 'Agregar al carrito' }} diff --git a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.ts b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.ts index e5306bc..28f3d00 100644 --- a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.ts +++ b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.ts @@ -15,12 +15,16 @@ import { VariantSelectorComponent, VariantSelectorVariant, } from '../variant-selector/variant-selector.component'; +import { CatalogAvailability } from '../../../core/services/catalog/catalog.interface'; +import { + AVAILABLE_CATALOG_AVAILABILITY, + primaryAvailabilityMessage, +} from '../../../core/services/catalog/catalog-availability'; export interface VerticalCartVariant extends VariantSelectorVariant { descripcion?: string | null; precio?: string | number; - maximum_addable_quantity?: number | null; - unavailable_message?: string | null; + availability?: CatalogAvailability; } @Component({ @@ -34,8 +38,7 @@ export class ProductVerticalWithCartCardComponent { readonly title = input(''); readonly description = input(''); readonly price = input(0); - readonly maximumAddableQuantity = input(null); - readonly unavailableMessage = input(null); + readonly availability = input(AVAILABLE_CATALOG_AVAILABILITY); readonly variants = input([]); readonly saving = input(false); @@ -58,17 +61,18 @@ export class ProductVerticalWithCartCardComponent { }); protected readonly formattedPrice = computed(() => this.formatCurrency(this.effectivePrice())); protected readonly hasVariants = computed(() => this.variants().length > 0); - protected readonly effectiveMaximum = computed( - () => this.selectedVariantData()?.maximum_addable_quantity ?? this.maximumAddableQuantity(), + protected readonly effectiveAvailability = computed( + () => + this.selectedVariantData()?.availability ?? + this.availability() ?? + AVAILABLE_CATALOG_AVAILABILITY, + ); + protected readonly effectiveMaximum = computed( + () => this.effectiveAvailability().maximum_quantity, + ); + protected readonly effectiveUnavailableMessage = computed(() => + primaryAvailabilityMessage(this.effectiveAvailability()), ); - protected readonly unavailable = computed(() => this.effectiveMaximum() === 0); - protected readonly effectiveUnavailableMessage = computed(() => { - const selectedVariant = this.selectedVariantData(); - - return selectedVariant - ? (selectedVariant.unavailable_message ?? null) - : this.unavailableMessage(); - }); constructor() { effect(() => { @@ -81,7 +85,7 @@ export class ProductVerticalWithCartCardComponent { } protected onAddToCart(): void { - if (this.saving() || this.unavailable()) { + if (this.saving() || !this.effectiveAvailability().capabilities.add_to_cart) { return; } @@ -89,7 +93,7 @@ export class ProductVerticalWithCartCardComponent { } protected onBuy(): void { - if (this.unavailable()) return; + if (!this.effectiveAvailability().capabilities.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 58b09fd..2eae441 100644 --- a/src/app/shared/components/variant-selector/variant-selector.component.ts +++ b/src/app/shared/components/variant-selector/variant-selector.component.ts @@ -22,6 +22,11 @@ export type VariantAttributeValue = VariantAttributeScalar | VariantAttributeSca export interface VariantSelectorVariant { id: unknown; values: Record; + availability?: { + capabilities: { + select_variant: boolean; + }; + }; } export interface VariantSelectorSelectionChange { @@ -64,10 +69,12 @@ export class VariantSelectorComponent { right: VariantAttributeValue | null, ): boolean => left !== null && right !== null && this.sameValue(left, right); protected readonly attributeKeys = computed(() => - Array.from(new Set(this.variants().flatMap((variant) => Object.keys(variant.values)))), + Array.from( + new Set(this.getSelectableVariants().flatMap((variant) => Object.keys(variant.values))), + ), ); protected readonly selectors = computed(() => { - const variants = this.variants(); + const variants = this.getSelectableVariants(); const keys = this.attributeKeys(); const selectedValues = this.selectedValues(); @@ -110,7 +117,7 @@ export class VariantSelectorComponent { }); effect(() => { - const variants = this.variants(); + const variants = this.getSelectableVariants(); const selectedVariant = this.selectedVariant(); const autoSelectFirst = this.autoSelectFirst(); @@ -139,7 +146,7 @@ export class VariantSelectorComponent { } protected onValueChange(key: string, value: VariantAttributeValue | null): void { - const variants = this.variants(); + const variants = this.getSelectableVariants(); const keys = this.attributeKeys(); const changedIndex = keys.indexOf(key); const values = { ...this.selectedValues() }; @@ -198,6 +205,12 @@ export class VariantSelectorComponent { return Array.from(options.values()); } + private getSelectableVariants(): VariantSelectorVariant[] { + return this.variants().filter( + (variant) => variant.availability?.capabilities.select_variant !== false, + ); + } + private reconcileManualSelection( selectedValues: Record, variants: VariantSelectorVariant[],