From 8089298383015e767be22d59fa9b1e786c771538 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 21 Aug 2026 16:47:29 -0300 Subject: [PATCH] feat(catalog): show product availability tooltips --- .../core/services/catalog/catalog.interface.ts | 3 +++ .../product-column-with-image.component.html | 18 +++++++++++++++--- .../product-column-with-image.component.ts | 10 +++++++++- .../product-list/product-list.component.html | 6 +++++- .../product-row-card.component.html | 3 +++ .../product-row-card.component.ts | 12 +++++++++++- .../product-ticket-selector.component.html | 7 ++++++- .../product-ticket-selector.component.ts | 4 +++- ...duct-vertical-with-cart-card.component.html | 7 ++++++- ...t-vertical-with-cart-card.component.spec.ts | 17 +++++++++++++++++ ...roduct-vertical-with-cart-card.component.ts | 12 +++++++++++- 11 files changed, 89 insertions(+), 10 deletions(-) diff --git a/src/app/core/services/catalog/catalog.interface.ts b/src/app/core/services/catalog/catalog.interface.ts index d9d26fb..009de6b 100644 --- a/src/app/core/services/catalog/catalog.interface.ts +++ b/src/app/core/services/catalog/catalog.interface.ts @@ -67,6 +67,7 @@ export interface CatalogItemVariant { event_date_ids?: number[]; event_dates?: string[]; maximum_addable_quantity?: number | null; + unavailable_message?: string | null; minimum_use_date?: string | null; maximum_use_date?: string | null; effective_minimum_use_date?: string | null; @@ -114,6 +115,7 @@ export interface CatalogFeaturedItemVariant { descripcion?: string | null; precio?: string; maximum_addable_quantity?: number | null; + unavailable_message?: string | null; values: Record; } @@ -146,6 +148,7 @@ export interface CatalogFeaturedItem { precio: number | string; image?: string | null; maximum_addable_quantity?: number | null; + unavailable_message?: string | null; variants?: CatalogFeaturedItemVariant[]; } diff --git a/src/app/shared/components/product-column-with-image/product-column-with-image.component.html b/src/app/shared/components/product-column-with-image/product-column-with-image.component.html index e63b5aa..8773ee2 100644 --- a/src/app/shared/components/product-column-with-image/product-column-with-image.component.html +++ b/src/app/shared/components/product-column-with-image/product-column-with-image.component.html @@ -31,9 +31,16 @@
-

{{ title() }}

+

+ {{ title() }} + @if (unavailableMessage(); as message) { + + } +

-
+
{{ formattedDiscountedPrice() }} @@ -53,7 +60,12 @@
- + {{ buttonText() }}
diff --git a/src/app/shared/components/product-column-with-image/product-column-with-image.component.ts b/src/app/shared/components/product-column-with-image/product-column-with-image.component.ts index 4196dc3..d3f7ca5 100644 --- a/src/app/shared/components/product-column-with-image/product-column-with-image.component.ts +++ b/src/app/shared/components/product-column-with-image/product-column-with-image.component.ts @@ -2,10 +2,11 @@ import { NgOptimizedImage } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; import { ButtonComponent } from '../button/button.component'; +import { TooltipComponent } from '../tooltip/tooltip.component'; @Component({ selector: 'app-product-column-with-image', - imports: [ButtonComponent, NgOptimizedImage], + imports: [ButtonComponent, NgOptimizedImage, TooltipComponent], templateUrl: './product-column-with-image.component.html', styleUrl: './product-column-with-image.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -18,9 +19,16 @@ export class ProductColumnWithImageComponent { readonly transferPrice = input(null); readonly buttonText = input('Comprar'); readonly imagePriority = input(false); + readonly unavailableMessage = input(null); readonly buy = output(); + protected onBuy(): void { + if (this.unavailableMessage()) return; + + this.buy.emit(); + } + readonly discountedPrice = computed(() => { const original = this.originalPrice(); const discount = this.discount(); 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 c181aba..b32df87 100644 --- a/src/app/shared/components/product-list/product-list.component.html +++ b/src/app/shared/components/product-list/product-list.component.html @@ -13,6 +13,7 @@ [description]="item.descripcion ?? ''" [price]="price(item)" [maximumAddableQuantity]="item.maximum_addable_quantity ?? null" + [unavailableMessage]="item.unavailable_message ?? null" [variants]="item.variants ?? []" [saving]="savingProductIds().has(item.id)" (buy)="emitRowBuy(item, $event)" @@ -25,6 +26,7 @@ [description]="item.descripcion ?? ''" [price]="price(item)" [maximumAddableQuantity]="item.maximum_addable_quantity ?? null" + [unavailableMessage]="item.unavailable_message ?? null" [variants]="item.variants ?? []" [saving]="savingProductIds().has(item.id)" (buy)="emitColumnBuy(item, $event)" @@ -38,7 +40,8 @@ [description]="item.descripcion ?? ''" [price]="price(item)" [imageUrl]="loadImages() ? (item.image ?? null) : null" - [disabled]="loading()" + [unavailableMessage]="item.unavailable_message ?? null" + [disabled]="loading() || !!item.unavailable_message" (buy)="emitTicketBuy(item, $event)" /> } @@ -47,6 +50,7 @@ [imageUrl]="loadImages() ? (item.image ?? null) : null" [title]="item.nombre" [originalPrice]="price(item)" + [unavailableMessage]="item.unavailable_message ?? null" [imagePriority]="loadImages() && index < 4" (buy)="emitProductDetailBuy(item)" /> 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 7574dab..f383578 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 @@ -3,6 +3,9 @@

{{ title() }} + @if (effectiveUnavailableMessage(); as message) { + + }

@if (effectiveDescription()) {

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 59dd265..ec9e699 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 @@ -9,6 +9,7 @@ import { } from '@angular/core'; import { ButtonComponent } from '../button/button.component'; import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component'; +import { TooltipComponent } from '../tooltip/tooltip.component'; import { VariantSelectorComponent, VariantSelectorVariant, @@ -19,12 +20,13 @@ export interface Variant extends VariantSelectorVariant { descripcion?: string | null; precio?: string | number; maximum_addable_quantity?: number | null; + unavailable_message?: string | null; } @Component({ selector: 'app-product-row-card', standalone: true, - imports: [ButtonComponent, QuantitySelectorComponent, VariantSelectorComponent], + imports: [ButtonComponent, QuantitySelectorComponent, TooltipComponent, VariantSelectorComponent], templateUrl: './product-row-card.component.html', styleUrl: './product-row-card.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -35,6 +37,7 @@ export class ProductRowCardComponent { readonly description = input(''); readonly price = input(0); readonly maximumAddableQuantity = input(null); + readonly unavailableMessage = input(null); readonly variants = input([]); readonly saving = input(false); @@ -61,6 +64,13 @@ export class ProductRowCardComponent { () => this.selectedVariantData()?.maximum_addable_quantity ?? this.maximumAddableQuantity(), ); 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())); diff --git a/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.html b/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.html index b38192e..fc92f95 100644 --- a/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.html +++ b/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.html @@ -1,7 +1,12 @@

-

{{ title() }}

+

+ {{ title() }} + @if (unavailableMessage(); as message) { + + } +

@if (description()) {

{{ description() }}

} 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 e643317..f420a03 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 @@ -28,6 +28,7 @@ import { ModalService } from '../../../core/services/modal.service'; import { ToastService } from '../../../core/services/toast.service'; import { ButtonComponent } from '../button/button.component'; import { IconButtonComponent } from '../icon-button/icon-button.component'; +import { TooltipComponent } from '../tooltip/tooltip.component'; type TicketSelectionStatus = | 'selecting' @@ -56,7 +57,7 @@ interface TicketSelectionRow { @Component({ selector: 'app-product-ticket-selector', - imports: [ButtonComponent, IconButtonComponent], + imports: [ButtonComponent, IconButtonComponent, TooltipComponent], templateUrl: './product-ticket-selector.component.html', styleUrl: './product-ticket-selector.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -80,6 +81,7 @@ export class ProductTicketSelectorComponent { readonly price = input(0); readonly imageUrl = input(null); readonly disabled = input(false); + readonly unavailableMessage = input(null); readonly buy = output(); 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 84c28f8..ceba58a 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 @@ -1,6 +1,11 @@
-

{{ title() }}

+

+ {{ title() }} + @if (effectiveUnavailableMessage(); as message) { + + } +

@if (effectiveDescription()) {

{{ effectiveDescription() }}

diff --git a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.spec.ts b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.spec.ts index c0aa867..4d9d902 100644 --- a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.spec.ts +++ b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.spec.ts @@ -56,6 +56,23 @@ describe('ProductVerticalWithCartCardComponent', () => { ).toBeNull(); }); + it('shows the backend availability message with the reusable tooltip', async () => { + const fixture = await createComponent(); + fixture.componentRef.setInput('maximumAddableQuantity', 0); + fixture.componentRef.setInput( + 'unavailableMessage', + 'Alcanzaste el cupo máximo permitido para este producto.', + ); + fixture.detectChanges(); + + const tooltip = (fixture.nativeElement as HTMLElement).querySelector('app-tooltip'); + + expect(tooltip?.querySelector('i.fa-solid.fa-circle-info')).not.toBeNull(); + expect(tooltip?.textContent).toContain( + 'Alcanzaste el cupo máximo permitido para este producto.', + ); + }); + it('updates the quantity with the reusable quantity selector', async () => { const fixture = await createComponent(); const buttons = fixture.nativeElement.querySelectorAll( 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 e904c64..e5306bc 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 @@ -10,6 +10,7 @@ import { import { ButtonComponent } from '../button/button.component'; import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component'; +import { TooltipComponent } from '../tooltip/tooltip.component'; import { VariantSelectorComponent, VariantSelectorVariant, @@ -19,11 +20,12 @@ export interface VerticalCartVariant extends VariantSelectorVariant { descripcion?: string | null; precio?: string | number; maximum_addable_quantity?: number | null; + unavailable_message?: string | null; } @Component({ selector: 'app-product-vertical-with-cart-card', - imports: [ButtonComponent, QuantitySelectorComponent, VariantSelectorComponent], + imports: [ButtonComponent, QuantitySelectorComponent, TooltipComponent, VariantSelectorComponent], templateUrl: './product-vertical-with-cart-card.component.html', styleUrl: './product-vertical-with-cart-card.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -33,6 +35,7 @@ export class ProductVerticalWithCartCardComponent { readonly description = input(''); readonly price = input(0); readonly maximumAddableQuantity = input(null); + readonly unavailableMessage = input(null); readonly variants = input([]); readonly saving = input(false); @@ -59,6 +62,13 @@ export class ProductVerticalWithCartCardComponent { () => this.selectedVariantData()?.maximum_addable_quantity ?? this.maximumAddableQuantity(), ); 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(() => {