@ticketboothapp/booking 1.2.55 → 1.2.58
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.
- package/package.json +1 -1
- package/src/components/booking/BookingFlow.tsx +691 -169
- package/src/components/booking/Calendar.module.css +6 -1
- package/src/components/booking/ChangeBookingDialog.tsx +41 -178
- package/src/components/booking/CheckoutForm.tsx +29 -4
- package/src/components/booking/DefaultTermsContent.tsx +178 -0
- package/src/components/booking/ItineraryBox.tsx +2 -2
- package/src/components/booking/PriceBreakdown.tsx +92 -27
- package/src/components/booking/PriceSummary.tsx +77 -4
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +83 -27
- package/src/components/booking/TermsAcceptance.tsx +2 -1
- package/src/components/booking/booking-flow-ui.ts +42 -0
- package/src/components/booking/booking-flow.css +37 -2
- package/src/components/booking/change-booking-compare.module.css +97 -0
- package/src/components/booking/change-booking-compare.tsx +228 -0
- package/src/components/booking/provider-dashboard-change-booking.ts +47 -0
- package/src/index.ts +19 -0
- package/src/runtime/types.ts +2 -1
|
@@ -15,6 +15,11 @@ export interface PriceBreakdownProps {
|
|
|
15
15
|
breakdown: PriceBreakdownType | null;
|
|
16
16
|
currency: Currency;
|
|
17
17
|
locale: Locale;
|
|
18
|
+
editable?: boolean;
|
|
19
|
+
editableValue?: string;
|
|
20
|
+
onEditableChange?: (value: string) => void;
|
|
21
|
+
onEditableBlur?: () => void;
|
|
22
|
+
onEditableReset?: () => void;
|
|
18
23
|
}
|
|
19
24
|
|
|
20
25
|
/**
|
|
@@ -39,6 +44,10 @@ function formatAdjustmentDetail(
|
|
|
39
44
|
return '';
|
|
40
45
|
}
|
|
41
46
|
|
|
47
|
+
function getCurrencySymbol(currency: Currency, locale: Locale): string {
|
|
48
|
+
return formatCurrencyAmount(0, currency, locale).replace(/[0-9\s,.-]/g, '') || currency;
|
|
49
|
+
}
|
|
50
|
+
|
|
42
51
|
/**
|
|
43
52
|
* Renders a single price line (e.g. "Adult × 2 — $X"). When the host app grants
|
|
44
53
|
* permission, shows a hover tooltip with the full price breakdown (base, adjustments,
|
|
@@ -51,11 +60,17 @@ export function PriceBreakdown({
|
|
|
51
60
|
breakdown,
|
|
52
61
|
currency,
|
|
53
62
|
locale,
|
|
63
|
+
editable = false,
|
|
64
|
+
editableValue,
|
|
65
|
+
onEditableChange,
|
|
66
|
+
onEditableBlur,
|
|
67
|
+
onEditableReset,
|
|
54
68
|
}: PriceBreakdownProps) {
|
|
55
69
|
const [showTooltip, setShowTooltip] = useState(false);
|
|
56
70
|
const { t } = useTranslations();
|
|
57
71
|
const { permissions } = useBookingApp();
|
|
58
72
|
const canShowBreakdown = breakdown != null && permissions.canViewPriceBreakdown;
|
|
73
|
+
const currencySymbol = getCurrencySymbol(currency, locale);
|
|
59
74
|
|
|
60
75
|
// Calculate base price for discount detection
|
|
61
76
|
// In public mode, the base price in breakdown already has dynamic increases rolled in
|
|
@@ -69,9 +84,34 @@ export function PriceBreakdown({
|
|
|
69
84
|
<span className="text-sm text-stone-600">
|
|
70
85
|
{category} {qty > 1 ? `× ${qty}` : ''}
|
|
71
86
|
</span>
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
87
|
+
{editable && onEditableChange ? (
|
|
88
|
+
<div className="flex items-center gap-1">
|
|
89
|
+
{onEditableReset ? (
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
className="rounded p-0.5 text-stone-400 hover:bg-stone-100 hover:text-stone-700"
|
|
93
|
+
onClick={onEditableReset}
|
|
94
|
+
aria-label={`Reset ${category} amount`}
|
|
95
|
+
title="Reset to quoted value"
|
|
96
|
+
>
|
|
97
|
+
<span className="inline-block h-3.5 w-3.5 text-center text-xs leading-[14px]">↺</span>
|
|
98
|
+
</button>
|
|
99
|
+
) : null}
|
|
100
|
+
<span className="text-sm text-stone-500">{currencySymbol}</span>
|
|
101
|
+
<input
|
|
102
|
+
type="text"
|
|
103
|
+
inputMode="decimal"
|
|
104
|
+
className="h-6 w-24 rounded border border-stone-300 bg-white px-2 py-0.5 text-right text-sm font-medium leading-none text-stone-700"
|
|
105
|
+
value={editableValue ?? String(itemTotal)}
|
|
106
|
+
onChange={(e) => onEditableChange(e.target.value)}
|
|
107
|
+
onBlur={onEditableBlur}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
) : (
|
|
111
|
+
<span className="text-sm font-medium text-stone-700">
|
|
112
|
+
{formatCurrencyAmount(itemTotal, currency, locale)}
|
|
113
|
+
</span>
|
|
114
|
+
)}
|
|
75
115
|
</div>
|
|
76
116
|
);
|
|
77
117
|
}
|
|
@@ -82,30 +122,55 @@ export function PriceBreakdown({
|
|
|
82
122
|
{category} {qty > 1 ? `× ${qty}` : ''}
|
|
83
123
|
</span>
|
|
84
124
|
<div className="relative flex-shrink-0 whitespace-nowrap">
|
|
85
|
-
|
|
86
|
-
className=
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
125
|
+
{editable && onEditableChange ? (
|
|
126
|
+
<div className="flex items-center gap-1">
|
|
127
|
+
{onEditableReset ? (
|
|
128
|
+
<button
|
|
129
|
+
type="button"
|
|
130
|
+
className="rounded p-0.5 text-stone-400 hover:bg-stone-100 hover:text-stone-700"
|
|
131
|
+
onClick={onEditableReset}
|
|
132
|
+
aria-label={`Reset ${category} amount`}
|
|
133
|
+
title="Reset to quoted value"
|
|
134
|
+
>
|
|
135
|
+
<span className="inline-block h-3.5 w-3.5 text-center text-xs leading-[14px]">↺</span>
|
|
136
|
+
</button>
|
|
137
|
+
) : null}
|
|
138
|
+
<span className="text-sm text-stone-500">{currencySymbol}</span>
|
|
139
|
+
<input
|
|
140
|
+
type="text"
|
|
141
|
+
inputMode="decimal"
|
|
142
|
+
className="h-6 w-24 rounded border border-stone-300 bg-white px-2 py-0.5 text-right text-sm font-medium leading-none text-stone-700"
|
|
143
|
+
value={editableValue ?? String(itemTotal)}
|
|
144
|
+
onChange={(e) => onEditableChange(e.target.value)}
|
|
145
|
+
onBlur={onEditableBlur}
|
|
146
|
+
/>
|
|
147
|
+
</div>
|
|
148
|
+
) : (
|
|
149
|
+
<span
|
|
150
|
+
className={
|
|
151
|
+
canShowBreakdown
|
|
152
|
+
? 'text-sm font-medium text-stone-700 cursor-help underline decoration-dotted'
|
|
153
|
+
: 'text-sm font-medium text-stone-700'
|
|
154
|
+
}
|
|
155
|
+
onMouseEnter={canShowBreakdown ? () => setShowTooltip(true) : undefined}
|
|
156
|
+
onMouseLeave={canShowBreakdown ? () => setShowTooltip(false) : undefined}
|
|
157
|
+
>
|
|
158
|
+
{hasDiscount ? (
|
|
159
|
+
<>
|
|
160
|
+
<span className="line-through text-stone-400">
|
|
161
|
+
{formatCurrencyAmount(baseTotal, currency, locale)}
|
|
162
|
+
</span>
|
|
163
|
+
{' '}
|
|
164
|
+
<span className="text-emerald-600">
|
|
165
|
+
{formatCurrencyAmount(itemTotal, currency, locale)}
|
|
166
|
+
</span>
|
|
167
|
+
</>
|
|
168
|
+
) : (
|
|
169
|
+
formatCurrencyAmount(itemTotal, currency, locale)
|
|
170
|
+
)}
|
|
171
|
+
</span>
|
|
172
|
+
)}
|
|
173
|
+
{!editable && canShowBreakdown && showTooltip && (
|
|
109
174
|
<div className="absolute right-0 top-full mt-2 w-72 p-3 bg-stone-900 text-white text-xs rounded-lg shadow-xl z-50">
|
|
110
175
|
<div className="font-semibold mb-2 pb-2 border-b border-stone-700">
|
|
111
176
|
Price Breakdown ({category})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
3
4
|
import { formatCurrencyAmount } from '../../lib/currency';
|
|
4
5
|
import type { PriceBreakdown as PriceBreakdownType } from '../../lib/booking/pricing';
|
|
5
6
|
import { PriceBreakdown } from './PriceBreakdown';
|
|
@@ -12,6 +13,8 @@ import styles from './PriceSummary.module.css';
|
|
|
12
13
|
export type PriceSummaryLine =
|
|
13
14
|
| {
|
|
14
15
|
kind: 'ticket';
|
|
16
|
+
lineKey?: string;
|
|
17
|
+
editable?: boolean;
|
|
15
18
|
category: string;
|
|
16
19
|
qty: number;
|
|
17
20
|
itemTotal: number;
|
|
@@ -19,6 +22,7 @@ export type PriceSummaryLine =
|
|
|
19
22
|
}
|
|
20
23
|
| {
|
|
21
24
|
kind: 'line';
|
|
25
|
+
lineKey?: string;
|
|
22
26
|
label: string;
|
|
23
27
|
amount: number;
|
|
24
28
|
/** Drives styling: discount (red, -), return add-on (green, +), default (stone). Receipt types: TICKET, FEE, RETURN_OPTION, PROMO_CODE, CANCELLATION_UPGRADE, TAX, etc. */
|
|
@@ -26,6 +30,8 @@ export type PriceSummaryLine =
|
|
|
26
30
|
quantity?: number | null;
|
|
27
31
|
/** Optional tooltip text - when set, shows info icon next to label (e.g. for Moraine Lake Road Access Fee) */
|
|
28
32
|
tooltip?: string;
|
|
33
|
+
/** Optional: render amount as inline editable input when handler is provided. */
|
|
34
|
+
editable?: boolean;
|
|
29
35
|
};
|
|
30
36
|
|
|
31
37
|
export interface PriceSummaryProps {
|
|
@@ -53,6 +59,8 @@ export interface PriceSummaryProps {
|
|
|
53
59
|
className?: string;
|
|
54
60
|
/** Optional: render between tax row and total row (e.g. promo code input in BookingFlow) */
|
|
55
61
|
extraBetweenTaxAndTotal?: React.ReactNode;
|
|
62
|
+
/** Optional: render inside receipt before subtotal/tax rows. */
|
|
63
|
+
extraBeforeSubtotal?: React.ReactNode;
|
|
56
64
|
/** Subtotal row spacing: 'compact' for booking flow/Stripe modal, 'relaxed' for /manage (equal top/bottom) */
|
|
57
65
|
subtotalSpacing?: 'compact' | 'relaxed';
|
|
58
66
|
/** Deposit mode: show Total (full), Deposit (amount due today), Remaining Balance */
|
|
@@ -61,6 +69,16 @@ export interface PriceSummaryProps {
|
|
|
61
69
|
hideSubtotal?: boolean;
|
|
62
70
|
/** Overrides the final "Total" row label (e.g. change booking: amount due for the change) */
|
|
63
71
|
totalLabel?: string;
|
|
72
|
+
/** Render after the total row (e.g. provider price delta + keep-original-price) */
|
|
73
|
+
extraAfterTotal?: ReactNode;
|
|
74
|
+
/** Optional map of editable line input values by line key. */
|
|
75
|
+
lineAmountInputs?: Record<string, string>;
|
|
76
|
+
/** Optional change handler for inline editable line amounts. */
|
|
77
|
+
onLineAmountInputChange?: (lineKey: string, value: string) => void;
|
|
78
|
+
/** Optional blur handler for enforcing display format (e.g. 2 decimals). */
|
|
79
|
+
onLineAmountInputBlur?: (lineKey: string) => void;
|
|
80
|
+
/** Optional reset handler for inline editable line amounts. */
|
|
81
|
+
onLineAmountReset?: (lineKey: string) => void;
|
|
64
82
|
}
|
|
65
83
|
|
|
66
84
|
function getLineAmountClass(type: string | undefined, amount: number): string {
|
|
@@ -84,6 +102,10 @@ function formatLineAmount(
|
|
|
84
102
|
return formatted;
|
|
85
103
|
}
|
|
86
104
|
|
|
105
|
+
function getCurrencySymbol(currency: Currency, locale: Locale): string {
|
|
106
|
+
return formatCurrencyAmount(0, currency, locale).replace(/[0-9\s,.-]/g, '') || currency;
|
|
107
|
+
}
|
|
108
|
+
|
|
87
109
|
/**
|
|
88
110
|
* Reusable price breakdown/summary used in:
|
|
89
111
|
* - BookingFlow sidebar (order summary)
|
|
@@ -105,13 +127,20 @@ export function PriceSummary({
|
|
|
105
127
|
t = (k) => k,
|
|
106
128
|
className = '',
|
|
107
129
|
extraBetweenTaxAndTotal,
|
|
130
|
+
extraBeforeSubtotal,
|
|
108
131
|
subtotalSpacing = 'compact',
|
|
109
132
|
depositMode,
|
|
110
133
|
hideSubtotal = false,
|
|
111
134
|
totalLabel,
|
|
135
|
+
extraAfterTotal,
|
|
136
|
+
lineAmountInputs,
|
|
137
|
+
onLineAmountInputChange,
|
|
138
|
+
onLineAmountInputBlur,
|
|
139
|
+
onLineAmountReset,
|
|
112
140
|
}: PriceSummaryProps) {
|
|
113
141
|
const textSize = size === 'sm' ? 'text-sm' : 'text-base';
|
|
114
142
|
const totalSize = size === 'sm' ? 'text-xl' : 'text-2xl';
|
|
143
|
+
const currencySymbol = getCurrencySymbol(currency, locale);
|
|
115
144
|
const subtotalRowClass = subtotalSpacing === 'relaxed'
|
|
116
145
|
? `pt-3 pb-3 mt-2 ${styles.ruleAbove}`
|
|
117
146
|
: `mt-2 pt-1.5 ${styles.ruleAbove}`;
|
|
@@ -131,10 +160,27 @@ export function PriceSummary({
|
|
|
131
160
|
breakdown={row.breakdown ?? null}
|
|
132
161
|
currency={currency}
|
|
133
162
|
locale={locale}
|
|
163
|
+
editable={row.editable}
|
|
164
|
+
editableValue={row.lineKey ? lineAmountInputs?.[row.lineKey] : undefined}
|
|
165
|
+
onEditableChange={
|
|
166
|
+
row.editable && row.lineKey && onLineAmountInputChange
|
|
167
|
+
? (value) => onLineAmountInputChange(row.lineKey!, value)
|
|
168
|
+
: undefined
|
|
169
|
+
}
|
|
170
|
+
onEditableBlur={
|
|
171
|
+
row.editable && row.lineKey && onLineAmountInputBlur
|
|
172
|
+
? () => onLineAmountInputBlur(row.lineKey!)
|
|
173
|
+
: undefined
|
|
174
|
+
}
|
|
175
|
+
onEditableReset={
|
|
176
|
+
row.editable && row.lineKey && onLineAmountReset
|
|
177
|
+
? () => onLineAmountReset(row.lineKey!)
|
|
178
|
+
: undefined
|
|
179
|
+
}
|
|
134
180
|
/>
|
|
135
181
|
);
|
|
136
182
|
}
|
|
137
|
-
const { label, amount, type, quantity, tooltip } = row;
|
|
183
|
+
const { lineKey, label, amount, type, quantity, tooltip, editable } = row;
|
|
138
184
|
// Receipt mode: insert Subtotal row before first TAX line
|
|
139
185
|
const isTaxLine = type === 'TAX';
|
|
140
186
|
const showSubtotalBeforeTax = isTaxLine && subtotal != null && subtotal > 0 && !subtotalShown;
|
|
@@ -155,13 +201,39 @@ export function PriceSummary({
|
|
|
155
201
|
</span>
|
|
156
202
|
{tooltip && <InfoTooltip text={tooltip} />}
|
|
157
203
|
</span>
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
204
|
+
{editable && lineKey && onLineAmountInputChange ? (
|
|
205
|
+
<div className="flex items-center gap-1">
|
|
206
|
+
{onLineAmountReset ? (
|
|
207
|
+
<button
|
|
208
|
+
type="button"
|
|
209
|
+
className="rounded p-0.5 text-stone-400 hover:bg-stone-100 hover:text-stone-700"
|
|
210
|
+
onClick={() => onLineAmountReset(lineKey)}
|
|
211
|
+
aria-label={`Reset ${label} amount`}
|
|
212
|
+
title="Reset to quoted value"
|
|
213
|
+
>
|
|
214
|
+
<span className="inline-block h-3.5 w-3.5 text-center text-xs leading-[14px]">↺</span>
|
|
215
|
+
</button>
|
|
216
|
+
) : null}
|
|
217
|
+
<span className="text-sm text-stone-500">{currencySymbol}</span>
|
|
218
|
+
<input
|
|
219
|
+
type="text"
|
|
220
|
+
inputMode="decimal"
|
|
221
|
+
className="h-6 w-24 rounded border border-stone-300 bg-white px-2 py-0.5 text-right text-sm font-medium leading-none text-stone-700"
|
|
222
|
+
value={lineAmountInputs?.[lineKey] ?? String(amount)}
|
|
223
|
+
onChange={(e) => onLineAmountInputChange(lineKey, e.target.value)}
|
|
224
|
+
onBlur={() => onLineAmountInputBlur?.(lineKey)}
|
|
225
|
+
/>
|
|
226
|
+
</div>
|
|
227
|
+
) : (
|
|
228
|
+
<span className={`flex-shrink-0 whitespace-nowrap font-medium ${getLineAmountClass(type, amount)}`}>
|
|
229
|
+
{formatLineAmount('line', amount, type, currency, locale)}
|
|
230
|
+
</span>
|
|
231
|
+
)}
|
|
161
232
|
</div>
|
|
162
233
|
</div>
|
|
163
234
|
);
|
|
164
235
|
})}
|
|
236
|
+
{extraBeforeSubtotal}
|
|
165
237
|
|
|
166
238
|
{/* Checkout mode: subtotal/tax/discount not in lines (e.g. Stripe Review & pay modal) */}
|
|
167
239
|
{subtotal != null && !subtotalShown && !hideSubtotal && (subtotal !== total || discountAmount > 0) && (
|
|
@@ -230,6 +302,7 @@ export function PriceSummary({
|
|
|
230
302
|
</div>
|
|
231
303
|
)}
|
|
232
304
|
</div>
|
|
305
|
+
{extraAfterTotal}
|
|
233
306
|
</div>
|
|
234
307
|
);
|
|
235
308
|
}
|
|
@@ -58,6 +58,10 @@ import {
|
|
|
58
58
|
} from '../../lib/booking/source-metadata';
|
|
59
59
|
import type { VideoSources } from '../../constants/products';
|
|
60
60
|
import type { BookingFlowUiOptions } from './booking-flow-ui';
|
|
61
|
+
import type {
|
|
62
|
+
ProviderDashboardChangeBookingPayload,
|
|
63
|
+
ProviderDashboardInitialBooking,
|
|
64
|
+
} from './provider-dashboard-change-booking';
|
|
61
65
|
import { BOOKING_FLOW_ABANDON_EVENT } from '../../providers/booking-dialog-provider';
|
|
62
66
|
|
|
63
67
|
interface PrivateShuttleBookingFlowProps {
|
|
@@ -99,6 +103,9 @@ interface PrivateShuttleBookingFlowProps {
|
|
|
99
103
|
specialRequest?: string | null;
|
|
100
104
|
notes?: string | null;
|
|
101
105
|
};
|
|
106
|
+
/** Provider dashboard change-booking (TicketBooth `BookingWidget` initialBooking + onChangeBooking). */
|
|
107
|
+
initialBooking?: ProviderDashboardInitialBooking;
|
|
108
|
+
onChangeBooking?: (data: ProviderDashboardChangeBookingPayload) => Promise<void>;
|
|
102
109
|
}
|
|
103
110
|
|
|
104
111
|
const RESOURCE_CAPACITY = 13;
|
|
@@ -151,6 +158,8 @@ export function PrivateShuttleBookingFlow({
|
|
|
151
158
|
availabilityPricingProfileId,
|
|
152
159
|
availabilityCancellationPolicyProfileId,
|
|
153
160
|
initialValues,
|
|
161
|
+
initialBooking,
|
|
162
|
+
onChangeBooking,
|
|
154
163
|
}: PrivateShuttleBookingFlowProps) {
|
|
155
164
|
const { strings: defaultStrings, analytics, catalog } = useBookingHost();
|
|
156
165
|
const { t } = useTranslations();
|
|
@@ -168,6 +177,7 @@ export function PrivateShuttleBookingFlow({
|
|
|
168
177
|
} = useBookingApp();
|
|
169
178
|
const availabilitiesCache = useAvailabilitiesCache();
|
|
170
179
|
const isAdmin = permissions.viewerRole === 'admin';
|
|
180
|
+
const isProviderChangeMode = Boolean(initialBooking && onChangeBooking);
|
|
171
181
|
|
|
172
182
|
const [availabilities, setAvailabilities] = useState<Availability[]>([]);
|
|
173
183
|
const [selectedDate, setSelectedDate] = useState<string>('');
|
|
@@ -175,11 +185,15 @@ export function PrivateShuttleBookingFlow({
|
|
|
175
185
|
const [selectedOption, setSelectedOption] = useState<string>('');
|
|
176
186
|
const [selectedStartTime, setSelectedStartTime] = useState<string>('');
|
|
177
187
|
const [isCustomTimeMode, setIsCustomTimeMode] = useState(false);
|
|
178
|
-
const [passengerCount, setPassengerCount] = useState<number>(
|
|
188
|
+
const [passengerCount, setPassengerCount] = useState<number>(
|
|
189
|
+
() => initialBooking?.privateShuttleDetails?.passengerCount ?? initialValues?.passengers ?? 1
|
|
190
|
+
);
|
|
179
191
|
const [email, setEmail] = useState('');
|
|
180
192
|
const [firstName, setFirstName] = useState('');
|
|
181
193
|
const [lastName, setLastName] = useState('');
|
|
182
|
-
const [pickupLocationId, setPickupLocationId] = useState<string | null>(
|
|
194
|
+
const [pickupLocationId, setPickupLocationId] = useState<string | null>(
|
|
195
|
+
() => initialBooking?.pickupLocationId ?? initialValues?.pickupLocationId ?? null
|
|
196
|
+
);
|
|
183
197
|
const hasAutoSelectedPartnerDateRef = useRef(false);
|
|
184
198
|
const hasAutoSelectedPartnerPickupRef = useRef(false);
|
|
185
199
|
const handleDateSelectRef = useRef<(date: string) => void>(() => {});
|
|
@@ -191,7 +205,15 @@ export function PrivateShuttleBookingFlow({
|
|
|
191
205
|
const [foodRestrictions, setFoodRestrictions] = useState('');
|
|
192
206
|
const [addOnSelections, setAddOnSelections] = useState<
|
|
193
207
|
Array<{ addOnId: string; variantId?: string; quantity?: number }>
|
|
194
|
-
>(
|
|
208
|
+
>(() =>
|
|
209
|
+
initialBooking?.addOnSelections?.length
|
|
210
|
+
? initialBooking.addOnSelections.map((s) => ({
|
|
211
|
+
addOnId: s.addOnId,
|
|
212
|
+
variantId: s.variantId,
|
|
213
|
+
quantity: s.quantity ?? 1,
|
|
214
|
+
}))
|
|
215
|
+
: []
|
|
216
|
+
);
|
|
195
217
|
const [addOns, setAddOns] = useState<AddOn[]>([]);
|
|
196
218
|
const [loading, setLoading] = useState(false);
|
|
197
219
|
const [loadingAvailabilities, setLoadingAvailabilities] = useState(true);
|
|
@@ -230,14 +252,18 @@ export function PrivateShuttleBookingFlow({
|
|
|
230
252
|
const [skipConfirmationCommunications, setSkipConfirmationCommunications] = useState(false);
|
|
231
253
|
const [disableAutoCommunications, setDisableAutoCommunications] = useState(false);
|
|
232
254
|
const [showAdminPaymentChoice, setShowAdminPaymentChoice] = useState(false);
|
|
233
|
-
const [promoCodeInput, setPromoCodeInput] = useState('');
|
|
234
|
-
const [appliedPromoCode, setAppliedPromoCode] = useState<string | null>(
|
|
255
|
+
const [promoCodeInput, setPromoCodeInput] = useState(() => initialBooking?.promoCode?.trim() ?? '');
|
|
256
|
+
const [appliedPromoCode, setAppliedPromoCode] = useState<string | null>(
|
|
257
|
+
() => initialBooking?.promoCode?.trim() || null
|
|
258
|
+
);
|
|
235
259
|
const [promoCodeError, setPromoCodeError] = useState('');
|
|
236
260
|
const [promoCodeValidating, setPromoCodeValidating] = useState(false);
|
|
237
261
|
const [promoDiscountAmount, setPromoDiscountAmount] = useState(0);
|
|
238
262
|
const [isGiftCard, setIsGiftCard] = useState(false);
|
|
239
263
|
const [isVoucher, setIsVoucher] = useState(false);
|
|
240
|
-
const [additionalHoursCount, setAdditionalHoursCount] = useState(
|
|
264
|
+
const [additionalHoursCount, setAdditionalHoursCount] = useState(
|
|
265
|
+
() => initialBooking?.additionalHoursCount ?? initialValues?.additionalHoursCount ?? 0
|
|
266
|
+
);
|
|
241
267
|
const [isSpecialRequestMode, setIsSpecialRequestMode] = useState(false);
|
|
242
268
|
const [specialRequestInputValue, setSpecialRequestInputValue] = useState('');
|
|
243
269
|
const [adminChoiceData, setAdminChoiceData] = useState<{
|
|
@@ -267,7 +293,9 @@ export function PrivateShuttleBookingFlow({
|
|
|
267
293
|
discountLabel?: string | null;
|
|
268
294
|
} | null>(null);
|
|
269
295
|
const [pricingConfig, setPricingConfig] = useState<PricingConfig | null>(null);
|
|
270
|
-
const [cancellationPolicyId, setCancellationPolicyId] = useState<string | null>(
|
|
296
|
+
const [cancellationPolicyId, setCancellationPolicyId] = useState<string | null>(
|
|
297
|
+
() => initialBooking?.cancellationPolicyId ?? null
|
|
298
|
+
);
|
|
271
299
|
const [precomputedPrices, setPrecomputedPrices] = useState<PrecomputedPricesByCategory | null>(null);
|
|
272
300
|
const [resourcePriceByCurrency, setResourcePriceByCurrency] = useState<Record<string, number> | null>(null);
|
|
273
301
|
const [resourcePriceByOption, setResourcePriceByOption] = useState<
|
|
@@ -1199,21 +1227,23 @@ export function PrivateShuttleBookingFlow({
|
|
|
1199
1227
|
setError(t('booking.selectPickupLocation'));
|
|
1200
1228
|
return;
|
|
1201
1229
|
}
|
|
1202
|
-
if (!
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1230
|
+
if (!isProviderChangeMode) {
|
|
1231
|
+
if (!email) {
|
|
1232
|
+
setError(t('booking.enterEmail') || 'Please enter your email address');
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
1236
|
+
setError(t('booking.invalidEmail') || 'Please enter a valid email address');
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
if (!firstName?.trim()) {
|
|
1240
|
+
setError(t('booking.enterFirstName') || 'Please enter your first name');
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
if (!lastName?.trim()) {
|
|
1244
|
+
setError(t('booking.enterLastName') || 'Please enter your last name');
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1217
1247
|
}
|
|
1218
1248
|
|
|
1219
1249
|
setLoading(true);
|
|
@@ -1226,6 +1256,31 @@ export function PrivateShuttleBookingFlow({
|
|
|
1226
1256
|
setLoading(false);
|
|
1227
1257
|
return;
|
|
1228
1258
|
}
|
|
1259
|
+
if (isProviderChangeMode && onChangeBooking) {
|
|
1260
|
+
const bookingItems = [{ category: 'RESOURCE' as const, count: billableResourceCount }];
|
|
1261
|
+
const [hours, minutes] = selectedStartTime.split(':').map(Number);
|
|
1262
|
+
const startTimeISO = `${selectedDate}T${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00-06:00`;
|
|
1263
|
+
await onChangeBooking({
|
|
1264
|
+
productId: selectedOption,
|
|
1265
|
+
dateTime: selectedDate,
|
|
1266
|
+
bookingItems,
|
|
1267
|
+
returnAvailabilityId: null,
|
|
1268
|
+
pickupLocationId: pickupLocationId ?? null,
|
|
1269
|
+
travelerHotel:
|
|
1270
|
+
selectedPickupLocation?.name ?? customPickupAddress ?? initialBooking?.travelerHotel ?? null,
|
|
1271
|
+
startTime: startTimeISO,
|
|
1272
|
+
passengerCount,
|
|
1273
|
+
childSafetySeatsCount: childSafetySeatsCount > 0 ? childSafetySeatsCount : null,
|
|
1274
|
+
foodRestrictions: foodRestrictions.trim() || null,
|
|
1275
|
+
addOnSelections: addOnSelections.length > 0 ? addOnSelections : null,
|
|
1276
|
+
cancellationPolicyId: cancellationPolicyId ?? initialBooking?.cancellationPolicyId ?? null,
|
|
1277
|
+
promoCode: appliedPromoCode ?? null,
|
|
1278
|
+
newTotalAmount: totalPrice,
|
|
1279
|
+
additionalHoursCount: isAdmin ? additionalHoursCount : null,
|
|
1280
|
+
});
|
|
1281
|
+
setLoading(false);
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1229
1284
|
const bookingSourceContext = buildBookingSourceContext(bookingSourceAttribution, {
|
|
1230
1285
|
clientChannelSource: inferClientBookingSourceFromProductIds(product.productId, selectedOption),
|
|
1231
1286
|
forcePartnerPortalChannel: partnerPortalBooking,
|
|
@@ -2649,11 +2704,12 @@ export function PrivateShuttleBookingFlow({
|
|
|
2649
2704
|
className={styles.submitBtn}
|
|
2650
2705
|
>
|
|
2651
2706
|
{loading
|
|
2652
|
-
?
|
|
2653
|
-
|
|
2654
|
-
t('booking.
|
|
2655
|
-
|
|
2656
|
-
|
|
2707
|
+
? isProviderChangeMode
|
|
2708
|
+
? 'Processing…'
|
|
2709
|
+
: (t('booking.creatingReservation') || 'Creating reservation...')
|
|
2710
|
+
: isProviderChangeMode
|
|
2711
|
+
? `Change booking (${formatCurrencyAmount(depositInfo ? depositInfo.depositAmount : totalPrice, currency, locale as 'en' | 'fr')})`
|
|
2712
|
+
: `${flowUi?.partnerDeferredInvoiceSubmitLabel || t('booking.continueToPayment') || 'Continue to Payment'} (${formatCurrencyAmount(depositInfo ? depositInfo.depositAmount : totalPrice, currency, locale as 'en' | 'fr')})`}
|
|
2657
2713
|
</button>
|
|
2658
2714
|
<p className={styles.secureNote}>
|
|
2659
2715
|
{t('booking.securePayment') || 'Secure payment powered by Stripe'}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState } from 'react';
|
|
4
4
|
import { useBookingHost } from '../../runtime';
|
|
5
|
+
import { DefaultTermsContent } from './DefaultTermsContent';
|
|
5
6
|
|
|
6
7
|
export interface TermsAcceptanceProps {
|
|
7
8
|
checked: boolean;
|
|
@@ -25,7 +26,7 @@ export function TermsAcceptance({
|
|
|
25
26
|
termsContent,
|
|
26
27
|
}: TermsAcceptanceProps) {
|
|
27
28
|
const { slots } = useBookingHost();
|
|
28
|
-
const TermsContent = slots.TermsContent;
|
|
29
|
+
const TermsContent = slots.TermsContent ?? DefaultTermsContent;
|
|
29
30
|
const [modalOpen, setModalOpen] = useState(false);
|
|
30
31
|
const content =
|
|
31
32
|
termsContent ?? (
|
|
@@ -1,3 +1,43 @@
|
|
|
1
|
+
export type ProviderDashboardPricingLine = {
|
|
2
|
+
lineKey: string;
|
|
3
|
+
label: string;
|
|
4
|
+
amount: number;
|
|
5
|
+
editable: boolean;
|
|
6
|
+
type?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type ProviderDashboardChangePricingUi = {
|
|
10
|
+
loading?: boolean;
|
|
11
|
+
error?: string | null;
|
|
12
|
+
helperText?: string;
|
|
13
|
+
quotedLines?: ProviderDashboardPricingLine[];
|
|
14
|
+
lineAmountInputs?: Record<string, string>;
|
|
15
|
+
onLineAmountInputChange?: (lineKey: string, value: string) => void;
|
|
16
|
+
onLineAmountInputBlur?: (lineKey: string) => void;
|
|
17
|
+
onLineAmountReset?: (lineKey: string) => void;
|
|
18
|
+
additionalAdjustments?: Array<{
|
|
19
|
+
id: string;
|
|
20
|
+
label: string;
|
|
21
|
+
amountInput: string;
|
|
22
|
+
mode: 'DISCOUNT' | 'CHARGE';
|
|
23
|
+
originalLabel?: string;
|
|
24
|
+
originalAmountInput?: string;
|
|
25
|
+
originalMode?: 'DISCOUNT' | 'CHARGE';
|
|
26
|
+
}>;
|
|
27
|
+
onAddAdditionalAdjustment?: () => void;
|
|
28
|
+
onUpdateAdditionalAdjustment?: (
|
|
29
|
+
id: string,
|
|
30
|
+
patch: Partial<{ label: string; amountInput: string; mode: 'DISCOUNT' | 'CHARGE' }>
|
|
31
|
+
) => void;
|
|
32
|
+
onRemoveAdditionalAdjustment?: (id: string) => void;
|
|
33
|
+
totalsPreview?: {
|
|
34
|
+
subtotalBeforeTax: number;
|
|
35
|
+
taxAmount: number;
|
|
36
|
+
totalAmount: number;
|
|
37
|
+
currency: string;
|
|
38
|
+
} | null;
|
|
39
|
+
};
|
|
40
|
+
|
|
1
41
|
/**
|
|
2
42
|
* Optional UI/behavior overrides for booking flows.
|
|
3
43
|
* Omitted fields keep main-site / change-booking defaults.
|
|
@@ -23,6 +63,8 @@ export interface BookingFlowUiOptions {
|
|
|
23
63
|
* when a host page has its own sticky chrome (e.g. partner portal header + tabs).
|
|
24
64
|
*/
|
|
25
65
|
itineraryStickyTopOffsetPx?: number;
|
|
66
|
+
/** Provider dashboard change flow: quote/override panel shown immediately before confirm CTA. */
|
|
67
|
+
providerDashboardChangePricingUi?: ProviderDashboardChangePricingUi;
|
|
26
68
|
}
|
|
27
69
|
|
|
28
70
|
/**
|
|
@@ -301,13 +301,16 @@
|
|
|
301
301
|
color: #b91c1c !important;
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
-
/* Clickable "your pickup location" etc.
|
|
304
|
+
/* Clickable "your pickup location" etc. should stay booking green */
|
|
305
305
|
.booking-flow-preflight button.text-stone-400.underline,
|
|
306
306
|
.booking-flow-preflight button.text-stone-400.underline:hover {
|
|
307
|
-
color: var(--booking-
|
|
307
|
+
color: var(--booking-emerald-600, #059669) !important;
|
|
308
308
|
background: none;
|
|
309
309
|
border: none;
|
|
310
310
|
}
|
|
311
|
+
.booking-flow-preflight button.text-stone-400.underline:hover {
|
|
312
|
+
color: var(--booking-emerald-700, #047857) !important;
|
|
313
|
+
}
|
|
311
314
|
|
|
312
315
|
/* ========== Itinerary section - orange bg, Poppins, lowercase (match TourDescription) ========== */
|
|
313
316
|
.booking-flow-preflight [class*="ItineraryBox_box"],
|
|
@@ -907,6 +910,38 @@
|
|
|
907
910
|
border-bottom-width: 1px !important;
|
|
908
911
|
}
|
|
909
912
|
|
|
913
|
+
/* Change-booking compare: single vertical / horizontal rule between current vs new (preflight-safe) */
|
|
914
|
+
/* Same as column total row: `border-t border-stone-200/80` in change-booking-compare.tsx */
|
|
915
|
+
.booking-flow-preflight .booking-change-compare-divider,
|
|
916
|
+
.booking-flow-root .booking-change-compare-divider {
|
|
917
|
+
flex-shrink: 0 !important;
|
|
918
|
+
border: none !important;
|
|
919
|
+
background-color: color-mix(
|
|
920
|
+
in srgb,
|
|
921
|
+
var(--booking-stone-200, #e7e5e4) 80%,
|
|
922
|
+
transparent
|
|
923
|
+
) !important;
|
|
924
|
+
}
|
|
925
|
+
@media (max-width: 767px) {
|
|
926
|
+
.booking-flow-preflight .booking-change-compare-divider,
|
|
927
|
+
.booking-flow-root .booking-change-compare-divider {
|
|
928
|
+
height: 1px !important;
|
|
929
|
+
width: 100% !important;
|
|
930
|
+
min-height: 1px !important;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
@media (min-width: 768px) {
|
|
934
|
+
.booking-flow-preflight .booking-change-compare-divider,
|
|
935
|
+
.booking-flow-root .booking-change-compare-divider {
|
|
936
|
+
width: 1px !important;
|
|
937
|
+
align-self: stretch !important;
|
|
938
|
+
height: auto !important;
|
|
939
|
+
min-height: 3rem !important;
|
|
940
|
+
margin-top: 0.875rem !important;
|
|
941
|
+
margin-bottom: 0.875rem !important;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
910
945
|
/* PriceSummary.ruleAbove — explicit module border beats preflight `* { border-width: 0 }` */
|
|
911
946
|
.booking-flow-preflight [class*="PriceSummary_ruleAbove"],
|
|
912
947
|
.booking-flow-root [class*="PriceSummary_ruleAbove"] {
|