diff --git a/angular.json b/angular.json
index 1b92efa..f75e020 100644
--- a/angular.json
+++ b/angular.json
@@ -34,7 +34,12 @@
"server": "src/main.server.ts",
"outputMode": "server",
"security": {
- "allowedHosts": ["localhost", "127.0.0.1"]
+ "allowedHosts": [
+ "localhost",
+ "localhost:4300",
+ "127.0.0.1",
+ "127.0.0.1:4300"
+ ]
},
"ssr": {
"entry": "src/server.ts"
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 7929bd6..a807a28 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
@@ -17,7 +17,31 @@
-
Seleccioná Entrada/s:
+
+ Seleccioná Entrada/s:
+ @if (validationStatus(); as status) {
+
+ @switch (status) {
+ @case ('validating') {
+
+ Validando
+ }
+ @case ('validated') {
+
+ Validado
+ }
+ @case ('error') {
+
+ Error
+ }
+ }
+
+ }
+
@for (row of rows(); track row.id) {
@@ -45,12 +69,6 @@
[disabled]="rowDisabled(row)"
(clicked)="removeRow(row.id)"
/>
- @if (rowStatus(row); as status) {
- {{ status }}
- }
- @if (row.error; as error) {
- {{ error }}
- }
}
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 34f210f..caa11c6 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
@@ -44,14 +44,49 @@
padding-top: 1rem;
}
- &__label {
- display: block;
+ &__label-row {
+ display: flex;
+ align-items: center;
+ gap: 0.65rem;
+ min-height: 1.5rem;
margin-bottom: 0.5rem;
+ }
+
+ &__label {
color: #555;
font-size: 15px;
font-weight: 700;
}
+ &__validation {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+ font-size: 13px;
+ font-weight: 600;
+
+ &--validating {
+ color: #777;
+ }
+
+ &--validated {
+ color: var(--success-color, #198754);
+ }
+
+ &--error {
+ color: var(--danger-color, #dc3545);
+ }
+ }
+
+ &__validation-spinner {
+ width: 0.8rem;
+ height: 0.8rem;
+ border: 2px solid currentcolor;
+ border-right-color: transparent;
+ border-radius: 50%;
+ animation: ticket-selector-spin 0.7s linear infinite;
+ }
+
&__rows {
display: grid;
gap: 0.5rem;
@@ -64,20 +99,6 @@
gap: 0.5rem;
}
- &__row-status,
- &__row-error {
- grid-column: 1 / -1;
- font-size: 13px;
- }
-
- &__row-status {
- color: var(--success-color, #198754);
- }
-
- &__row-error {
- color: var(--danger-color, #dc3545);
- }
-
&__fields {
min-width: 0;
display: grid;
@@ -129,6 +150,18 @@
}
}
+@keyframes ticket-selector-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .ticket-selector__validation-spinner {
+ animation-duration: 1.4s;
+ }
+}
+
@media (max-width: 767.98px) {
.ticket-selector {
&__header {
diff --git a/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.spec.ts b/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.spec.ts
index 40a2997..f382f5b 100644
--- a/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.spec.ts
+++ b/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.spec.ts
@@ -1,4 +1,5 @@
import '@angular/compiler';
+import { signal } from '@angular/core';
import { TestBed, getTestBed } from '@angular/core/testing';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { of } from 'rxjs';
@@ -33,6 +34,7 @@ describe('ProductTicketSelectorComponent', () => {
price_range: { minimum: '10000.00', maximum: '10000.00' },
};
const catalogService = {
+ withoutLoading: vi.fn(),
getVariantOptions: vi
.fn()
.mockReturnValueOnce(
@@ -77,6 +79,8 @@ describe('ProductTicketSelectorComponent', () => {
),
};
const cartService = {
+ cart: signal(null).asReadonly(),
+ withoutLoading: vi.fn(),
addItem: vi.fn().mockReturnValue(
of({
data: {
@@ -98,6 +102,8 @@ describe('ProductTicketSelectorComponent', () => {
}),
),
};
+ catalogService.withoutLoading.mockReturnValue(catalogService);
+ cartService.withoutLoading.mockReturnValue(cartService);
await TestBed.configureTestingModule({
imports: [ProductTicketSelectorComponent],
providers: [
@@ -109,6 +115,10 @@ describe('ProductTicketSelectorComponent', () => {
fixture.componentRef.setInput('productId', 7);
fixture.componentRef.setInput('title', 'Entrada');
fixture.detectChanges();
+ await fixture.whenStable();
+
+ expect(catalogService.getVariantOptions).toHaveBeenCalledTimes(1);
+ expect(catalogService.withoutLoading).toHaveBeenCalled();
fixture.componentInstance['onSelectionChange'](1, 'sector', {
value: 'a',
@@ -124,11 +134,123 @@ describe('ProductTicketSelectorComponent', () => {
fixture.componentInstance['onSelectionChange'](1, 'seat', '1');
expect(cartService.addItem).toHaveBeenCalledWith(7, 401, 1);
+ expect(cartService.withoutLoading).toHaveBeenCalled();
expect(fixture.componentInstance['rows']()[0]).toMatchObject({
variantId: 401,
reservedVariantId: 401,
cartItemId: 25,
status: 'reserved',
});
+
+ fixture.detectChanges();
+ const validation = fixture.nativeElement.querySelector('.ticket-selector__validation');
+ expect(validation.textContent).toContain('Validado');
+ expect(validation.querySelector('.fa-check')).not.toBeNull();
+ expect(fixture.nativeElement.querySelector('.ticket-selector__row-status')).toBeNull();
+ expect(fixture.nativeElement.querySelector('.ticket-selector__row-error')).toBeNull();
+ });
+
+ it('restores selections that are already reserved in the cart', async () => {
+ const cartState = signal({
+ id: 10,
+ tenant_codigo: 'demo',
+ status: 'active',
+ subtotal: '10000.00',
+ items: [
+ {
+ id: 25,
+ cantidad: 1,
+ precio_unitario: '10000.00',
+ catalog_item_id: 7,
+ variant_id: 401,
+ product: {
+ nombre: 'Entrada',
+ imagen: null,
+ variants: [
+ {
+ id: 401,
+ precio: '10000.00',
+ stock_tecnico: 1,
+ values: {
+ sector: { value: 'a', label: 'Sector A' },
+ seat: '1',
+ },
+ },
+ ],
+ },
+ },
+ ],
+ });
+ const catalogService = {
+ withoutLoading: vi.fn(),
+ getVariantOptions: vi.fn().mockReturnValue(
+ of({
+ valid: true,
+ available_variant_count: 1,
+ matching_variant_count: 1,
+ price_range: { minimum: '10000.00', maximum: '10000.00' },
+ selectors: [
+ {
+ key: 'sector',
+ label: 'Sector',
+ options: [{ value: 'a', label: 'Sector A' }],
+ enabled: true,
+ },
+ { key: 'seat', label: 'Seat', options: ['1'], enabled: true },
+ ],
+ selected_values: { sector: 'a', seat: '1' },
+ resolved_variant: {
+ id: 401,
+ precio: '10000.00',
+ stock_tecnico: 1,
+ values: { sector: { value: 'a', label: 'Sector A' }, seat: '1' },
+ },
+ }),
+ ),
+ };
+ const cartService = {
+ cart: cartState.asReadonly(),
+ withoutLoading: vi.fn(),
+ addItem: vi.fn(),
+ updateItemVariant: vi.fn(),
+ };
+ catalogService.withoutLoading.mockReturnValue(catalogService);
+ cartService.withoutLoading.mockReturnValue(cartService);
+
+ await TestBed.configureTestingModule({
+ imports: [ProductTicketSelectorComponent],
+ providers: [
+ { provide: CatalogService, useValue: catalogService },
+ { provide: CartService, useValue: cartService },
+ ],
+ }).compileComponents();
+ const fixture = TestBed.createComponent(ProductTicketSelectorComponent);
+ fixture.componentRef.setInput('productId', 7);
+ fixture.componentRef.setInput('title', 'Entrada');
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ expect(catalogService.getVariantOptions).toHaveBeenCalledWith(7, {
+ selected_values: { sector: 'a', seat: '1' },
+ cart_item_id: 25,
+ });
+ expect(fixture.componentInstance['rows']()[0]).toMatchObject({
+ variantId: 401,
+ reservedVariantId: 401,
+ cartItemId: 25,
+ selectedValues: { sector: 'a', seat: '1' },
+ status: 'reserved',
+ });
+ expect(cartService.addItem).not.toHaveBeenCalled();
+ expect(cartService.updateItemVariant).not.toHaveBeenCalled();
+ const selects = Array.from(fixture.nativeElement.querySelectorAll('select'));
+ expect(selects.map((select) => select.selectedOptions[0]?.textContent?.trim())).toEqual([
+ 'Sector A',
+ '1',
+ ]);
+ expect(
+ fixture.nativeElement.querySelector('.ticket-selector__validation').textContent,
+ ).toContain('Validado');
});
});
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 7583910..415772a 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,5 +1,6 @@
import { HttpErrorResponse } from '@angular/common/http';
import {
+ afterNextRender,
ChangeDetectionStrategy,
Component,
computed,
@@ -9,11 +10,13 @@ import {
input,
output,
signal,
+ untracked,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Subscription } from 'rxjs';
import { CartService } from '../../../core/services/cart/cart.service';
+import { CartItem, CartItemVariantValue } from '../../../core/services/cart/cart.interface';
import {
CatalogVariantOptionsResponse,
CatalogVariantSelector,
@@ -31,6 +34,8 @@ type TicketSelectionStatus =
| 'removing'
| 'error';
+type TicketValidationStatus = 'validating' | 'validated' | 'error';
+
interface TicketSelectionRow {
id: number;
variantId: number | null;
@@ -55,6 +60,7 @@ export class ProductTicketSelectorComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly optionRequests = new Map();
private readonly reservationRequests = new Map();
+ private readonly viewReady = signal(false);
readonly productId = input.required();
readonly title = input.required();
@@ -80,6 +86,15 @@ export class ProductTicketSelectorComponent {
row.variantId === row.reservedVariantId,
),
);
+ protected readonly validationStatus = computed(() => {
+ const rows = this.rows();
+
+ if (rows.some((row) => this.isBusy(row))) return 'validating';
+ if (rows.some((row) => row.status === 'error' || row.error !== null)) return 'error';
+ if (rows.length > 0 && rows.every((row) => row.status === 'reserved')) return 'validated';
+
+ return null;
+ });
protected readonly canAddRow = computed(
() =>
this.availableVariantCount() > 0 &&
@@ -100,10 +115,23 @@ export class ProductTicketSelectorComponent {
constructor() {
effect(() => {
- this.productId();
- this.loadOptions(1, {});
+ const viewReady = this.viewReady();
+ const cart = this.cartService.cart?.() ?? null;
+
+ if (!viewReady) return;
+
+ untracked(() => {
+ if (cart === null) {
+ this.ensureEmptyRowLoaded();
+ return;
+ }
+
+ this.synchronizeWithCart(cart.items);
+ });
});
+ afterNextRender(() => this.viewReady.set(true));
+
this.destroyRef.onDestroy(() => {
this.optionRequests.forEach((request) => request.unsubscribe());
this.reservationRequests.forEach((request) => request.unsubscribe());
@@ -140,18 +168,21 @@ export class ProductTicketSelectorComponent {
}
this.patchRow(rowId, { status: 'removing', error: null });
- const request = this.cartService.removeItem(row.cartItemId).subscribe({
- next: () => {
- this.availableVariantCount.update((count) => count + 1);
- this.deleteRow(rowId);
- },
- error: (error: HttpErrorResponse) => {
- this.patchRow(rowId, {
- status: 'reserved',
- error: this.errorMessage(error, 'No se pudo liberar la entrada.'),
- });
- },
- });
+ const request = this.cartService
+ .withoutLoading()
+ .removeItem(row.cartItemId)
+ .subscribe({
+ next: () => {
+ this.availableVariantCount.update((count) => count + 1);
+ this.deleteRow(rowId);
+ },
+ error: (error: HttpErrorResponse) => {
+ this.patchRow(rowId, {
+ status: 'reserved',
+ error: this.errorMessage(error, 'No se pudo liberar la entrada.'),
+ });
+ },
+ });
this.reservationRequests.set(rowId, request);
}
@@ -175,12 +206,12 @@ export class ProductTicketSelectorComponent {
this.loadOptions(rowId, selectedValues);
}
- protected compareValues(
+ protected readonly compareValues = (
left: CatalogVariantValue | null,
right: CatalogVariantValue | null,
- ): boolean {
+ ): boolean => {
return left !== null && right !== null && this.valueKey(left) === this.valueKey(right);
- }
+ };
protected optionLabel(value: CatalogVariantValue): string {
const values = Array.isArray(value) ? value : [value];
@@ -195,15 +226,6 @@ export class ProductTicketSelectorComponent {
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;
@@ -212,6 +234,7 @@ export class ProductTicketSelectorComponent {
this.patchRow(rowId, { selectedValues, status: 'checking', error: null });
const request = this.catalogService
+ .withoutLoading()
.getVariantOptions(this.productId(), {
selected_values: selectedValues,
cart_item_id: row.cartItemId,
@@ -243,7 +266,11 @@ export class ProductTicketSelectorComponent {
});
if (response.resolved_variant !== null) {
- this.reserveRow(rowId, response.resolved_variant.id);
+ if (row.cartItemId !== null && row.reservedVariantId === response.resolved_variant.id) {
+ this.patchRow(rowId, { status: 'reserved' });
+ } else {
+ this.reserveRow(rowId, response.resolved_variant.id);
+ }
}
},
error: (error: HttpErrorResponse) => {
@@ -261,10 +288,11 @@ export class ProductTicketSelectorComponent {
if (!row || (row.status === 'reserved' && row.reservedVariantId === variantId)) return;
this.patchRow(rowId, { status: 'reserving', error: null });
+ const cartService = this.cartService.withoutLoading();
const operation =
row.cartItemId === null
- ? this.cartService.addItem(this.productId(), variantId, 1)
- : this.cartService.updateItemVariant(row.cartItemId, 1, variantId);
+ ? cartService.addItem(this.productId(), variantId, 1)
+ : cartService.updateItemVariant(row.cartItemId, 1, variantId);
const request = operation.subscribe({
next: (response) => {
const cartItem =
@@ -329,6 +357,108 @@ export class ProductTicketSelectorComponent {
};
}
+ private createCartRow(item: CartItem, id = this.nextRowId++): TicketSelectionRow | null {
+ if (item.variant_id === null) return null;
+
+ const variant = item.product?.variants?.find(({ id }) => id === item.variant_id);
+ if (!variant) return null;
+
+ return {
+ id,
+ variantId: item.variant_id,
+ reservedVariantId: item.variant_id,
+ cartItemId: item.id,
+ selectedValues: this.normalizeCartValues(variant.values),
+ selectors: [],
+ status: 'checking',
+ error: null,
+ };
+ }
+
+ private synchronizeWithCart(items: CartItem[]): void {
+ const cartItems = items.filter(
+ (item) => item.catalog_item_id === this.productId() && item.variant_id !== null,
+ );
+ const currentRows = this.rows();
+
+ if (cartItems.length === 0) {
+ if (currentRows.some((row) => row.cartItemId !== null)) {
+ this.replaceRows([this.createRow(this.nextRowId++)]);
+ }
+ this.ensureEmptyRowLoaded();
+ return;
+ }
+
+ const rowsToLoad: TicketSelectionRow[] = [];
+ const synchronizedRows = cartItems.flatMap((item) => {
+ const existingRow = currentRows.find((row) => row.cartItemId === item.id);
+ const cartRow = this.createCartRow(item, existingRow?.id);
+
+ if (!cartRow) return [];
+
+ if (
+ existingRow &&
+ existingRow.reservedVariantId === cartRow.reservedVariantId &&
+ this.sameSelectedValues(existingRow.selectedValues, cartRow.selectedValues)
+ ) {
+ return [existingRow];
+ }
+
+ rowsToLoad.push(cartRow);
+ return [cartRow];
+ });
+ const localRows = currentRows.filter(
+ (row) => row.cartItemId === null && Object.keys(row.selectedValues).length > 0,
+ );
+
+ this.replaceRows([...synchronizedRows, ...localRows]);
+ rowsToLoad.forEach((row) => this.loadOptions(row.id, row.selectedValues));
+ }
+
+ private ensureEmptyRowLoaded(): void {
+ const currentRows = this.rows();
+
+ if (currentRows.length === 0) {
+ const row = this.createRow(this.nextRowId++);
+ this.rows.set([row]);
+ this.loadOptions(row.id, {});
+ return;
+ }
+
+ if (currentRows.length === 1 && !this.optionRequests.has(currentRows[0].id)) {
+ this.loadOptions(currentRows[0].id, currentRows[0].selectedValues);
+ }
+ }
+
+ private replaceRows(rows: TicketSelectionRow[]): void {
+ const retainedIds = new Set(rows.map(({ id }) => id));
+
+ this.rows().forEach((row) => {
+ if (retainedIds.has(row.id)) return;
+
+ this.optionRequests.get(row.id)?.unsubscribe();
+ this.reservationRequests.get(row.id)?.unsubscribe();
+ this.optionRequests.delete(row.id);
+ this.reservationRequests.delete(row.id);
+ });
+ this.rows.set(rows);
+ }
+
+ private normalizeCartValues(
+ values: Record,
+ ): Record {
+ return Object.fromEntries(
+ Object.entries(values).map(([key, value]) => [key, this.normalizeValue(value)]),
+ );
+ }
+
+ private sameSelectedValues(
+ left: Record,
+ right: Record,
+ ): boolean {
+ return JSON.stringify(left) === JSON.stringify(right);
+ }
+
private findRow(rowId: number): TicketSelectionRow | undefined {
return this.rows().find((row) => row.id === rowId);
}