feat(catalog): consume global availability decisions

This commit is contained in:
2026-08-25 09:03:23 -03:00
parent 3ec469a539
commit 5273459b64
13 changed files with 149 additions and 95 deletions

View File

@@ -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 = { export const AVAILABLE_CATALOG_AVAILABILITY: CatalogAvailability = {
subject: 'product', state: 'visible',
maximum_quantity: null, maximum_quantity: null,
restrictions: [], allowed_actions: ALL_CATALOG_ACTIONS,
capabilities: { reasons: [],
display: true,
select_variant: true,
change_quantity: true,
add_to_cart: true,
buy_now: true,
},
}; };
export function createCatalogAvailability( export function createCatalogAvailability(maximumQuantity: number | null): CatalogAvailability {
maximumQuantity: number | null,
subject: CatalogAvailability['subject'] = 'product',
): CatalogAvailability {
const unavailable = maximumQuantity === 0; const unavailable = maximumQuantity === 0;
if (unavailable) {
return {
state: 'hidden',
reasons: [
{
code: 'out_of_stock',
message: 'Este producto no tiene stock disponible.',
},
],
};
}
return { return {
subject, state: 'visible',
maximum_quantity: maximumQuantity, maximum_quantity: maximumQuantity,
restrictions: unavailable allowed_actions: [...ALL_CATALOG_ACTIONS],
? [ reasons: [],
{
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,
},
}; };
} }
export function primaryAvailabilityMessage(availability: CatalogAvailability): string | null { 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);
} }

View File

