Compare commits

...

2 Commits

Author SHA1 Message Date
8089298383 feat(catalog): show product availability tooltips 2026-08-21 16:47:29 -03:00
d51db00247 feat(ui): add reusable information tooltip 2026-08-21 16:47:21 -03:00
15 changed files with 208 additions and 10 deletions

View File

@@ -67,6 +67,7 @@ export interface CatalogItemVariant {
event_date_ids?: number[]; event_date_ids?: number[];
event_dates?: string[]; event_dates?: string[];
maximum_addable_quantity?: number | null; maximum_addable_quantity?: number | null;
unavailable_message?: string | null;
minimum_use_date?: string | null; minimum_use_date?: string | null;
maximum_use_date?: string | null; maximum_use_date?: string | null;
effective_minimum_use_date?: string | null; effective_minimum_use_date?: string | null;
@@ -114,6 +115,7 @@ export interface CatalogFeaturedItemVariant {
descripcion?: string | null; descripcion?: string | null;
precio?: string; precio?: string;
maximum_addable_quantity?: number | null; maximum_addable_quantity?: number | null;
unavailable_message?: string | null;
values: Record<string, CatalogVariantValue>; values: Record<string, CatalogVariantValue>;
} }
@@ -146,6 +148,7 @@ export interface CatalogFeaturedItem {
precio: number | string; precio: number | string;
image?: string | null; image?: string | null;
maximum_addable_quantity?: number | null; maximum_addable_quantity?: number | null;
unavailable_message?: string | null;
variants?: CatalogFeaturedItemVariant[]; variants?: CatalogFeaturedItemVariant[];
} }

View File

@@ -31,9 +31,16 @@
<div <div
class="product-column-with-image__body p-3 d-flex flex-column align-items-center text-center flex-grow-1" class="product-column-with-image__body p-3 d-flex flex-column align-items-center text-center flex-grow-1"
> >
<h3 class="product-column-with-image__title text-uppercase text-black mb-4">{{ title() }}</h3> <h3 class="product-column-with-image__title text-uppercase text-black mb-4">
{{ title() }}
@if (unavailableMessage(); as message) {
<app-tooltip [message]="message" />
}
</h3>
<div class="product-column-with-image__prices d-flex align-items-center justify-content-center gap-2"> <div
class="product-column-with-image__prices d-flex align-items-center justify-content-center gap-2"
>
<span class="product-column-with-image__price-discounted text-primary"> <span class="product-column-with-image__price-discounted text-primary">
{{ formattedDiscountedPrice() }} {{ formattedDiscountedPrice() }}
</span> </span>
@@ -53,7 +60,12 @@
</div> </div>
<div class="product-column-with-image__action mt-auto w-100"> <div class="product-column-with-image__action mt-auto w-100">
<app-button variant="primary" class="w-100" (click)="buy.emit()"> <app-button
variant="primary"
class="w-100"
[disabled]="!!unavailableMessage()"
(click)="onBuy()"
>
{{ buttonText() }} {{ buttonText() }}
</app-button> </app-button>
</div> </div>

View File

