@ticketboothapp/booking 1.2.72 → 1.2.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,397 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect, useCallback, useMemo } from 'react';
4
+ import { formatBookingRefForDisplay } from '../../lib/format-booking-ref';
5
+ import {
6
+ fetchProducts,
7
+ updatePickupLocation,
8
+ previewPickupLocationChange,
9
+ type Product,
10
+ } from '../../lib/booking-api';
11
+ import { PickupLocationSelector } from './PickupLocationSelector';
12
+ import { useTranslations } from '../../lib/booking/i18n';
13
+ import styles from './PickupLocationDialog.module.css';
14
+
15
+ function findProductWithPickupLocations(
16
+ products: Product[],
17
+ bookingProductId: string
18
+ ): Product | null {
19
+ for (const p of products) {
20
+ if (p.productId === bookingProductId) return p;
21
+ if (p.pickupLocations?.some((loc) => loc.id === bookingProductId)) return p;
22
+ const opt = (p as Product & { options?: { optionId: string }[] })?.options?.find(
23
+ (o) => o.optionId === bookingProductId
24
+ );
25
+ if (opt) return p;
26
+ }
27
+ return null;
28
+ }
29
+
30
+ /** Itinerary rows shown on manage-booking (may include optional label/time from API). */
31
+ export interface PickupDialogItineraryStep {
32
+ label?: string | null;
33
+ time?: string | null;
34
+ stepType?: string | null;
35
+ place?: string | null;
36
+ }
37
+
38
+ /** Minimal booking shape required by the manage-booking pickup dialog. */
39
+ export interface PickupLocationDialogBooking {
40
+ bookingReference: string;
41
+ companyId?: string | null;
42
+ productId: string;
43
+ pickupLocationId?: string | null;
44
+ travelerHotel?: string | null;
45
+ customer?: { lastName?: string | null } | null;
46
+ productType?: string | null;
47
+ itineraryDisplay?: PickupDialogItineraryStep[] | null;
48
+ }
49
+
50
+ export interface PickupLocationDialogProps {
51
+ isOpen: boolean;
52
+ onClose: () => void;
53
+ booking: PickupLocationDialogBooking;
54
+ /** Prefer URL query `ref` / `bookingReference` when present (e.g. manage-booking page). */
55
+ bookingReferenceFromUrl?: string | null;
56
+ onSuccess: (updatedBooking: unknown) => void;
57
+ }
58
+
59
+ function getPickupTimeFromItinerary(itinerary: PickupDialogItineraryStep[] | null | undefined): string {
60
+ if (!itinerary?.length) return '—';
61
+ const pickupStep = itinerary.find((s) => s.stepType === 'pickup');
62
+ return pickupStep?.time?.trim() || 'TBD';
63
+ }
64
+
65
+ function getDropoffTimeFromItinerary(itinerary: PickupDialogItineraryStep[] | null | undefined): string {
66
+ if (!itinerary?.length) return '—';
67
+ const dropOffStep = [...itinerary].reverse().find((s) => s.stepType === 'drop_off');
68
+ return dropOffStep?.time?.trim() || 'TBD';
69
+ }
70
+
71
+ function getItineraryStepLabel(step: PickupDialogItineraryStep, includeTime = false): string {
72
+ if (step.label?.trim()) {
73
+ const base = step.label.trim();
74
+ return includeTime && step.time?.trim() ? `${base} ${step.time.trim()}` : base;
75
+ }
76
+ const place = step.place?.trim();
77
+ const placeDisplay =
78
+ place === 'your_pickup_location'
79
+ ? 'your pickup location'
80
+ : place === 'the_destination'
81
+ ? 'the destination'
82
+ : place ?? '';
83
+ const timePart = includeTime && step.time?.trim() ? ` ${step.time.trim()}` : '';
84
+ switch (step.stepType) {
85
+ case 'pickup':
86
+ return placeDisplay ? `Pickup at ${placeDisplay}${timePart}` : `Pickup${timePart}`;
87
+ case 'drop_off':
88
+ return placeDisplay ? `Drop off at ${placeDisplay}${timePart}` : `Drop-off${timePart}`;
89
+ case 'arrive':
90
+ return placeDisplay ? `Arrive at ${placeDisplay}${timePart}` : `Arrive${timePart}`;
91
+ case 'depart':
92
+ return placeDisplay ? `Depart ${placeDisplay}${timePart}` : `Depart${timePart}`;
93
+ case 'trip_end':
94
+ return 'Trip ends';
95
+ case 'draft':
96
+ return placeDisplay || 'Stop';
97
+ default:
98
+ return placeDisplay || (step.stepType ?? 'Step');
99
+ }
100
+ }
101
+
102
+ export function PickupLocationDialog({
103
+ isOpen,
104
+ onClose,
105
+ booking,
106
+ bookingReferenceFromUrl = '',
107
+ onSuccess,
108
+ }: PickupLocationDialogProps) {
109
+ const { t } = useTranslations();
110
+ const [products, setProducts] = useState<Product[]>([]);
111
+ const [loading, setLoading] = useState(false);
112
+ const [error, setError] = useState<string | null>(null);
113
+ const [selectedLocationId, setSelectedLocationId] = useState<string | null>(
114
+ booking.pickupLocationId ?? null
115
+ );
116
+ const [selectedLocationName, setSelectedLocationName] = useState<string | null>(null);
117
+ const [selectedCustomAddress, setSelectedCustomAddress] = useState<string | null>(
118
+ booking.travelerHotel ?? null
119
+ );
120
+ const [saving, setSaving] = useState(false);
121
+ const [previewItinerary, setPreviewItinerary] = useState<PickupDialogItineraryStep[] | null>(null);
122
+
123
+ const refFromUrl = (bookingReferenceFromUrl ?? '').trim();
124
+ const refFromBooking =
125
+ formatBookingRefForDisplay(booking.bookingReference) || booking.bookingReference || '';
126
+ const effectiveBookingRef = (refFromUrl || refFromBooking).trim();
127
+
128
+ const companyId = booking.companyId ?? '';
129
+ const lastName = booking.customer?.lastName ?? '';
130
+ const isPrivateShuttle = booking.productType === 'PRIVATE_SHUTTLE';
131
+
132
+ const product = useMemo(
133
+ () => findProductWithPickupLocations(products, booking.productId),
134
+ [products, booking.productId]
135
+ );
136
+ const pickupLocations = product?.pickupLocations ?? [];
137
+ const destinations = product?.destinations ?? [];
138
+
139
+ const loadProducts = useCallback(async () => {
140
+ if (!companyId?.trim()) {
141
+ setError('Missing company information');
142
+ return;
143
+ }
144
+ setLoading(true);
145
+ setError(null);
146
+ try {
147
+ const data = await fetchProducts(companyId);
148
+ setProducts(data);
149
+ } catch (e) {
150
+ setError(e instanceof Error ? e.message : 'Failed to load pickup locations');
151
+ } finally {
152
+ setLoading(false);
153
+ }
154
+ }, [companyId]);
155
+
156
+ useEffect(() => {
157
+ if (isOpen && companyId) loadProducts();
158
+ }, [isOpen, companyId, loadProducts]);
159
+
160
+ useEffect(() => {
161
+ if (isOpen) {
162
+ setSelectedLocationId(booking.pickupLocationId ?? null);
163
+ setSelectedLocationName(null);
164
+ setSelectedCustomAddress(booking.travelerHotel ?? null);
165
+ setPreviewItinerary(null);
166
+ }
167
+ }, [isOpen, booking.pickupLocationId, booking.travelerHotel]);
168
+
169
+ const selectedPickupName = useMemo(() => {
170
+ if (selectedCustomAddress) return selectedCustomAddress;
171
+ if (selectedLocationId) {
172
+ return (
173
+ selectedLocationName ??
174
+ pickupLocations.find((l) => l.id === selectedLocationId)?.name ??
175
+ selectedLocationId
176
+ );
177
+ }
178
+ return null;
179
+ }, [selectedLocationId, selectedLocationName, selectedCustomAddress, pickupLocations]);
180
+
181
+ const hasValidSelection = Boolean(selectedLocationId || selectedCustomAddress);
182
+ const hasChanged =
183
+ selectedLocationId !== (booking.pickupLocationId ?? null) ||
184
+ selectedCustomAddress !== (booking.travelerHotel ?? null);
185
+
186
+ useEffect(() => {
187
+ if (!isOpen || !hasValidSelection || !hasChanged || !lastName?.trim()) {
188
+ setPreviewItinerary(null);
189
+ return;
190
+ }
191
+ let cancelled = false;
192
+ const payload = selectedCustomAddress
193
+ ? { travelerHotel: selectedCustomAddress }
194
+ : selectedLocationId
195
+ ? { pickupLocationId: selectedLocationId }
196
+ : null;
197
+ if (!payload) return;
198
+ previewPickupLocationChange(effectiveBookingRef, lastName, payload)
199
+ .then((data) => {
200
+ if (!cancelled && data?.itineraryDisplay) {
201
+ setPreviewItinerary(data.itineraryDisplay as PickupDialogItineraryStep[]);
202
+ } else {
203
+ setPreviewItinerary(null);
204
+ }
205
+ })
206
+ .catch(() => setPreviewItinerary(null));
207
+ return () => {
208
+ cancelled = true;
209
+ };
210
+ }, [
211
+ isOpen,
212
+ hasValidSelection,
213
+ hasChanged,
214
+ selectedLocationId,
215
+ selectedCustomAddress,
216
+ effectiveBookingRef,
217
+ lastName,
218
+ ]);
219
+
220
+ const currentPickupName =
221
+ booking.travelerHotel ||
222
+ (booking.pickupLocationId
223
+ ? pickupLocations.find((l) => l.id === booking.pickupLocationId)?.name ?? booking.pickupLocationId
224
+ : null);
225
+
226
+ const itineraryDisplay = booking.itineraryDisplay ?? [];
227
+ const pickupOrDropOffSteps = itineraryDisplay.filter(
228
+ (s) => s.stepType === 'pickup' || s.stepType === 'drop_off'
229
+ );
230
+
231
+ const handleSave = async () => {
232
+ if (!lastName?.trim()) {
233
+ setError('Last name is required to update pickup');
234
+ return;
235
+ }
236
+ if (!hasValidSelection) {
237
+ setError('Please select a pickup location');
238
+ return;
239
+ }
240
+
241
+ setSaving(true);
242
+ setError(null);
243
+ try {
244
+ let payload: { pickupLocationId?: string; travelerHotel?: string };
245
+ if (selectedCustomAddress) {
246
+ payload = { travelerHotel: selectedCustomAddress };
247
+ } else if (selectedLocationId) {
248
+ payload = { pickupLocationId: selectedLocationId, travelerHotel: selectedLocationName ?? undefined };
249
+ } else {
250
+ setError('Please select a pickup location');
251
+ setSaving(false);
252
+ return;
253
+ }
254
+
255
+ const updated = await updatePickupLocation(effectiveBookingRef, lastName, payload);
256
+ onSuccess(updated);
257
+ onClose();
258
+ } catch (e) {
259
+ setError(e instanceof Error ? e.message : 'Failed to update pickup location');
260
+ } finally {
261
+ setSaving(false);
262
+ }
263
+ };
264
+
265
+ if (!isOpen) return null;
266
+
267
+ return (
268
+ <div
269
+ className={styles.overlay}
270
+ onClick={onClose}
271
+ role="dialog"
272
+ aria-modal="true"
273
+ aria-labelledby="pickup-dialog-title"
274
+ >
275
+ <div className={`${styles.modal} ${styles.pickupDialogRoot}`} onClick={(e) => e.stopPropagation()}>
276
+ <div
277
+ className={styles.header}
278
+ style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', alignItems: 'center' }}
279
+ >
280
+ <span className={styles.headerSpacer} aria-hidden />
281
+ <h2
282
+ id="pickup-dialog-title"
283
+ className={styles.title}
284
+ style={{ textAlign: 'center', justifySelf: 'center' }}
285
+ >
286
+ {t('pickup.title') || 'Select pickup location'}
287
+ </h2>
288
+ <button type="button" onClick={onClose} className={styles.closeBtn} aria-label="Close">
289
+ <svg width="24" height="24" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
290
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
291
+ </svg>
292
+ </button>
293
+ </div>
294
+
295
+ <div className={styles.content}>
296
+ {loading ? (
297
+ <div className={styles.loading}>Loading pickup locations…</div>
298
+ ) : error && !pickupLocations.length ? (
299
+ <div className={styles.error}>{error}</div>
300
+ ) : pickupLocations.length === 0 ? (
301
+ <div className={styles.empty}>No pickup locations available for this tour.</div>
302
+ ) : (
303
+ <>
304
+ <div className={styles.currentPickupSection}>
305
+ <p className={styles.currentPickupLabel}>Current pickup</p>
306
+ <p className={styles.currentPickupName}>
307
+ {currentPickupName ?? (t('booking.pickupLocationUnknown') || "I don't know")}
308
+ </p>
309
+ {(booking.pickupLocationId || booking.travelerHotel) && (
310
+ <p className={styles.currentPickupTimes}>
311
+ Pickup: {getPickupTimeFromItinerary(itineraryDisplay)} · Drop-off:{' '}
312
+ {getDropoffTimeFromItinerary(itineraryDisplay)}
313
+ </p>
314
+ )}
315
+ </div>
316
+ <div
317
+ className={`${styles.selectorWrapper} ${styles.selectorWrapperConstrained}`}
318
+ style={{ fontFamily: "'Figtree', sans-serif" }}
319
+ >
320
+ <PickupLocationSelector
321
+ hideTitle
322
+ hideSkipOption
323
+ pickupLocations={pickupLocations}
324
+ selectedLocationId={selectedLocationId}
325
+ selectedCustomAddress={selectedCustomAddress}
326
+ allowCustomLocation={isPrivateShuttle}
327
+ restrictCustomLocationToServiceArea={isPrivateShuttle}
328
+ destinations={destinations}
329
+ onLocationSelect={(locationId, customLocation, locationName) => {
330
+ setError('');
331
+ if (customLocation) {
332
+ setSelectedLocationId(null);
333
+ setSelectedLocationName(null);
334
+ setSelectedCustomAddress(customLocation.address);
335
+ } else {
336
+ setSelectedLocationId(locationId);
337
+ setSelectedLocationName(locationName ?? null);
338
+ setSelectedCustomAddress(null);
339
+ }
340
+ }}
341
+ />
342
+ </div>
343
+
344
+ {hasValidSelection && hasChanged && pickupOrDropOffSteps.length > 0 && (
345
+ <div className={styles.itineraryPreview}>
346
+ <h3 className={styles.previewTitle}>Itinerary changes</h3>
347
+ <p className={styles.previewIntro}>
348
+ Your itinerary will be updated with the new pickup location:
349
+ </p>
350
+ <ul className={styles.previewList}>
351
+ {pickupOrDropOffSteps.map((step, i) => {
352
+ const previewStep = previewItinerary?.find((s) => s.stepType === step.stepType);
353
+ const newTime = previewStep?.time?.trim();
354
+ const newLabel =
355
+ step.stepType === 'pickup'
356
+ ? selectedPickupName
357
+ ? `Pickup at ${selectedPickupName}${newTime ? ` ${newTime}` : ''}`
358
+ : t('booking.pickupLocationUnknown') || "I don't know"
359
+ : step.stepType === 'drop_off'
360
+ ? selectedPickupName
361
+ ? `Drop off at ${selectedPickupName}${newTime ? ` ${newTime}` : ''}`
362
+ : t('booking.pickupLocationUnknown') || "I don't know"
363
+ : getItineraryStepLabel(previewStep ?? step, true);
364
+ return (
365
+ <li key={i} className={styles.previewItem}>
366
+ <span className={styles.previewOld}>{getItineraryStepLabel(step, true)}</span>
367
+ <span className={styles.previewArrow}>→</span>
368
+ <span className={styles.previewNew}>{newLabel}</span>
369
+ </li>
370
+ );
371
+ })}
372
+ </ul>
373
+ </div>
374
+ )}
375
+
376
+ {error && <div className={styles.error}>{error}</div>}
377
+
378
+ <div className={styles.footer}>
379
+ <button type="button" className={`${styles.footerBtn} ${styles.footerBtnOutline}`} onClick={onClose}>
380
+ Cancel
381
+ </button>
382
+ <button
383
+ type="button"
384
+ className={`${styles.footerBtn} ${styles.footerBtnPrimary}`}
385
+ onClick={handleSave}
386
+ disabled={!hasValidSelection || !hasChanged || saving}
387
+ >
388
+ {saving ? 'Saving…' : 'Save pickup location'}
389
+ </button>
390
+ </div>
391
+ </>
392
+ )}
393
+ </div>
394
+ </div>
395
+ </div>
396
+ );
397
+ }
@@ -147,6 +147,17 @@ export function PriceSummary({
147
147
 
148
148
  let subtotalShown = false;
149
149
 
150
+ /** When lines include a TAX row, subtotal is rendered inside the loop before that row — slot content must sit there too, not after all lines (else it ends up below tax). */
151
+ const firstTaxLineIndex = lines.findIndex(
152
+ (r) => r.kind === 'line' && String(r.type ?? '').toUpperCase() === 'TAX',
153
+ );
154
+ const embedSubtotalBeforeTaxInLines =
155
+ firstTaxLineIndex >= 0 && subtotal != null && subtotal > 0;
156
+ const extraSlotBeforeEmbeddedSubtotal =
157
+ embedSubtotalBeforeTaxInLines && extraBeforeSubtotal ? extraBeforeSubtotal : null;
158
+ const extraSlotBeforeCheckoutSubtotal =
159
+ !embedSubtotalBeforeTaxInLines && extraBeforeSubtotal ? extraBeforeSubtotal : null;
160
+
150
161
  return (
151
162
  <div className={`space-y-2 min-w-0 ${className}`}>
152
163
  {lines.map((row, index) => {
@@ -185,8 +196,11 @@ export function PriceSummary({
185
196
  const isTaxLine = type === 'TAX';
186
197
  const showSubtotalBeforeTax = isTaxLine && subtotal != null && subtotal > 0 && !subtotalShown;
187
198
  if (showSubtotalBeforeTax) subtotalShown = true;
199
+ const slotHere =
200
+ showSubtotalBeforeTax && index === firstTaxLineIndex ? extraSlotBeforeEmbeddedSubtotal : null;
188
201
  return (
189
202
  <div key={`${label}-${index}`}>
203
+ {slotHere}
190
204
  {showSubtotalBeforeTax && (
191
205
  <div className={`flex justify-between gap-3 min-w-0 ${textSize} ${subtotalRowClass}`}>
192
206
  <span className="text-stone-600 min-w-0 truncate">{t('booking.subtotal') || 'Subtotal'}</span>
@@ -233,7 +247,7 @@ export function PriceSummary({
233
247
  </div>
234
248
  );
235
249
  })}
236
- {extraBeforeSubtotal}
250
+ {extraSlotBeforeCheckoutSubtotal}
237
251
 
238
252
  {/* Checkout mode: subtotal/tax/discount not in lines (e.g. Stripe Review & pay modal) */}
239
253
  {subtotal != null && !subtotalShown && !hideSubtotal && (subtotal !== total || discountAmount > 0) && (
@@ -279,6 +279,42 @@
279
279
  box-shadow: 0 0 0 2px rgba(5, 150, 105, 0.2);
280
280
  }
281
281
 
282
+ /**
283
+ * Admin “custom receipt line” row: scoped preflight sets `input, button { padding: 0 }` with
284
+ * higher specificity than Tailwind padding utilities, so insets must be restored here.
285
+ */
286
+ .booking-flow-preflight .admin-custom-receipt-line input.admin-custom-receipt-input[type='text'] {
287
+ box-sizing: border-box;
288
+ padding: 0.625rem 0.875rem;
289
+ min-height: 2.75rem;
290
+ }
291
+ .booking-flow-preflight .admin-custom-receipt-line input.admin-custom-receipt-input-amount[type='text'] {
292
+ box-sizing: border-box;
293
+ padding: 0.5rem 0.75rem;
294
+ min-height: 2.75rem;
295
+ }
296
+ .booking-flow-preflight .admin-custom-receipt-line .admin-custom-receipt-segment {
297
+ box-sizing: border-box;
298
+ padding: 0.5rem 0.65rem;
299
+ min-height: 2.75rem;
300
+ }
301
+ @media (min-width: 360px) {
302
+ .booking-flow-preflight .admin-custom-receipt-line .admin-custom-receipt-segment {
303
+ padding: 0.5rem 0.75rem;
304
+ }
305
+ }
306
+ .booking-flow-preflight .admin-custom-receipt-line .admin-custom-receipt-remove {
307
+ box-sizing: border-box;
308
+ padding: 0.5rem;
309
+ min-height: 2.75rem;
310
+ min-width: 2.75rem;
311
+ }
312
+ .booking-flow-preflight .admin-custom-receipt-line-add {
313
+ box-sizing: border-box;
314
+ padding: 0.5rem 0.85rem;
315
+ min-height: 2.5rem;
316
+ }
317
+
282
318
  /* Labels */
283
319
  .booking-flow-preflight label {
284
320
  font-family: var(--booking-font-sans);
@@ -20,6 +20,7 @@ export type ProviderDashboardChangeBookingPayload = {
20
20
  pricingAdjustment?: {
21
21
  mode: 'AUTO' | 'MANUAL_LINES';
22
22
  lineOverrides?: Array<{ lineKey: string; amount: number; reason?: string }>;
23
+ /** Signed major-unit rows; may include admin custom receipt lines merged with provider inline adjustments. */
23
24
  additionalAdjustments?: Array<{ label: string; amount: number }>;
24
25
  } | null;
25
26
  capacitySeatCredit?: {
package/src/index.ts CHANGED
@@ -44,6 +44,13 @@ export type {
44
44
  export { getItineraryStepLabel } from './lib/booking/itinerary-display';
45
45
  export { PARTNER_EMBEDDED_BOOKING_FLOW_UI_BASE } from './components/booking/booking-flow-ui';
46
46
  export { default as BookingDialog } from './components/booking/BookingDialog';
47
+ export { PickupLocationDialog } from './components/booking/PickupLocationDialog';
48
+ export type {
49
+ PickupLocationDialogBooking,
50
+ PickupLocationDialogProps,
51
+ PickupDialogItineraryStep,
52
+ } from './components/booking/PickupLocationDialog';
53
+ export { formatBookingRefForDisplay } from './lib/format-booking-ref';
47
54
  export { default as BookingProductGrid } from './components/booking/BookingProductGrid';
48
55
  export { DefaultTermsContent as TermsContent } from './components/booking/DefaultTermsContent';
49
56
 
@@ -855,6 +855,12 @@ export interface ChangeBookingQuoteRequest {
855
855
  newAddOnSelections?: Array<{ addOnId: string; variantId?: string; quantity?: number }>;
856
856
  /** Full new-booking total shown in the UI; server verifies within tolerance then uses this for the session so charge matches screen. */
857
857
  clientProposedTotal?: number;
858
+ /**
859
+ * Optional signed manual lines (admin/provider custom receipt rows). When present, the server should fold these
860
+ * amounts into pricing / stored receipt so totals align with the client (same semantics as provider dashboard
861
+ * `pricingAdjustment.additionalAdjustments`).
862
+ */
863
+ manualLineAdjustments?: Array<{ label: string; amount: number }>;
858
864
  /** Optional change-flow capacity hint so API can exclude current booking seats from sold counts. */
859
865
  capacitySeatCredit?: {
860
866
  enabled: boolean;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Format booking reference for display and URLs.
3
+ * Strips the bookRef_ prefix so users see the short form. Backend accepts both forms.
4
+ */
5
+ export function formatBookingRefForDisplay(ref: string | null | undefined): string {
6
+ if (!ref || typeof ref !== 'string') return '';
7
+ const trimmed = ref.trim();
8
+ if (trimmed.toLowerCase().startsWith('bookref_')) {
9
+ return trimmed.slice(8);
10
+ }
11
+ return trimmed;
12
+ }