@@ -54,20 +54,19 @@ export interface CatalogRestriction {
message: string; message: string;
} }
export interface CatalogCapabilities { export type CatalogAction = 'select_variant' | 'change_quantity' | 'add_to_cart' | 'buy_now';
display: boolean;
select_variant: boolean;
change_quantity: boolean;
add_to_cart: boolean;
buy_now: boolean;
}
export interface CatalogAvailability { export type CatalogAvailability =
subject: 'product' | 'variant'; | {
maximum_quantity: number | null; state: 'hidden';
restrictions: CatalogRestriction[]; reasons: CatalogRestriction[];
capabilities: CatalogCapabilities; }
} | {
state: 'visible';
maximum_quantity: number | null;
allowed_actions: CatalogAction[];
reasons: CatalogRestriction[];
};
export interface CatalogVariantOption { export interface CatalogVariantOption {
value: string; value: string;

View File

@@ -15,6 +15,7 @@ import {
ProductAttribute, ProductAttribute,
ProductAttributeOption, ProductAttributeOption,
} from '../../../../core/services/catalog/catalog.interface'; } from '../../../../core/services/catalog/catalog.interface';
import { allowsCatalogAction } from '../../../../core/services/catalog/catalog-availability';
@Component({ @Component({
selector: 'app-product-attribute-selector', selector: 'app-product-attribute-selector',
@@ -242,7 +243,7 @@ export class ProductAttributeSelectorComponent {
} }
private isVariantAvailable(variant: CatalogItemVariant): boolean { private isVariantAvailable(variant: CatalogItemVariant): boolean {
return variant.availability.capabilities.select_variant; return allowsCatalogAction(variant.availability, 'select_variant');
} }
private findFirstHexValue(value: unknown): string | null { private findFirstHexValue(value: unknown): string | null {

View File

@@ -44,7 +44,7 @@
[variants]="prod.variants" [variants]="prod.variants"
[selectedVariant]="prod.selected_variant ?? null" [selectedVariant]="prod.selected_variant ?? null"
[inventoryPolicy]="prod.inventory_policy" [inventoryPolicy]="prod.inventory_policy"
[disabled]="!prod.availability.capabilities.select_variant" [disabled]="!allows(prod.availability, 'select_variant')"
(variantChange)="onVariantChange($event)" (variantChange)="onVariantChange($event)"
/> />
</section> </section>
@@ -61,7 +61,7 @@
<app-quantity-selector <app-quantity-selector
[(quantity)]="quantity" [(quantity)]="quantity"
[max]="selectedVariantMax()" [max]="selectedVariantMax()"
[disabled]="!effectiveAvailability().capabilities.change_quantity" [disabled]="!allows(effectiveAvailability(), 'change_quantity')"
/> />
<div class="product-detail__actions"> <div class="product-detail__actions">

View File

@@ -34,6 +34,9 @@ import { TenantService } from '../../../../core/services/tenant.service';
import { AuthService } from '../../../../core/services/auth/auth.service'; import { AuthService } from '../../../../core/services/auth/auth.service';
import { import {
AVAILABLE_CATALOG_AVAILABILITY, AVAILABLE_CATALOG_AVAILABILITY,
allowsCatalogAction,
combineCatalogAvailability,
maximumCatalogQuantity,
primaryAvailabilityMessage, primaryAvailabilityMessage,
} from '../../../../core/services/catalog/catalog-availability'; } from '../../../../core/services/catalog/catalog-availability';
@@ -105,7 +108,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
const variant = this.selectedVariant(); const variant = this.selectedVariant();
if (!prod) return AVAILABLE_CATALOG_AVAILABILITY; if (!prod) return AVAILABLE_CATALOG_AVAILABILITY;
return variant?.availability ?? prod.availability; return combineCatalogAvailability(prod.availability, variant?.availability);
}); });
protected readonly selectedVariantMax = computed<number | null>(() => { protected readonly selectedVariantMax = computed<number | null>(() => {
const prod = this.product(); const prod = this.product();
@@ -113,7 +116,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
if (!prod) return 0; if (!prod) return 0;
if (!variant && prod.variants.length > 0) return 0; if (!variant && prod.variants.length > 0) return 0;
return this.effectiveAvailability().maximum_quantity; return maximumCatalogQuantity(this.effectiveAvailability());
}); });
protected readonly hasPurchasableSelection = computed(() => { protected readonly hasPurchasableSelection = computed(() => {
const prod = this.product(); const prod = this.product();
@@ -122,14 +125,19 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
return prod.variants.length === 0 || this.selectedVariant() !== null; return prod.variants.length === 0 || this.selectedVariant() !== null;
}); });
protected readonly canAddToCart = computed( protected readonly canAddToCart = computed(
() => this.hasPurchasableSelection() && this.effectiveAvailability().capabilities.add_to_cart, () =>
this.hasPurchasableSelection() &&
allowsCatalogAction(this.effectiveAvailability(), 'add_to_cart'),
); );
protected readonly canBuyNow = computed( protected readonly canBuyNow = computed(
() => this.hasPurchasableSelection() && this.effectiveAvailability().capabilities.buy_now, () =>
this.hasPurchasableSelection() &&
allowsCatalogAction(this.effectiveAvailability(), 'buy_now'),
); );
protected readonly restrictionMessage = computed(() => protected readonly restrictionMessage = computed(() =>
primaryAvailabilityMessage(this.effectiveAvailability()), primaryAvailabilityMessage(this.effectiveAvailability()),
); );
protected readonly allows = allowsCatalogAction;
protected readonly descriptionExpanded = signal(false); protected readonly descriptionExpanded = signal(false);
protected readonly descriptionMaxHeight = signal(0); protected readonly descriptionMaxHeight = signal(0);
protected readonly descriptionHasOverflow = signal(false); protected readonly descriptionHasOverflow = signal(false);
@@ -235,8 +243,12 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
this.quantity.set(Math.max(1, maximum)); this.quantity.set(Math.max(1, maximum));
} }
}, },
error: () => { error: (error: HttpErrorResponse) => {
// Keep the last known availability if the silent refresh fails. 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(); const variant = this.selectedVariant();
if (!currentProduct || !this.canAddToCart()) { if (!currentProduct || !this.canAddToCart()) {
this.toastService.danger( this.toastService.danger(
this.effectiveAvailability().restrictions[0]?.message ?? this.effectiveAvailability().reasons[0]?.message ?? 'Por favor, selecciona una variante.',
'Por favor, selecciona una variante.',
); );
return; return;
} }
@@ -342,8 +353,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
if (!currentProduct || !this.canBuyNow()) { if (!currentProduct || !this.canBuyNow()) {
this.toastService.danger( this.toastService.danger(
this.effectiveAvailability().restrictions[0]?.message ?? this.effectiveAvailability().reasons[0]?.message ?? 'Por favor, selecciona una variante.',
'Por favor, selecciona una variante.',
); );
return; return;
} }

View File

@@ -39,7 +39,7 @@
[price]="price(item)" [price]="price(item)"
[imageUrl]="loadImages() ? (item.image ?? null) : null" [imageUrl]="loadImages() ? (item.image ?? null) : null"
[unavailableMessage]="availabilityMessage(item)" [unavailableMessage]="availabilityMessage(item)"
[disabled]="loading() || !itemAvailability(item).capabilities.buy_now" [disabled]="loading() || !allows(itemAvailability(item), 'buy_now')"
(buy)="emitTicketBuy(item, $event)" (buy)="emitTicketBuy(item, $event)"
/> />
} }

