refactor(ticket-selector): consume compact remote options

This commit is contained in:
2026-08-14 10:14:46 -03:00
parent 8ff9ed216c
commit f7983b109f
6 changed files with 160 additions and 140 deletions

View File

@@ -118,11 +118,23 @@ export interface CatalogFeaturedItemVariant {
}
export interface CatalogVariantOptionsResponse {
variants: CatalogFeaturedItemVariant[];
options: Record<string, CatalogVariantValue[]>;
selectors: CatalogVariantSelector[];
selected_values: Record<string, string | string[]>;
resolved_variant_id: number | null;
resolved_variant: CatalogFeaturedItemVariant | null;
valid: boolean;
available_variant_count: number;
matching_variant_count: number;
price_range: {
minimum: string;
maximum: string;
};
}
export interface CatalogVariantSelector {
key: string;
label: string;
options: CatalogVariantValue[];
enabled: boolean;
}
export interface CatalogFeaturedItem {

View File

@@ -772,7 +772,6 @@
[description]="testTicketSelectorProduct.description"
[price]="testTicketSelectorProduct.price"
[imageUrl]="testTicketSelectorProduct.imageUrl"
[variants]="testTicketSelectorProduct.variants"
(buy)="onTicketBuy($event)"
/>
</div>

View File

@@ -34,8 +34,6 @@
[description]="item.descripcion ?? ''"
[price]="price(item)"
[imageUrl]="loadImages() ? (item.image ?? null) : null"
[variants]="item.variants ?? []"
[unavailableVariantIds]="unavailableVariantIds()"
[disabled]="loading()"
(buy)="emitTicketBuy(item, $event)"
/>

View File

@@ -22,16 +22,23 @@
<div class="ticket-selector__rows">
@for (row of rows(); track row.id) {
<div class="ticket-selector__row">
<app-variant-selector
class="ticket-selector__fields"
[variants]="variantsForRow(row.id)"
[disabled]="rowDisabled(row)"
[autoSelectFirst]="false"
[resetToken]="row.resetToken"
[selectedVariant]="row.variantId"
(selectedVariantChange)="updateCandidate(row.id, $event)"
(selectionValuesChange)="onSelectionChange(row.id, $event)"
/>
<div class="ticket-selector__fields variant-selector">
@for (selector of row.selectors; track selector.key) {
<select
class="form-select variant-selector__select"
[attr.aria-label]="selector.label"
[disabled]="rowDisabled(row) || !selector.enabled"
[compareWith]="compareValues"
[ngModel]="selectedOption(row, selector.key)"
(ngModelChange)="onSelectionChange(row.id, selector.key, $event)"
>
<option [ngValue]="null" disabled>Seleccioná {{ selector.label }}</option>
@for (option of selector.options; track $index) {
<option [ngValue]="option">{{ optionLabel(option) }}</option>
}
</select>
}
</div>
<app-icon-button
variant="trash"
ariaLabel="Eliminar entrada"
@@ -58,7 +65,7 @@
+ Agregar entrada
</app-button>
@if (selectableVariants().length === 0) {
@if (availableVariantCount() === 0) {
<p class="ticket-selector__empty">No hay entradas disponibles.</p>
}

View File

@@ -80,11 +80,23 @@
&__fields {
min-width: 0;
display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr));
gap: 0.5rem;
}
::ng-deep .variant-selector {
display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr));
gap: 0.5rem;
.variant-selector__select {
width: 100%;
min-width: 0;
height: 38px;
color: #666;
border-color: var(--border-color);
font-size: 14px;
cursor: pointer;
&:focus {
border-color: var(--tenant-primary);
box-shadow: 0 0 0 0.25rem rgba(0, 153, 51, 0.25);
}
}
@@ -124,9 +136,7 @@
}
&__fields {
::ng-deep .variant-selector {
grid-template-columns: 1fr;
}
grid-template-columns: 1fr;
}
&__map {

View File

@@ -1,3 +1,4 @@
import { HttpErrorResponse } from '@angular/common/http';
import {
ChangeDetectionStrategy,
Component,
@@ -9,26 +10,18 @@ import {
output,
signal,
} from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { FormsModule } from '@angular/forms';
import { Subscription } from 'rxjs';
import { CartService } from '../../../core/services/cart/cart.service';
import {
CatalogVariantOptionsResponse,
CatalogVariantSelector,
CatalogVariantValue,
} from '../../../core/services/catalog/catalog.interface';
import { CatalogService } from '../../../core/services/catalog/catalog.service';
import { ButtonComponent } from '../button/button.component';
import { IconButtonComponent } from '../icon-button/icon-button.component';
import {
VariantAttributeScalar,
VariantAttributeValue,
VariantSelectorComponent,
VariantSelectorSelectionChange,
VariantSelectorVariant,
} from '../variant-selector/variant-selector.component';
export interface TicketSelectorVariant extends VariantSelectorVariant {
id: number;
precio?: string | number;
stock_tecnico?: number | null;
}
type TicketSelectionStatus =
| 'selecting'
@@ -44,15 +37,14 @@ interface TicketSelectionRow {
reservedVariantId: number | null;
cartItemId: number | null;
selectedValues: Record<string, string | string[]>;
remoteVariants: TicketSelectorVariant[] | null;
selectors: CatalogVariantSelector[];
status: TicketSelectionStatus;
error: string | null;
resetToken: number;
}
@Component({
selector: 'app-product-ticket-selector',
imports: [ButtonComponent, IconButtonComponent, VariantSelectorComponent],
imports: [ButtonComponent, IconButtonComponent, FormsModule],
templateUrl: './product-ticket-selector.component.html',
styleUrl: './product-ticket-selector.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -69,22 +61,15 @@ export class ProductTicketSelectorComponent {
readonly description = input<string>('');
readonly price = input<number>(0);
readonly imageUrl = input<string | null>(null);
readonly variants = input<TicketSelectorVariant[]>([]);
readonly unavailableVariantIds = input<ReadonlySet<number>>(new Set<number>());
readonly disabled = input(false);
readonly buy = output<number[]>();
protected readonly rows = signal<TicketSelectionRow[]>([this.createRow(1)]);
protected readonly availableVariantCount = signal(0);
private readonly remotePriceRange = signal<{ minimum: number; maximum: number } | null>(null);
private nextRowId = 2;
protected readonly selectableVariants = computed<TicketSelectorVariant[]>(() =>
this.variants().filter(
(variant) =>
!this.unavailableVariantIds().has(variant.id) &&
(variant.stock_tecnico == null || variant.stock_tecnico > 0),
),
);
protected readonly hasSelection = computed(
() =>
this.rows().length > 0 &&
@@ -97,21 +82,15 @@ export class ProductTicketSelectorComponent {
);
protected readonly canAddRow = computed(
() =>
this.rows().length < this.selectableVariants().length &&
this.availableVariantCount() > 0 &&
this.rows().every((row) => row.status === 'reserved') &&
!this.rows().some((row) => this.isBusy(row)),
);
protected readonly priceRange = computed(() => {
const prices = this.selectableVariants()
.map((variant) => Number(variant.precio ?? this.price()))
.filter(Number.isFinite);
const range = this.remotePriceRange();
const minimum = range?.minimum ?? this.price();
const maximum = range?.maximum ?? this.price();
if (prices.length === 0) {
const price = this.formatCurrency(this.price());
return { minimum: price, maximum: price, hasRange: false };
}
const minimum = Math.min(...prices);
const maximum = Math.max(...prices);
return {
minimum: this.formatCurrency(minimum),
maximum: this.formatCurrency(maximum),
@@ -121,20 +100,8 @@ export class ProductTicketSelectorComponent {
constructor() {
effect(() => {
const unavailable = this.unavailableVariantIds();
this.rows.update((rows) =>
rows.map((row) =>
row.cartItemId === null && row.variantId !== null && unavailable.has(row.variantId)
? {
...row,
variantId: null,
status: 'error',
error: 'La entrada seleccionada ya no está disponible.',
}
: row,
),
);
this.productId();
this.loadOptions(1, {});
});
this.destroyRef.onDestroy(() => {
@@ -155,7 +122,10 @@ export class ProductTicketSelectorComponent {
protected addRow(): void {
if (!this.canAddRow()) return;
this.rows.update((rows) => [...rows, this.createRow(this.nextRowId++)]);
const row = this.createRow(this.nextRowId++);
this.rows.update((rows) => [...rows, row]);
this.loadOptions(row.id, {});
}
protected removeRow(rowId: number): void {
@@ -171,7 +141,10 @@ export class ProductTicketSelectorComponent {
this.patchRow(rowId, { status: 'removing', error: null });
const request = this.cartService.removeItem(row.cartItemId).subscribe({
next: () => this.deleteRow(rowId),
next: () => {
this.availableVariantCount.update((count) => count + 1);
this.deleteRow(rowId);
},
error: (error: HttpErrorResponse) => {
this.patchRow(rowId, {
status: 'reserved',
@@ -182,23 +155,61 @@ export class ProductTicketSelectorComponent {
this.reservationRequests.set(rowId, request);
}
protected updateCandidate(rowId: number, selectedVariant: unknown): void {
this.patchRow(rowId, {
variantId: typeof selectedVariant === 'number' ? selectedVariant : null,
});
}
protected onSelectionChange(rowId: number, event: VariantSelectorSelectionChange): void {
protected onSelectionChange(
rowId: number,
selectorKey: string,
value: CatalogVariantValue | null,
): void {
const row = this.findRow(rowId);
if (!row || this.isBusy(row)) return;
const selectedValues = this.normalizeSelections(event.values);
const selectorIndex = row.selectors.findIndex((selector) => selector.key === selectorKey);
const retainedKeys = new Set(row.selectors.slice(0, selectorIndex + 1).map(({ key }) => key));
const selectedValues = Object.fromEntries(
Object.entries(row.selectedValues).filter(([key]) => retainedKeys.has(key)),
);
if (value === null) delete selectedValues[selectorKey];
else selectedValues[selectorKey] = this.normalizeValue(value);
this.loadOptions(rowId, selectedValues);
}
protected compareValues(
left: CatalogVariantValue | null,
right: CatalogVariantValue | null,
): boolean {
return left !== null && right !== null && this.valueKey(left) === this.valueKey(right);
}
protected optionLabel(value: CatalogVariantValue): string {
const values = Array.isArray(value) ? value : [value];
return values.map((item) => (typeof item === 'string' ? item : item.label)).join(', ');
}
protected selectedOption(row: TicketSelectionRow, key: string): string | string[] | null {
return row.selectedValues[key] ?? null;
}
protected rowDisabled(row: TicketSelectionRow): boolean {
return this.disabled() || this.isBusy(row);
}
protected rowStatus(row: TicketSelectionRow): string | null {
if (row.status === 'checking') return 'Consultando disponibilidad…';
if (row.status === 'reserving') return 'Reservando entrada…';
if (row.status === 'removing') return 'Liberando entrada…';
if (row.status === 'reserved' && row.error === null) return 'Entrada reservada';
return null;
}
private loadOptions(rowId: number, selectedValues: Record<string, string | string[]>): void {
const row = this.findRow(rowId);
if (!row) return;
this.optionRequests.get(rowId)?.unsubscribe();
this.patchRow(rowId, {
selectedValues,
status: 'checking',
error: null,
});
this.patchRow(rowId, { selectedValues, status: 'checking', error: null });
const request = this.catalogService
.getVariantOptions(this.productId(), {
@@ -207,29 +218,32 @@ export class ProductTicketSelectorComponent {
})
.subscribe({
next: (response) => {
const variants = response.variants as TicketSelectorVariant[];
this.applySummary(response, row);
if (!response.valid) {
this.patchRow(rowId, {
variantId: null,
selectedValues: {},
remoteVariants: null,
status: 'error',
error: 'La combinación seleccionada ya no está disponible.',
resetToken: row.resetToken + 1,
selectors: response.selectors,
status: response.available_variant_count === 0 ? 'selecting' : 'error',
error:
response.available_variant_count === 0
? null
: 'La combinación seleccionada ya no está disponible.',
});
return;
}
this.patchRow(rowId, {
variantId: response.resolved_variant_id,
remoteVariants: variants,
variantId: response.resolved_variant?.id ?? null,
selectedValues: response.selected_values,
selectors: response.selectors,
status: 'selecting',
error: null,
});
if (response.resolved_variant_id !== null) {
this.reserveRow(rowId, response.resolved_variant_id);
if (response.resolved_variant !== null) {
this.reserveRow(rowId, response.resolved_variant.id);
}
},
error: (error: HttpErrorResponse) => {
@@ -242,34 +256,6 @@ export class ProductTicketSelectorComponent {
this.optionRequests.set(rowId, request);
}
protected variantsForRow(rowId: number): VariantSelectorVariant[] {
const row = this.findRow(rowId);
const selectedByOtherRows = new Set(
this.rows()
.filter((candidate) => candidate.id !== rowId)
.flatMap((candidate) => {
const variantId = candidate.reservedVariantId ?? candidate.variantId;
return variantId === null ? [] : [variantId];
}),
);
const variants = row?.remoteVariants ?? this.selectableVariants();
return variants.filter((variant) => !selectedByOtherRows.has(variant.id as number));
}
protected rowDisabled(row: TicketSelectionRow): boolean {
return this.disabled() || this.isBusy(row);
}
protected rowStatus(row: TicketSelectionRow): string | null {
if (row.status === 'checking') return 'Consultando disponibilidad…';
if (row.status === 'reserving') return 'Reservando entrada…';
if (row.status === 'removing') return 'Liberando entrada…';
if (row.status === 'reserved' && row.error === null) return 'Entrada reservada';
return null;
}
private reserveRow(rowId: number, variantId: number): void {
const row = this.findRow(rowId);
if (!row || (row.status === 'reserved' && row.reservedVariantId === variantId)) return;
@@ -305,6 +291,9 @@ export class ProductTicketSelectorComponent {
status: 'reserved',
error: null,
});
if (row.cartItemId === null) {
this.availableVariantCount.update((count) => Math.max(0, count - 1));
}
},
error: (error: HttpErrorResponse) => {
this.patchRow(rowId, {
@@ -317,6 +306,16 @@ export class ProductTicketSelectorComponent {
this.reservationRequests.set(rowId, request);
}
private applySummary(response: CatalogVariantOptionsResponse, row: TicketSelectionRow): void {
this.availableVariantCount.set(
Math.max(0, response.available_variant_count - (row.cartItemId === null ? 0 : 1)),
);
this.remotePriceRange.set({
minimum: Number(response.price_range.minimum),
maximum: Number(response.price_range.maximum),
});
}
private createRow(id: number): TicketSelectionRow {
return {
id,
@@ -324,10 +323,9 @@ export class ProductTicketSelectorComponent {
reservedVariantId: null,
cartItemId: null,
selectedValues: {},
remoteVariants: null,
status: 'selecting',
selectors: [],
status: 'checking',
error: null,
resetToken: 0,
};
}
@@ -349,24 +347,20 @@ export class ProductTicketSelectorComponent {
return ['checking', 'reserving', 'removing'].includes(row.status);
}
private normalizeSelections(
selections: Record<string, VariantAttributeValue>,
): Record<string, string | string[]> {
return Object.fromEntries(
Object.entries(selections).map(([key, value]) => [key, this.normalizeValue(value)]),
);
}
private normalizeValue(value: VariantAttributeValue): string | string[] {
private normalizeValue(value: CatalogVariantValue): string | string[] {
return Array.isArray(value)
? value.map((item) => this.scalarValue(item))
: this.scalarValue(value);
}
private scalarValue(value: VariantAttributeScalar): string {
private scalarValue(value: string | { value: string }): string {
return typeof value === 'string' ? value : value.value;
}
private valueKey(value: CatalogVariantValue): string {
return JSON.stringify(this.normalizeValue(value));
}
private errorMessage(error: HttpErrorResponse, fallback: string): string {
return typeof error.error?.message === 'string' ? error.error.message : fallback;
}