feat(ticket-selector): enhance validation feedback and improve cart synchronization
This commit is contained in:
@@ -34,7 +34,12 @@
|
|||||||
"server": "src/main.server.ts",
|
"server": "src/main.server.ts",
|
||||||
"outputMode": "server",
|
"outputMode": "server",
|
||||||
"security": {
|
"security": {
|
||||||
"allowedHosts": ["localhost", "127.0.0.1"]
|
"allowedHosts": [
|
||||||
|
"localhost",
|
||||||
|
"localhost:4300",
|
||||||
|
"127.0.0.1",
|
||||||
|
"127.0.0.1:4300"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"ssr": {
|
"ssr": {
|
||||||
"entry": "src/server.ts"
|
"entry": "src/server.ts"
|
||||||
|
|||||||
@@ -17,7 +17,31 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="ticket-selector__selection">
|
<div class="ticket-selector__selection">
|
||||||
<span class="ticket-selector__label">Seleccioná Entrada/s:</span>
|
<div class="ticket-selector__label-row">
|
||||||
|
<span class="ticket-selector__label">Seleccioná Entrada/s:</span>
|
||||||
|
@if (validationStatus(); as status) {
|
||||||
|
<span
|
||||||
|
class="ticket-selector__validation ticket-selector__validation--{{ status }}"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
@switch (status) {
|
||||||
|
@case ('validating') {
|
||||||
|
<span class="ticket-selector__validation-spinner" aria-hidden="true"></span>
|
||||||
|
Validando
|
||||||
|
}
|
||||||
|
@case ('validated') {
|
||||||
|
<i class="fa-solid fa-check" aria-hidden="true"></i>
|
||||||
|
Validado
|
||||||
|
}
|
||||||
|
@case ('error') {
|
||||||
|
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
|
||||||
|
Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="ticket-selector__rows">
|
<div class="ticket-selector__rows">
|
||||||
@for (row of rows(); track row.id) {
|
@for (row of rows(); track row.id) {
|
||||||
@@ -45,12 +69,6 @@
|
|||||||
[disabled]="rowDisabled(row)"
|
[disabled]="rowDisabled(row)"
|
||||||
(clicked)="removeRow(row.id)"
|
(clicked)="removeRow(row.id)"
|
||||||
/>
|
/>
|
||||||
@if (rowStatus(row); as status) {
|
|
||||||
<span class="ticket-selector__row-status">{{ status }}</span>
|
|
||||||
}
|
|
||||||
@if (row.error; as error) {
|
|
||||||
<span class="ticket-selector__row-error">{{ error }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,14 +44,49 @@
|
|||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__label {
|
&__label-row {
|
||||||
display: block;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
min-height: 1.5rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
color: #555;
|
color: #555;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
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 {
|
&__rows {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -64,20 +99,6 @@
|
|||||||
gap: 0.5rem;
|
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 {
|
&__fields {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
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) {
|
@media (max-width: 767.98px) {
|
||||||
.ticket-selector {
|
.ticket-selector {
|
||||||
&__header {
|
&__header {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import '@angular/compiler';
|
import '@angular/compiler';
|
||||||
|
import { signal } from '@angular/core';
|
||||||
import { TestBed, getTestBed } from '@angular/core/testing';
|
import { TestBed, getTestBed } from '@angular/core/testing';
|
||||||
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
|
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
|
||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
@@ -33,6 +34,7 @@ describe('ProductTicketSelectorComponent', () => {
|
|||||||
price_range: { minimum: '10000.00', maximum: '10000.00' },
|
price_range: { minimum: '10000.00', maximum: '10000.00' },
|
||||||
};
|
};
|
||||||
const catalogService = {
|
const catalogService = {
|
||||||
|
withoutLoading: vi.fn(),
|
||||||
getVariantOptions: vi
|
getVariantOptions: vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockReturnValueOnce(
|
.mockReturnValueOnce(
|
||||||
@@ -77,6 +79,8 @@ describe('ProductTicketSelectorComponent', () => {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
const cartService = {
|
const cartService = {
|
||||||
|
cart: signal(null).asReadonly(),
|
||||||
|
withoutLoading: vi.fn(),
|
||||||
addItem: vi.fn().mockReturnValue(
|
addItem: vi.fn().mockReturnValue(
|
||||||
of({
|
of({
|
||||||
data: {
|
data: {
|
||||||
@@ -98,6 +102,8 @@ describe('ProductTicketSelectorComponent', () => {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
catalogService.withoutLoading.mockReturnValue(catalogService);
|
||||||
|
cartService.withoutLoading.mockReturnValue(cartService);
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [ProductTicketSelectorComponent],
|
imports: [ProductTicketSelectorComponent],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -109,6 +115,10 @@ describe('ProductTicketSelectorComponent', () => {
|
|||||||
fixture.componentRef.setInput('productId', 7);
|
fixture.componentRef.setInput('productId', 7);
|
||||||
fixture.componentRef.setInput('title', 'Entrada');
|
fixture.componentRef.setInput('title', 'Entrada');
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
expect(catalogService.getVariantOptions).toHaveBeenCalledTimes(1);
|
||||||
|
expect(catalogService.withoutLoading).toHaveBeenCalled();
|
||||||
|
|
||||||
fixture.componentInstance['onSelectionChange'](1, 'sector', {
|
fixture.componentInstance['onSelectionChange'](1, 'sector', {
|
||||||
value: 'a',
|
value: 'a',
|
||||||
@@ -124,11 +134,123 @@ describe('ProductTicketSelectorComponent', () => {
|
|||||||
fixture.componentInstance['onSelectionChange'](1, 'seat', '1');
|
fixture.componentInstance['onSelectionChange'](1, 'seat', '1');
|
||||||
|
|
||||||
expect(cartService.addItem).toHaveBeenCalledWith(7, 401, 1);
|
expect(cartService.addItem).toHaveBeenCalledWith(7, 401, 1);
|
||||||
|
expect(cartService.withoutLoading).toHaveBeenCalled();
|
||||||
expect(fixture.componentInstance['rows']()[0]).toMatchObject({
|
expect(fixture.componentInstance['rows']()[0]).toMatchObject({
|
||||||
variantId: 401,
|
variantId: 401,
|
||||||
reservedVariantId: 401,
|
reservedVariantId: 401,
|
||||||
cartItemId: 25,
|
cartItemId: 25,
|
||||||
status: 'reserved',
|
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<HTMLSelectElement>(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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { HttpErrorResponse } from '@angular/common/http';
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
import {
|
import {
|
||||||
|
afterNextRender,
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
@@ -9,11 +10,13 @@ import {
|
|||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
signal,
|
signal,
|
||||||
|
untracked,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
|
|
||||||
import { CartService } from '../../../core/services/cart/cart.service';
|
import { CartService } from '../../../core/services/cart/cart.service';
|
||||||
|
import { CartItem, CartItemVariantValue } from '../../../core/services/cart/cart.interface';
|
||||||
import {
|
import {
|
||||||
CatalogVariantOptionsResponse,
|
CatalogVariantOptionsResponse,
|
||||||
CatalogVariantSelector,
|
CatalogVariantSelector,
|
||||||
@@ -31,6 +34,8 @@ type TicketSelectionStatus =
|
|||||||
| 'removing'
|
| 'removing'
|
||||||
| 'error';
|
| 'error';
|
||||||
|
|
||||||
|
type TicketValidationStatus = 'validating' | 'validated' | 'error';
|
||||||
|
|
||||||
interface TicketSelectionRow {
|
interface TicketSelectionRow {
|
||||||
id: number;
|
id: number;
|
||||||
variantId: number | null;
|
variantId: number | null;
|
||||||
@@ -55,6 +60,7 @@ export class ProductTicketSelectorComponent {
|
|||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly optionRequests = new Map<number, Subscription>();
|
private readonly optionRequests = new Map<number, Subscription>();
|
||||||
private readonly reservationRequests = new Map<number, Subscription>();
|
private readonly reservationRequests = new Map<number, Subscription>();
|
||||||
|
private readonly viewReady = signal(false);
|
||||||
|
|
||||||
readonly productId = input.required<number>();
|
readonly productId = input.required<number>();
|
||||||
readonly title = input.required<string>();
|
readonly title = input.required<string>();
|
||||||
@@ -80,6 +86,15 @@ export class ProductTicketSelectorComponent {
|
|||||||
row.variantId === row.reservedVariantId,
|
row.variantId === row.reservedVariantId,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
protected readonly validationStatus = computed<TicketValidationStatus | null>(() => {
|
||||||
|
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(
|
protected readonly canAddRow = computed(
|
||||||
() =>
|
() =>
|
||||||
this.availableVariantCount() > 0 &&
|
this.availableVariantCount() > 0 &&
|
||||||
@@ -100,10 +115,23 @@ export class ProductTicketSelectorComponent {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
this.productId();
|
const viewReady = this.viewReady();
|
||||||
this.loadOptions(1, {});
|
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.destroyRef.onDestroy(() => {
|
||||||
this.optionRequests.forEach((request) => request.unsubscribe());
|
this.optionRequests.forEach((request) => request.unsubscribe());
|
||||||
this.reservationRequests.forEach((request) => request.unsubscribe());
|
this.reservationRequests.forEach((request) => request.unsubscribe());
|
||||||
@@ -140,18 +168,21 @@ export class ProductTicketSelectorComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.patchRow(rowId, { status: 'removing', error: null });
|
this.patchRow(rowId, { status: 'removing', error: null });
|
||||||
const request = this.cartService.removeItem(row.cartItemId).subscribe({
|
const request = this.cartService
|
||||||
next: () => {
|
.withoutLoading()
|
||||||
this.availableVariantCount.update((count) => count + 1);
|
.removeItem(row.cartItemId)
|
||||||
this.deleteRow(rowId);
|
.subscribe({
|
||||||
},
|
next: () => {
|
||||||
error: (error: HttpErrorResponse) => {
|
this.availableVariantCount.update((count) => count + 1);
|
||||||
this.patchRow(rowId, {
|
this.deleteRow(rowId);
|
||||||
status: 'reserved',
|
},
|
||||||
error: this.errorMessage(error, 'No se pudo liberar la entrada.'),
|
error: (error: HttpErrorResponse) => {
|
||||||
});
|
this.patchRow(rowId, {
|
||||||
},
|
status: 'reserved',
|
||||||
});
|
error: this.errorMessage(error, 'No se pudo liberar la entrada.'),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
this.reservationRequests.set(rowId, request);
|
this.reservationRequests.set(rowId, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,12 +206,12 @@ export class ProductTicketSelectorComponent {
|
|||||||
this.loadOptions(rowId, selectedValues);
|
this.loadOptions(rowId, selectedValues);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected compareValues(
|
protected readonly compareValues = (
|
||||||
left: CatalogVariantValue | null,
|
left: CatalogVariantValue | null,
|
||||||
right: CatalogVariantValue | null,
|
right: CatalogVariantValue | null,
|
||||||
): boolean {
|
): boolean => {
|
||||||
return left !== null && right !== null && this.valueKey(left) === this.valueKey(right);
|
return left !== null && right !== null && this.valueKey(left) === this.valueKey(right);
|
||||||
}
|
};
|
||||||
|
|
||||||
protected optionLabel(value: CatalogVariantValue): string {
|
protected optionLabel(value: CatalogVariantValue): string {
|
||||||
const values = Array.isArray(value) ? value : [value];
|
const values = Array.isArray(value) ? value : [value];
|
||||||
@@ -195,15 +226,6 @@ export class ProductTicketSelectorComponent {
|
|||||||
return this.disabled() || this.isBusy(row);
|
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 {
|
private loadOptions(rowId: number, selectedValues: Record<string, string | string[]>): void {
|
||||||
const row = this.findRow(rowId);
|
const row = this.findRow(rowId);
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
@@ -212,6 +234,7 @@ export class ProductTicketSelectorComponent {
|
|||||||
this.patchRow(rowId, { selectedValues, status: 'checking', error: null });
|
this.patchRow(rowId, { selectedValues, status: 'checking', error: null });
|
||||||
|
|
||||||
const request = this.catalogService
|
const request = this.catalogService
|
||||||
|
.withoutLoading()
|
||||||
.getVariantOptions(this.productId(), {
|
.getVariantOptions(this.productId(), {
|
||||||
selected_values: selectedValues,
|
selected_values: selectedValues,
|
||||||
cart_item_id: row.cartItemId,
|
cart_item_id: row.cartItemId,
|
||||||
@@ -243,7 +266,11 @@ export class ProductTicketSelectorComponent {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.resolved_variant !== null) {
|
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) => {
|
error: (error: HttpErrorResponse) => {
|
||||||
@@ -261,10 +288,11 @@ export class ProductTicketSelectorComponent {
|
|||||||
if (!row || (row.status === 'reserved' && row.reservedVariantId === variantId)) return;
|
if (!row || (row.status === 'reserved' && row.reservedVariantId === variantId)) return;
|
||||||
|
|
||||||
this.patchRow(rowId, { status: 'reserving', error: null });
|
this.patchRow(rowId, { status: 'reserving', error: null });
|
||||||
|
const cartService = this.cartService.withoutLoading();
|
||||||
const operation =
|
const operation =
|
||||||
row.cartItemId === null
|
row.cartItemId === null
|
||||||
? this.cartService.addItem(this.productId(), variantId, 1)
|
? cartService.addItem(this.productId(), variantId, 1)
|
||||||
: this.cartService.updateItemVariant(row.cartItemId, 1, variantId);
|
: cartService.updateItemVariant(row.cartItemId, 1, variantId);
|
||||||
const request = operation.subscribe({
|
const request = operation.subscribe({
|
||||||
next: (response) => {
|
next: (response) => {
|
||||||
const cartItem =
|
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<string, CartItemVariantValue>,
|
||||||
|
): Record<string, string | string[]> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(values).map(([key, value]) => [key, this.normalizeValue(value)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sameSelectedValues(
|
||||||
|
left: Record<string, string | string[]>,
|
||||||
|
right: Record<string, string | string[]>,
|
||||||
|
): boolean {
|
||||||
|
return JSON.stringify(left) === JSON.stringify(right);
|
||||||
|
}
|
||||||
|
|
||||||
private findRow(rowId: number): TicketSelectionRow | undefined {
|
private findRow(rowId: number): TicketSelectionRow | undefined {
|
||||||
return this.rows().find((row) => row.id === rowId);
|
return this.rows().find((row) => row.id === rowId);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user