View File

@@ -20,6 +20,7 @@ import {
} from '../../../core/services/catalog/catalog.interface'; } from '../../../core/services/catalog/catalog.interface';
import { import {
AVAILABLE_CATALOG_AVAILABILITY, AVAILABLE_CATALOG_AVAILABILITY,
allowsCatalogAction,
primaryAvailabilityMessage, primaryAvailabilityMessage,
} from '../../../core/services/catalog/catalog-availability'; } from '../../../core/services/catalog/catalog-availability';
import { CarouselComponent } from '../carousel/carousel.component'; import { CarouselComponent } from '../carousel/carousel.component';
@@ -78,6 +79,7 @@ export class ProductListComponent {
readonly buy = output<ProductListBuyEvent>(); readonly buy = output<ProductListBuyEvent>();
readonly addToCart = output<ProductListCartEvent>(); readonly addToCart = output<ProductListCartEvent>();
readonly pageChange = output<number>(); readonly pageChange = output<number>();
protected readonly allows = allowsCatalogAction;
protected readonly effectiveLayout = computed<ProductListLayout>(() => protected readonly effectiveLayout = computed<ProductListLayout>(() =>
this.mobile() && this.layout() === 'row' ? 'column_with_cart' : this.layout(), this.mobile() && this.layout() === 'row' ? 'column_with_cart' : this.layout(),

View File

@@ -21,14 +21,14 @@
<app-variant-selector <app-variant-selector
class="product-row-card__selectors" class="product-row-card__selectors"
[variants]="variants()" [variants]="variants()"
[disabled]="!availability().capabilities.select_variant" [disabled]="!allows(availability(), 'select_variant')"
[(selectedVariant)]="selectedVariant" [(selectedVariant)]="selectedVariant"
/> />
<app-quantity-selector <app-quantity-selector
[(quantity)]="quantity" [(quantity)]="quantity"
[max]="effectiveMaximum()" [max]="effectiveMaximum()"
[disabled]="!effectiveAvailability().capabilities.change_quantity" [disabled]="!allows(effectiveAvailability(), 'change_quantity')"
/> />
</div> </div>
@@ -41,7 +41,7 @@
<div class="product-row-card__btn-wrapper"> <div class="product-row-card__btn-wrapper">
<app-button <app-button
variant="primary" variant="primary"
[disabled]="!hasPurchasableSelection() || !effectiveAvailability().capabilities.buy_now" [disabled]="!hasPurchasableSelection() || !allows(effectiveAvailability(), 'buy_now')"
(click)="onBuy()" (click)="onBuy()"
> >
Comprar Comprar
@@ -53,7 +53,7 @@
[disabled]=" [disabled]="
saving() || saving() ||
!hasPurchasableSelection() || !hasPurchasableSelection() ||
!effectiveAvailability().capabilities.add_to_cart !allows(effectiveAvailability(), 'add_to_cart')
" "
(click)="onAddToCart()" (click)="onAddToCart()"
> >

View File

@@ -17,6 +17,9 @@ import {
import { CatalogAvailability } from '../../../core/services/catalog/catalog.interface'; import { CatalogAvailability } from '../../../core/services/catalog/catalog.interface';
import { import {
AVAILABLE_CATALOG_AVAILABILITY, AVAILABLE_CATALOG_AVAILABILITY,
allowsCatalogAction,
combineCatalogAvailability,
maximumCatalogQuantity,
primaryAvailabilityMessage, primaryAvailabilityMessage,
} from '../../../core/services/catalog/catalog-availability'; } from '../../../core/services/catalog/catalog-availability';
@@ -63,24 +66,22 @@ export class ProductRowCardComponent {
return Number.isFinite(variantPrice) ? variantPrice : this.price(); return Number.isFinite(variantPrice) ? variantPrice : this.price();
}); });
protected readonly effectiveAvailability = computed( protected readonly effectiveAvailability = computed(() =>
() => combineCatalogAvailability(this.availability(), this.selectedVariantData()?.availability),
this.selectedVariantData()?.availability ??
this.availability() ??
AVAILABLE_CATALOG_AVAILABILITY,
); );
protected readonly hasVariants = computed(() => this.variants().length > 0); protected readonly hasVariants = computed(() => this.variants().length > 0);
protected readonly hasPurchasableSelection = computed( protected readonly hasPurchasableSelection = computed(
() => !this.hasVariants() || this.selectedVariantData() !== undefined, () => !this.hasVariants() || this.selectedVariantData() !== undefined,
); );
protected readonly effectiveMaximum = computed( protected readonly effectiveMaximum = computed(() =>
() => this.effectiveAvailability().maximum_quantity, maximumCatalogQuantity(this.effectiveAvailability()),
); );
protected readonly effectiveUnavailableMessage = computed(() => protected readonly effectiveUnavailableMessage = computed(() =>
primaryAvailabilityMessage(this.effectiveAvailability()), primaryAvailabilityMessage(this.effectiveAvailability()),
); );
readonly formattedPrice = computed(() => this.formatCurrency(this.effectivePrice())); readonly formattedPrice = computed(() => this.formatCurrency(this.effectivePrice()));
protected readonly allows = allowsCatalogAction;
constructor() { constructor() {
effect(() => { effect(() => {
@@ -96,7 +97,7 @@ export class ProductRowCardComponent {
if ( if (
this.saving() || this.saving() ||
!this.hasPurchasableSelection() || !this.hasPurchasableSelection() ||
!this.effectiveAvailability().capabilities.add_to_cart !this.allows(this.effectiveAvailability(), 'add_to_cart')
) { ) {
return; return;
} }
@@ -108,7 +109,7 @@ export class ProductRowCardComponent {
} }
protected onBuy(): void { protected onBuy(): void {
if (!this.hasPurchasableSelection() || !this.effectiveAvailability().capabilities.buy_now) if (!this.hasPurchasableSelection() || !this.allows(this.effectiveAvailability(), 'buy_now'))
return; return;
this.buy.emit({ this.buy.emit({

View File

@@ -23,7 +23,10 @@ import {
CatalogVariantSelector, CatalogVariantSelector,
CatalogVariantValue, CatalogVariantValue,
} from '../../../core/services/catalog/catalog.interface'; } 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 { CatalogService } from '../../../core/services/catalog/catalog.service';
import { ModalService } from '../../../core/services/modal.service'; import { ModalService } from '../../../core/services/modal.service';
import { ToastService } from '../../../core/services/toast.service'; import { ToastService } from '../../../core/services/toast.service';
@@ -356,7 +359,8 @@ export class ProductTicketSelectorComponent {
} }
return variants.filter( return variants.filter(
({ id, availability }) => ({ 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 { return {
id: item.variant.id, id: item.variant.id,
precio: item.variant.precio, 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, values: item.variant.values,
}; };
} }

View File

@@ -19,7 +19,7 @@
<app-quantity-selector <app-quantity-selector
[(quantity)]="quantity" [(quantity)]="quantity"
[max]="effectiveMaximum()" [max]="effectiveMaximum()"
[disabled]="!effectiveAvailability().capabilities.change_quantity" [disabled]="!allows(effectiveAvailability(), 'change_quantity')"
/> />
</div> </div>
} @else { } @else {
@@ -29,14 +29,14 @@
<app-quantity-selector <app-quantity-selector
[(quantity)]="quantity" [(quantity)]="quantity"
[max]="effectiveMaximum()" [max]="effectiveMaximum()"
[disabled]="!effectiveAvailability().capabilities.change_quantity" [disabled]="!allows(effectiveAvailability(), 'change_quantity')"
/> />
</div> </div>
<div class="product-vertical-with-cart-card__variant-selectors"> <div class="product-vertical-with-cart-card__variant-selectors">
<app-variant-selector <app-variant-selector
[variants]="variants()" [variants]="variants()"
[disabled]="!availability().capabilities.select_variant" [disabled]="!allows(availability(), 'select_variant')"
[(selectedVariant)]="selectedVariant" [(selectedVariant)]="selectedVariant"
/> />
</div> </div>
@@ -47,7 +47,7 @@
<app-button <app-button
variant="primary" variant="primary"
[disabled]=" [disabled]="
!effectiveAvailability().capabilities.buy_now || !allows(effectiveAvailability(), 'buy_now') ||
(hasVariants() && selectedVariant() === null) (hasVariants() && selectedVariant() === null)
" "
(click)="onBuy()" (click)="onBuy()"
@@ -58,7 +58,7 @@
variant="secondary" variant="secondary"
[disabled]=" [disabled]="
saving() || saving() ||
!effectiveAvailability().capabilities.add_to_cart || !allows(effectiveAvailability(), 'add_to_cart') ||
(hasVariants() && selectedVariant() === null) (hasVariants() && selectedVariant() === null)
" "
(click)="onAddToCart()" (click)="onAddToCart()"

View File

@@ -18,6 +18,9 @@ import {
import { CatalogAvailability } from '../../../core/services/catalog/catalog.interface'; import { CatalogAvailability } from '../../../core/services/catalog/catalog.interface';
import { import {
AVAILABLE_CATALOG_AVAILABILITY, AVAILABLE_CATALOG_AVAILABILITY,
allowsCatalogAction,
combineCatalogAvailability,
maximumCatalogQuantity,
primaryAvailabilityMessage, primaryAvailabilityMessage,
} from '../../../core/services/catalog/catalog-availability'; } from '../../../core/services/catalog/catalog-availability';
@@ -61,18 +64,16 @@ export class ProductVerticalWithCartCardComponent {
}); });
protected readonly formattedPrice = computed(() => this.formatCurrency(this.effectivePrice())); protected readonly formattedPrice = computed(() => this.formatCurrency(this.effectivePrice()));
protected readonly hasVariants = computed(() => this.variants().length > 0); protected readonly hasVariants = computed(() => this.variants().length > 0);
protected readonly effectiveAvailability = computed( protected readonly effectiveAvailability = computed(() =>
() => combineCatalogAvailability(this.availability(), this.selectedVariantData()?.availability),
this.selectedVariantData()?.availability ??
this.availability() ??
AVAILABLE_CATALOG_AVAILABILITY,
); );
protected readonly effectiveMaximum = computed( protected readonly effectiveMaximum = computed(() =>
() => this.effectiveAvailability().maximum_quantity, maximumCatalogQuantity(this.effectiveAvailability()),
); );
protected readonly effectiveUnavailableMessage = computed(() => protected readonly effectiveUnavailableMessage = computed(() =>
primaryAvailabilityMessage(this.effectiveAvailability()), primaryAvailabilityMessage(this.effectiveAvailability()),
); );
protected readonly allows = allowsCatalogAction;
constructor() { constructor() {
effect(() => { effect(() => {
@@ -85,7 +86,7 @@ export class ProductVerticalWithCartCardComponent {
} }
protected onAddToCart(): void { protected onAddToCart(): void {
if (this.saving() || !this.effectiveAvailability().capabilities.add_to_cart) { if (this.saving() || !this.allows(this.effectiveAvailability(), 'add_to_cart')) {
return; return;
} }
@@ -93,7 +94,7 @@ export class ProductVerticalWithCartCardComponent {
} }
protected onBuy(): void { 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() }); this.buy.emit({ quantity: this.quantity(), variant: this.selectedVariant() });
} }

View File

@@ -10,6 +10,8 @@ import {
untracked, untracked,
} from '@angular/core'; } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { CatalogAvailability } from '../../../core/services/catalog/catalog.interface';
import { allowsCatalogAction } from '../../../core/services/catalog/catalog-availability';
export interface VariantAttributeOption { export interface VariantAttributeOption {
value: string; value: string;
@@ -22,12 +24,7 @@ export type VariantAttributeValue = VariantAttributeScalar | VariantAttributeSca
export interface VariantSelectorVariant { export interface VariantSelectorVariant {
id: unknown; id: unknown;
values: Record<string, VariantAttributeValue>; values: Record<string, VariantAttributeValue>;
availability?: { availability?: CatalogAvailability;
capabilities: {
display: boolean;
select_variant: boolean;
};
};
} }
export interface VariantSelectorSelectionChange { export interface VariantSelectorSelectionChange {
@@ -208,7 +205,9 @@ export class VariantSelectorComponent {
private getSelectableVariants(): VariantSelectorVariant[] { private getSelectableVariants(): VariantSelectorVariant[] {
return this.variants().filter( return this.variants().filter(
(variant) => variant.availability?.capabilities.select_variant !== false, (variant) =>
variant.availability === undefined ||
allowsCatalogAction(variant.availability, 'select_variant'),
); );
} }