diff --git a/src/app/core/services/catalog/catalog.interface.ts b/src/app/core/services/catalog/catalog.interface.ts index cb297c0..3ae65e1 100644 --- a/src/app/core/services/catalog/catalog.interface.ts +++ b/src/app/core/services/catalog/catalog.interface.ts @@ -118,11 +118,23 @@ export interface CatalogFeaturedItemVariant { } export interface CatalogVariantOptionsResponse { - variants: CatalogFeaturedItemVariant[]; - options: Record; + selectors: CatalogVariantSelector[]; selected_values: Record; - 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 { diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html index c5b4107..b36a8a7 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html @@ -772,7 +772,6 @@ [description]="testTicketSelectorProduct.description" [price]="testTicketSelectorProduct.price" [imageUrl]="testTicketSelectorProduct.imageUrl" - [variants]="testTicketSelectorProduct.variants" (buy)="onTicketBuy($event)" /> 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 f7445c5..5fd6425 100644 --- a/src/app/shared/components/product-list/product-list.component.html +++ b/src/app/shared/components/product-list/product-list.component.html @@ -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)" /> 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 9f33317..7929bd6 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 @@ -22,16 +22,23 @@
@for (row of rows(); track row.id) {
- +
+ @for (selector of row.selectors; track selector.key) { + + } +
- @if (selectableVariants().length === 0) { + @if (availableVariantCount() === 0) {

No hay entradas disponibles.

} diff --git a/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.scss b/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.scss index a58bc49..34f210f 100644 --- a/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.scss +++ b/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.scss @@ -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 { 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 65cbd7c..7583910 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 @@ -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; - 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(''); readonly price = input(0); readonly imageUrl = input(null); - readonly variants = input([]); - readonly unavailableVariantIds = input>(new Set()); readonly disabled = input(false); readonly buy = output(); protected readonly rows = signal([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(() => - 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): 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, - ): Record { - 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; }