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