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