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 = {
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);
}

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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)"
/>
</section>
@@ -61,7 +61,7 @@
<app-quantity-selector
[(quantity)]="quantity"
[max]="selectedVariantMax()"
[disabled]="!effectiveAvailability().capabilities.change_quantity"
[disabled]="!allows(effectiveAvailability(), 'change_quantity')"
/>
<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 {
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<number | null>(() => {
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;
}

View File

@@ -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)"
/>
}

View File

@@ -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<ProductListBuyEvent>();
readonly addToCart = output<ProductListCartEvent>();
readonly pageChange = output<number>();
protected readonly allows = allowsCatalogAction;
protected readonly effectiveLayout = computed<ProductListLayout>(() =>
this.mobile() && this.layout() === 'row' ? 'column_with_cart' : this.layout(),

View File

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

View File

@@ -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({

View File

@@ -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,
};
}

View File

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

View File

@@ -18,6 +18,9 @@ import {
import { CatalogAvailability } from '../../../core/services/catalog/catalog.interface';
import {
AVAILABLE_CATALOG_AVAILABILITY,
allowsCatalogAction,
combineCatalogAvailability,
maximumCatalogQuantity,
primaryAvailabilityMessage,
} from '../../../core/services/catalog/catalog-availability';
@@ -61,18 +64,16 @@ export class ProductVerticalWithCartCardComponent {
});
protected readonly formattedPrice = computed(() => 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() });
}

View File

@@ -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<string, VariantAttributeValue>;
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'),
);
}