@@ -2,10 +2,11 @@ import { NgOptimizedImage } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
import { ButtonComponent } from '../button/button.component'; import { ButtonComponent } from '../button/button.component';
import { TooltipComponent } from '../tooltip/tooltip.component';
@Component({ @Component({
selector: 'app-product-column-with-image', selector: 'app-product-column-with-image',
imports: [ButtonComponent, NgOptimizedImage], imports: [ButtonComponent, NgOptimizedImage, TooltipComponent],
templateUrl: './product-column-with-image.component.html', templateUrl: './product-column-with-image.component.html',
styleUrl: './product-column-with-image.component.scss', styleUrl: './product-column-with-image.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -18,9 +19,16 @@ export class ProductColumnWithImageComponent {
readonly transferPrice = input<number | null>(null); readonly transferPrice = input<number | null>(null);
readonly buttonText = input<string>('Comprar'); readonly buttonText = input<string>('Comprar');
readonly imagePriority = input<boolean>(false); readonly imagePriority = input<boolean>(false);
readonly unavailableMessage = input<string | null>(null);
readonly buy = output<void>(); readonly buy = output<void>();
protected onBuy(): void {
if (this.unavailableMessage()) return;
this.buy.emit();
}
readonly discountedPrice = computed(() => { readonly discountedPrice = computed(() => {
const original = this.originalPrice(); const original = this.originalPrice();
const discount = this.discount(); const discount = this.discount();

View File

@@ -13,6 +13,7 @@
[description]="item.descripcion ?? ''" [description]="item.descripcion ?? ''"
[price]="price(item)" [price]="price(item)"
[maximumAddableQuantity]="item.maximum_addable_quantity ?? null" [maximumAddableQuantity]="item.maximum_addable_quantity ?? null"
[unavailableMessage]="item.unavailable_message ?? null"
[variants]="item.variants ?? []" [variants]="item.variants ?? []"
[saving]="savingProductIds().has(item.id)" [saving]="savingProductIds().has(item.id)"
(buy)="emitRowBuy(item, $event)" (buy)="emitRowBuy(item, $event)"
@@ -25,6 +26,7 @@
[description]="item.descripcion ?? ''" [description]="item.descripcion ?? ''"
[price]="price(item)" [price]="price(item)"
[maximumAddableQuantity]="item.maximum_addable_quantity ?? null" [maximumAddableQuantity]="item.maximum_addable_quantity ?? null"
[unavailableMessage]="item.unavailable_message ?? null"
[variants]="item.variants ?? []" [variants]="item.variants ?? []"
[saving]="savingProductIds().has(item.id)" [saving]="savingProductIds().has(item.id)"
(buy)="emitColumnBuy(item, $event)" (buy)="emitColumnBuy(item, $event)"
@@ -38,7 +40,8 @@
[description]="item.descripcion ?? ''" [description]="item.descripcion ?? ''"
[price]="price(item)" [price]="price(item)"
[imageUrl]="loadImages() ? (item.image ?? null) : null" [imageUrl]="loadImages() ? (item.image ?? null) : null"
[disabled]="loading()" [unavailableMessage]="item.unavailable_message ?? null"
[disabled]="loading() || !!item.unavailable_message"
(buy)="emitTicketBuy(item, $event)" (buy)="emitTicketBuy(item, $event)"
/> />
} }
@@ -47,6 +50,7 @@
[imageUrl]="loadImages() ? (item.image ?? null) : null" [imageUrl]="loadImages() ? (item.image ?? null) : null"
[title]="item.nombre" [title]="item.nombre"
[originalPrice]="price(item)" [originalPrice]="price(item)"
[unavailableMessage]="item.unavailable_message ?? null"
[imagePriority]="loadImages() && index < 4" [imagePriority]="loadImages() && index < 4"
(buy)="emitProductDetailBuy(item)" (buy)="emitProductDetailBuy(item)"
/> />

View File

@@ -3,6 +3,9 @@
<div class="product-row-card__info d-flex flex-column justify-content-center flex-grow-1"> <div class="product-row-card__info d-flex flex-column justify-content-center flex-grow-1">
<h3 class="product-row-card__title text-uppercase mb-1 m-0"> <h3 class="product-row-card__title text-uppercase mb-1 m-0">
{{ title() }} {{ title() }}
@if (effectiveUnavailableMessage(); as message) {
<app-tooltip [message]="message" />
}
</h3> </h3>
@if (effectiveDescription()) { @if (effectiveDescription()) {
<p class="product-row-card__description m-0 mt-1"> <p class="product-row-card__description m-0 mt-1">

View File

@@ -9,6 +9,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { ButtonComponent } from '../button/button.component'; import { ButtonComponent } from '../button/button.component';
import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component'; import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component';
import { TooltipComponent } from '../tooltip/tooltip.component';
import { import {
VariantSelectorComponent, VariantSelectorComponent,
VariantSelectorVariant, VariantSelectorVariant,
@@ -19,12 +20,13 @@ export interface Variant extends VariantSelectorVariant {
descripcion?: string | null; descripcion?: string | null;
precio?: string | number; precio?: string | number;
maximum_addable_quantity?: number | null; maximum_addable_quantity?: number | null;
unavailable_message?: string | null;
} }
@Component({ @Component({
selector: 'app-product-row-card', selector: 'app-product-row-card',
standalone: true, standalone: true,
imports: [ButtonComponent, QuantitySelectorComponent, VariantSelectorComponent], imports: [ButtonComponent, QuantitySelectorComponent, TooltipComponent, VariantSelectorComponent],
templateUrl: './product-row-card.component.html', templateUrl: './product-row-card.component.html',
styleUrl: './product-row-card.component.scss', styleUrl: './product-row-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -35,6 +37,7 @@ export class ProductRowCardComponent {
readonly description = input<string>(''); readonly description = input<string>('');
readonly price = input<number>(0); readonly price = input<number>(0);
readonly maximumAddableQuantity = input<number | null>(null); readonly maximumAddableQuantity = input<number | null>(null);
readonly unavailableMessage = input<string | null>(null);
readonly variants = input<Variant[]>([]); readonly variants = input<Variant[]>([]);
readonly saving = input(false); readonly saving = input(false);
@@ -61,6 +64,13 @@ export class ProductRowCardComponent {
() => this.selectedVariantData()?.maximum_addable_quantity ?? this.maximumAddableQuantity(), () => this.selectedVariantData()?.maximum_addable_quantity ?? this.maximumAddableQuantity(),
); );
protected readonly unavailable = computed(() => this.effectiveMaximum() === 0); 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())); readonly formattedPrice = computed(() => this.formatCurrency(this.effectivePrice()));

View File

@@ -1,7 +1,12 @@
<article class="ticket-selector"> <article class="ticket-selector">
<header class="ticket-selector__header"> <header class="ticket-selector__header">
<div> <div>
<h3 class="ticket-selector__title">{{ title() }}</h3> <h3 class="ticket-selector__title">
{{ title() }}
@if (unavailableMessage(); as message) {
<app-tooltip [message]="message" />
}
</h3>
@if (description()) { @if (description()) {
<p class="ticket-selector__description">{{ description() }}</p> <p class="ticket-selector__description">{{ description() }}</p>
} }

View File

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

View File

@@ -1,6 +1,11 @@
<article class="product-vertical-with-cart-card"> <article class="product-vertical-with-cart-card">
<div class="product-vertical-with-cart-card__content"> <div class="product-vertical-with-cart-card__content">
<h3 class="product-vertical-with-cart-card__title">{{ title() }}</h3> <h3 class="product-vertical-with-cart-card__title">
{{ title() }}
@if (effectiveUnavailableMessage(); as message) {
<app-tooltip [message]="message" />
}
</h3>
@if (effectiveDescription()) { @if (effectiveDescription()) {
<p class="product-vertical-with-cart-card__description">{{ effectiveDescription() }}</p> <p class="product-vertical-with-cart-card__description">{{ effectiveDescription() }}</p>

View File

@@ -56,6 +56,23 @@ describe('ProductVerticalWithCartCardComponent', () => {
).toBeNull(); ).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 () => { it('updates the quantity with the reusable quantity selector', async () => {
const fixture = await createComponent(); const fixture = await createComponent();
const buttons = fixture.nativeElement.querySelectorAll( const buttons = fixture.nativeElement.querySelectorAll(

View File

@@ -10,6 +10,7 @@ import {
import { ButtonComponent } from '../button/button.component'; import { ButtonComponent } from '../button/button.component';
import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component'; import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component';
import { TooltipComponent } from '../tooltip/tooltip.component';
import { import {
VariantSelectorComponent, VariantSelectorComponent,
VariantSelectorVariant, VariantSelectorVariant,
@@ -19,11 +20,12 @@ export interface VerticalCartVariant extends VariantSelectorVariant {
descripcion?: string | null; descripcion?: string | null;
precio?: string | number; precio?: string | number;
maximum_addable_quantity?: number | null; maximum_addable_quantity?: number | null;
unavailable_message?: string | null;
} }
@Component({ @Component({
selector: 'app-product-vertical-with-cart-card', selector: 'app-product-vertical-with-cart-card',
imports: [ButtonComponent, QuantitySelectorComponent, VariantSelectorComponent], imports: [ButtonComponent, QuantitySelectorComponent, TooltipComponent, VariantSelectorComponent],
templateUrl: './product-vertical-with-cart-card.component.html', templateUrl: './product-vertical-with-cart-card.component.html',
styleUrl: './product-vertical-with-cart-card.component.scss', styleUrl: './product-vertical-with-cart-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -33,6 +35,7 @@ export class ProductVerticalWithCartCardComponent {
readonly description = input<string>(''); readonly description = input<string>('');
readonly price = input<number>(0); readonly price = input<number>(0);
readonly maximumAddableQuantity = input<number | null>(null); readonly maximumAddableQuantity = input<number | null>(null);
readonly unavailableMessage = input<string | null>(null);
readonly variants = input<VerticalCartVariant[]>([]); readonly variants = input<VerticalCartVariant[]>([]);
readonly saving = input(false); readonly saving = input(false);
@@ -59,6 +62,13 @@ export class ProductVerticalWithCartCardComponent {
() => this.selectedVariantData()?.maximum_addable_quantity ?? this.maximumAddableQuantity(), () => this.selectedVariantData()?.maximum_addable_quantity ?? this.maximumAddableQuantity(),
); );
protected readonly unavailable = computed(() => this.effectiveMaximum() === 0); protected readonly unavailable = computed(() => this.effectiveMaximum() === 0);
protected readonly effectiveUnavailableMessage = computed(() => {
const selectedVariant = this.selectedVariantData();
return selectedVariant
? (selectedVariant.unavailable_message ?? null)
: this.unavailableMessage();
});
constructor() { constructor() {
effect(() => { effect(() => {

View File

@@ -0,0 +1,11 @@
<button
type="button"
class="tooltip-trigger"
[attr.aria-describedby]="tooltipId"
[attr.aria-label]="message()"
>
<i class="fa-solid fa-circle-info" aria-hidden="true"></i>
<span class="tooltip-message" [id]="tooltipId" role="tooltip">
{{ message() }}
</span>
</button>

View File

@@ -0,0 +1,62 @@
:host {
display: inline-flex;
align-items: center;
margin-left: 0.25rem;
vertical-align: middle;
}
.tooltip-trigger {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
background: transparent;
color: var(--tenant-primary, var(--color-primary, #0d6efd));
font-size: 1em;
line-height: inherit;
&:focus-visible {
border-radius: 50%;
outline: 2px solid currentColor;
outline-offset: 2px;
}
&:hover .tooltip-message,
&:focus-visible .tooltip-message {
visibility: visible;
opacity: 1;
transform: translate(-50%, -0.25rem);
}
}
.tooltip-message {
position: absolute;
z-index: 1100;
bottom: calc(100% + 0.625rem);
left: 50%;
width: max-content;
max-width: min(16rem, 75vw);
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
border: 1px solid var(--tenant-primary, var(--color-primary, #0d6efd));
background: #fff;
color: #212529;
font-family: inherit;
font-size: 0.75rem;
font-weight: 400;
line-height: 1.35;
text-align: center;
text-transform: none;
letter-spacing: normal;
white-space: normal;
visibility: hidden;
opacity: 0;
pointer-events: none;
transform: translate(-50%, 0);
transition:
opacity 0.15s ease,
transform 0.15s ease,
visibility 0.15s ease;
}

View File

@@ -0,0 +1,32 @@
import { TestBed, getTestBed } from '@angular/core/testing';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
import { TooltipComponent } from './tooltip.component';
describe('TooltipComponent', () => {
beforeAll(() => {
try {
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
});
afterEach(() => TestBed.resetTestingModule());
it('renders the reusable danger information tooltip', async () => {
await TestBed.configureTestingModule({ imports: [TooltipComponent] }).compileComponents();
const fixture = TestBed.createComponent(TooltipComponent);
fixture.componentRef.setInput('message', 'Este producto no tiene stock disponible.');
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const trigger = element.querySelector('.tooltip-trigger');
const message = element.querySelector('.tooltip-message');
expect(trigger?.querySelector('i.fa-solid.fa-circle-info')).not.toBeNull();
expect(message?.textContent?.trim()).toBe('Este producto no tiene stock disponible.');
expect(trigger?.getAttribute('aria-describedby')).toBe(message?.id);
});
});

View File

@@ -0,0 +1,14 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
let nextTooltipId = 0;
@Component({
selector: 'app-tooltip',
templateUrl: './tooltip.component.html',
styleUrl: './tooltip.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TooltipComponent {
readonly message = input.required<string>();
protected readonly tooltipId = `app-tooltip-${nextTooltipId++}`;
}