@ticketboothapp/booking 1.2.167 → 1.2.168
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/AdminChangeBookingContent.tsx +13 -1
- package/src/components/booking/AdminChangeBookingFlow.tsx +180 -65
- package/src/components/booking/AdminChangeCheckoutPanel.tsx +43 -13
- package/src/components/booking/AdminChangePricingPanel.tsx +400 -134
- package/src/components/booking/AdminChangeReceiptComparison.tsx +163 -145
- package/src/components/booking/ChangeBookingFlow.tsx +6 -3
- package/src/components/booking/ChangeBookingQuoteStatusPlaceholder.tsx +7 -2
- package/src/components/booking/ChangeBookingSelectionControlsPanel.tsx +6 -0
- package/src/components/booking/ChangeBookingTicketsAndAddOnsPanel.tsx +80 -0
- package/src/components/booking/PickupLocationSelector.module.css +22 -0
- package/src/components/booking/PickupLocationSelector.tsx +3 -3
- package/src/components/booking/PriceSummary.tsx +4 -0
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +27 -13
- package/src/components/booking/PrivateShuttleCheckoutSection.tsx +95 -54
- package/src/components/booking/admin-adjustment-components.ts +44 -0
- package/src/components/booking/admin-adjustment-tax-behavior.ts +50 -0
- package/src/components/booking/admin-change-flow-state-helpers.ts +10 -0
- package/src/components/booking/admin-change-provider-payload.ts +3 -0
- package/src/components/booking/admin-change-quote-request-key.ts +27 -0
- package/src/components/booking/admin-change-v2-quote-request.ts +10 -2
- package/src/components/booking/admin-refund-decision-context.ts +37 -0
- package/src/components/booking/admin-refund-disposition.ts +71 -2
- package/src/components/booking/booking-flow-types.ts +4 -0
- package/src/components/booking/booking-flow.css +5 -0
- package/src/components/booking/change-booking-error-message.ts +137 -0
- package/src/components/booking/change-booking-flow-helpers.ts +77 -2
- package/src/components/booking/change-booking-quote-guards.ts +4 -1
- package/src/components/booking/change-booking-quote-state.ts +44 -1
- package/src/components/booking/incompatible-add-on-selections.ts +19 -0
- package/src/components/booking/private-shuttle-availability.ts +14 -0
- package/src/components/booking/private-shuttle-cancellation-policy.ts +9 -0
- package/src/components/booking/private-shuttle-checkout-controller.ts +5 -0
- package/src/components/booking/private-shuttle-provider-change-runner.ts +21 -0
- package/src/components/booking/private-shuttle-reservation-runner.ts +5 -3
- package/src/components/booking/provider-dashboard-change-booking.ts +16 -1
- package/src/components/booking/useAdminChangeCheckoutController.ts +16 -0
- package/src/components/booking/useAdminChangeProductReset.ts +6 -0
- package/src/components/booking/useAdminChangeProtectedPricing.ts +4 -0
- package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +6 -4
- package/src/components/booking/useAdminChangeQuotePreview.ts +76 -20
- package/src/components/booking/useAdminCustomReceiptLines.ts +171 -10
- package/src/components/booking/useAdminProviderPricingAdjustments.ts +50 -19
- package/src/components/booking/useBookingAvailabilityAddOns.ts +19 -2
- package/src/components/booking/useChangeBookingAddOnFloorController.ts +15 -9
- package/src/components/booking/useChangeBookingInitialHydration.ts +10 -7
- package/src/components/booking/useChangeBookingQuotePreview.ts +3 -1
- package/src/components/booking/useChangeBookingSelectionDetails.ts +23 -17
- package/src/lib/booking/change-booking-server-preview.ts +26 -9
- package/src/lib/booking/change-flow-pricing.ts +12 -0
- package/src/lib/booking/i18n/messages/en.json +1 -0
- package/src/lib/booking/i18n/messages/fr.json +1 -0
- package/src/lib/booking-api.ts +64 -0
- package/test/change-booking-helpers.test.ts +853 -3
- package/src/components/booking/useAdminChangePricingDebugPanel.tsx +0 -178
|
@@ -3,6 +3,28 @@
|
|
|
3
3
|
* Uses CSS module to avoid Tailwind purging/override issues in booking-flow.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
.searchInputWrapper {
|
|
7
|
+
position: relative;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.searchIcon {
|
|
11
|
+
position: absolute;
|
|
12
|
+
top: 50%;
|
|
13
|
+
left: 1rem;
|
|
14
|
+
z-index: 1;
|
|
15
|
+
width: 1.25rem;
|
|
16
|
+
height: 1.25rem;
|
|
17
|
+
color: #a8a29e;
|
|
18
|
+
pointer-events: none;
|
|
19
|
+
transform: translateY(-50%);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.searchIcon svg {
|
|
23
|
+
display: block;
|
|
24
|
+
width: 100%;
|
|
25
|
+
height: 100%;
|
|
26
|
+
}
|
|
27
|
+
|
|
6
28
|
/* Filter pills - horizontal scroll on mobile, scrollbar hidden until scroll (overlay-style) */
|
|
7
29
|
.filterPillsScroll {
|
|
8
30
|
display: flex;
|
|
@@ -916,9 +916,9 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
|
|
|
916
916
|
)}
|
|
917
917
|
|
|
918
918
|
{/* Input with search icon inside - padding ensures text stays to the right of icon */}
|
|
919
|
-
<div className=
|
|
920
|
-
<div className=
|
|
921
|
-
<svg
|
|
919
|
+
<div className={styles.searchInputWrapper}>
|
|
920
|
+
<div className={styles.searchIcon}>
|
|
921
|
+
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
922
922
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
923
923
|
</svg>
|
|
924
924
|
</div>
|
|
@@ -80,6 +80,8 @@ export interface PriceSummaryProps {
|
|
|
80
80
|
hideSubtotal?: boolean;
|
|
81
81
|
/** Overrides the final "Total" row label (e.g. change booking: amount due for the change) */
|
|
82
82
|
totalLabel?: string;
|
|
83
|
+
/** Prefix a positive total with + when the row represents a signed change rather than an absolute total. */
|
|
84
|
+
showPositiveTotalSign?: boolean;
|
|
83
85
|
/** Render after the total row (e.g. provider price delta + keep-original-price) */
|
|
84
86
|
extraAfterTotal?: ReactNode;
|
|
85
87
|
/** Optional map of editable line input values by line key. */
|
|
@@ -182,6 +184,7 @@ export function PriceSummary({
|
|
|
182
184
|
depositMode,
|
|
183
185
|
hideSubtotal = false,
|
|
184
186
|
totalLabel,
|
|
187
|
+
showPositiveTotalSign = false,
|
|
185
188
|
extraAfterTotal,
|
|
186
189
|
lineAmountInputs,
|
|
187
190
|
lineLabelInputs,
|
|
@@ -382,6 +385,7 @@ export function PriceSummary({
|
|
|
382
385
|
(t('common.total') && t('common.total') !== 'common.total' ? t('common.total') : 'Total')}
|
|
383
386
|
</span>
|
|
384
387
|
<span className="flex-shrink-0 whitespace-nowrap font-semibold text-stone-900">
|
|
388
|
+
{showPositiveTotalSign && total > 0.005 ? '+' : ''}
|
|
385
389
|
{formatCurrencyAmount(total, currency, locale)}
|
|
386
390
|
</span>
|
|
387
391
|
</div>
|
|
@@ -32,6 +32,7 @@ import { PrivateShuttleStartTimeSection } from './PrivateShuttleStartTimeSection
|
|
|
32
32
|
import {
|
|
33
33
|
getPrivateShuttleAvailabilityOptionId,
|
|
34
34
|
privateShuttleBookingCutoffMessage,
|
|
35
|
+
privateShuttleInitialDateKey,
|
|
35
36
|
privateShuttleStartDateTime,
|
|
36
37
|
privateShuttleStartTimeAllowed,
|
|
37
38
|
resolveHydratedPrivateShuttleAvailability,
|
|
@@ -51,6 +52,7 @@ import {
|
|
|
51
52
|
} from './usePrivateShuttlePriceSummary';
|
|
52
53
|
import { usePrivateShuttlePromoController } from './usePrivateShuttlePromoController';
|
|
53
54
|
import { useBookingViewItemAnalytics } from './useBookingViewItemAnalytics';
|
|
55
|
+
import { resolvePrivateShuttleCancellationPolicyId } from './private-shuttle-cancellation-policy';
|
|
54
56
|
|
|
55
57
|
interface PrivateShuttleBookingFlowProps {
|
|
56
58
|
product: Product;
|
|
@@ -131,7 +133,12 @@ export function PrivateShuttleBookingFlow({
|
|
|
131
133
|
const isAdmin = permissions.viewerRole === 'admin';
|
|
132
134
|
const isProviderChangeMode = Boolean(initialBooking && onChangeBooking);
|
|
133
135
|
|
|
134
|
-
const [selectedDate, setSelectedDate] = useState<string>(
|
|
136
|
+
const [selectedDate, setSelectedDate] = useState<string>(() =>
|
|
137
|
+
privateShuttleInitialDateKey(
|
|
138
|
+
initialBooking?.dateTime ?? initialValues?.dateTime,
|
|
139
|
+
companyTimezone,
|
|
140
|
+
)
|
|
141
|
+
);
|
|
135
142
|
const [selectedAvailability, setSelectedAvailability] = useState<Availability | null>(null);
|
|
136
143
|
const [selectedOption, setSelectedOption] = useState<string>('');
|
|
137
144
|
const [selectedStartTime, setSelectedStartTime] = useState<string>('');
|
|
@@ -139,10 +146,16 @@ export function PrivateShuttleBookingFlow({
|
|
|
139
146
|
const [passengerCount, setPassengerCount] = useState<number>(
|
|
140
147
|
() => initialBooking?.privateShuttleDetails?.passengerCount ?? initialValues?.passengers ?? 1
|
|
141
148
|
);
|
|
142
|
-
const [email, setEmail] = useState('');
|
|
143
|
-
const [firstName, setFirstName] = useState(
|
|
144
|
-
|
|
145
|
-
|
|
149
|
+
const [email, setEmail] = useState(() => initialBooking?.customer?.email?.trim() ?? '');
|
|
150
|
+
const [firstName, setFirstName] = useState(
|
|
151
|
+
() => initialBooking?.customer?.firstName?.trim() ?? ''
|
|
152
|
+
);
|
|
153
|
+
const [lastName, setLastName] = useState(
|
|
154
|
+
() => initialBooking?.customer?.lastName?.trim() ?? ''
|
|
155
|
+
);
|
|
156
|
+
const [phoneNumber, setPhoneNumber] = useState(
|
|
157
|
+
() => initialBooking?.customer?.phoneNumber?.trim() ?? ''
|
|
158
|
+
);
|
|
146
159
|
const [pickupLocationId, setPickupLocationId] = useState<string | null>(
|
|
147
160
|
() => initialBooking?.pickupLocationId ?? initialValues?.pickupLocationId ?? null
|
|
148
161
|
);
|
|
@@ -544,14 +557,14 @@ export function PrivateShuttleBookingFlow({
|
|
|
544
557
|
}, [isAdmin, isCustomTimeMode, selectedStartTime, suggestedStartTimes]);
|
|
545
558
|
|
|
546
559
|
useEffect(() => {
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
}, [pricingConfig?.cancellationPolicies, cancellationPolicyId]);
|
|
560
|
+
const policies = pricingConfig?.cancellationPolicies;
|
|
561
|
+
if (!policies?.length || forcedCancellationPolicy) return;
|
|
562
|
+
const resolved = resolvePrivateShuttleCancellationPolicyId(
|
|
563
|
+
cancellationPolicyId,
|
|
564
|
+
policies.map((policy) => policy.id),
|
|
565
|
+
);
|
|
566
|
+
if (resolved !== cancellationPolicyId) setCancellationPolicyId(resolved);
|
|
567
|
+
}, [pricingConfig?.cancellationPolicies, cancellationPolicyId, forcedCancellationPolicy]);
|
|
555
568
|
|
|
556
569
|
const handleOptionSelect = (optionId: string) => {
|
|
557
570
|
setSelectedOption(optionId);
|
|
@@ -808,6 +821,7 @@ export function PrivateShuttleBookingFlow({
|
|
|
808
821
|
isLoading={loadingAvailabilities || isFetchingMoreAvailabilities}
|
|
809
822
|
onDateSelect={handleDateSelect}
|
|
810
823
|
timezone={companyTimezone}
|
|
824
|
+
syncVisibleWeekToSelectedDate={isProviderChangeMode || Boolean(initialValues?.dateTime)}
|
|
811
825
|
displayMode="status"
|
|
812
826
|
earliestDate={earliestAvailabilityDate}
|
|
813
827
|
onVisibleRangeChange={handleVisibleRangeChange}
|
|
@@ -123,7 +123,8 @@ export function PrivateShuttleCheckoutSection({
|
|
|
123
123
|
partnerDeferredInvoiceSubmitLabel,
|
|
124
124
|
}: PrivateShuttleCheckoutSectionProps) {
|
|
125
125
|
const amountDueToday = depositInfo ? depositInfo.depositAmount : totalPrice;
|
|
126
|
-
const
|
|
126
|
+
const displayedTotal = isProviderChangeMode ? totalPrice : amountDueToday;
|
|
127
|
+
const formattedDisplayedTotal = formatCurrencyAmount(displayedTotal, currency, locale);
|
|
127
128
|
const hasCancellationPolicies = Boolean(cancellationPolicies?.length || forcedCancellationPolicy);
|
|
128
129
|
|
|
129
130
|
return (
|
|
@@ -131,7 +132,7 @@ export function PrivateShuttleCheckoutSection({
|
|
|
131
132
|
<div className={`${styles.section} mt-6`}>
|
|
132
133
|
<PriceSummary
|
|
133
134
|
lines={priceSummaryLines}
|
|
134
|
-
total={
|
|
135
|
+
total={displayedTotal}
|
|
135
136
|
currency={currency}
|
|
136
137
|
locale={locale}
|
|
137
138
|
taxAmount={effectiveTaxAmount}
|
|
@@ -158,7 +159,7 @@ export function PrivateShuttleCheckoutSection({
|
|
|
158
159
|
size="sm"
|
|
159
160
|
t={t}
|
|
160
161
|
depositMode={
|
|
161
|
-
depositInfo
|
|
162
|
+
!isProviderChangeMode && depositInfo
|
|
162
163
|
? {
|
|
163
164
|
totalLabel: t('booking.deposit') || 'Deposit',
|
|
164
165
|
balanceAmount: depositInfo.balanceAmount,
|
|
@@ -166,9 +167,15 @@ export function PrivateShuttleCheckoutSection({
|
|
|
166
167
|
}
|
|
167
168
|
: undefined
|
|
168
169
|
}
|
|
169
|
-
hideSubtotal={!!depositInfo}
|
|
170
|
+
hideSubtotal={!isProviderChangeMode && !!depositInfo}
|
|
170
171
|
/>
|
|
171
|
-
{
|
|
172
|
+
{isProviderChangeMode ? (
|
|
173
|
+
<div className="mt-3 rounded-lg border border-sky-200 bg-sky-50 p-3 text-xs text-sky-900">
|
|
174
|
+
This is the updated booking total. Payments already made stay with this booking and are
|
|
175
|
+
applied first; only the remaining balance will be recorded as owing.
|
|
176
|
+
</div>
|
|
177
|
+
) : null}
|
|
178
|
+
{!isProviderChangeMode && depositInfo && (
|
|
172
179
|
<div className="deposit-notice mt-3 p-3 bg-amber-50 border border-amber-200 rounded-lg text-amber-800">
|
|
173
180
|
<p className="text-xs font-medium">
|
|
174
181
|
{(t('booking.depositPaymentNotice') !== 'booking.depositPaymentNotice'
|
|
@@ -190,51 +197,78 @@ export function PrivateShuttleCheckoutSection({
|
|
|
190
197
|
|
|
191
198
|
<div className="border-t border-stone-200 pt-6 space-y-4">
|
|
192
199
|
<div className="space-y-4">
|
|
193
|
-
|
|
194
|
-
<div>
|
|
195
|
-
<
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
200
|
+
{isProviderChangeMode ? (
|
|
201
|
+
<div className="rounded-lg border border-stone-200 bg-stone-50 p-4">
|
|
202
|
+
<p className="m-0 text-sm font-semibold text-stone-900">Existing booking contact</p>
|
|
203
|
+
<div className="mt-3 grid gap-3 text-sm sm:grid-cols-2">
|
|
204
|
+
<div>
|
|
205
|
+
<span className="block text-xs font-medium text-stone-500">Name</span>
|
|
206
|
+
<span className="text-stone-900">
|
|
207
|
+
{[firstName, lastName].filter(Boolean).join(' ') || 'Not provided'}
|
|
208
|
+
</span>
|
|
209
|
+
</div>
|
|
210
|
+
<div>
|
|
211
|
+
<span className="block text-xs font-medium text-stone-500">Email</span>
|
|
212
|
+
<span className="text-stone-900">{email || 'Not provided'}</span>
|
|
213
|
+
</div>
|
|
214
|
+
<div>
|
|
215
|
+
<span className="block text-xs font-medium text-stone-500">Phone</span>
|
|
216
|
+
<span className="text-stone-900">{phoneNumber || 'Not provided'}</span>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
<p className="mb-0 mt-3 text-xs text-stone-600">
|
|
220
|
+
These customer details stay unchanged on the existing booking.
|
|
221
|
+
</p>
|
|
205
222
|
</div>
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
223
|
+
) : (
|
|
224
|
+
<>
|
|
225
|
+
<div className={styles.contactGrid}>
|
|
226
|
+
<div>
|
|
227
|
+
<label className={styles.sectionLabel}>
|
|
228
|
+
{t('booking.firstName')} <span className={styles.required}>*</span>
|
|
229
|
+
</label>
|
|
230
|
+
<input
|
|
231
|
+
type="text"
|
|
232
|
+
value={firstName}
|
|
233
|
+
onChange={(e) => onFirstNameChange(e.target.value)}
|
|
234
|
+
placeholder={t('booking.firstNamePlaceholder')}
|
|
235
|
+
className={styles.input}
|
|
236
|
+
/>
|
|
237
|
+
</div>
|
|
238
|
+
<div>
|
|
239
|
+
<label className={styles.sectionLabel}>
|
|
240
|
+
{t('booking.lastName')} <span className={styles.required}>*</span>
|
|
241
|
+
</label>
|
|
242
|
+
<input
|
|
243
|
+
type="text"
|
|
244
|
+
value={lastName}
|
|
245
|
+
onChange={(e) => onLastNameChange(e.target.value)}
|
|
246
|
+
placeholder={t('booking.lastNamePlaceholder')}
|
|
247
|
+
className={styles.input}
|
|
248
|
+
/>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
<div>
|
|
252
|
+
<label className={styles.sectionLabel}>
|
|
253
|
+
{t('common.emailForConfirmation')} <span className={styles.required}>*</span>
|
|
254
|
+
</label>
|
|
255
|
+
<input
|
|
256
|
+
type="email"
|
|
257
|
+
value={email}
|
|
258
|
+
onChange={(e) => onEmailChange(e.target.value)}
|
|
259
|
+
placeholder={t('common.emailPlaceholder')}
|
|
260
|
+
className={styles.input}
|
|
261
|
+
/>
|
|
262
|
+
</div>
|
|
263
|
+
<CheckoutOptionalPhoneFields
|
|
264
|
+
idPrefix="shuttle"
|
|
265
|
+
labelClassName={styles.sectionLabel}
|
|
266
|
+
phoneNumber={phoneNumber}
|
|
267
|
+
onPhoneNumberChange={onPhoneNumberChange}
|
|
268
|
+
t={t}
|
|
216
269
|
/>
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
<div>
|
|
220
|
-
<label className={styles.sectionLabel}>
|
|
221
|
-
{t('common.emailForConfirmation')} <span className={styles.required}>*</span>
|
|
222
|
-
</label>
|
|
223
|
-
<input
|
|
224
|
-
type="email"
|
|
225
|
-
value={email}
|
|
226
|
-
onChange={(e) => onEmailChange(e.target.value)}
|
|
227
|
-
placeholder={t('common.emailPlaceholder')}
|
|
228
|
-
className={styles.input}
|
|
229
|
-
/>
|
|
230
|
-
</div>
|
|
231
|
-
<CheckoutOptionalPhoneFields
|
|
232
|
-
idPrefix="shuttle"
|
|
233
|
-
labelClassName={styles.sectionLabel}
|
|
234
|
-
phoneNumber={phoneNumber}
|
|
235
|
-
onPhoneNumberChange={onPhoneNumberChange}
|
|
236
|
-
t={t}
|
|
237
|
-
/>
|
|
270
|
+
</>
|
|
271
|
+
)}
|
|
238
272
|
{hasCancellationPolicies && (
|
|
239
273
|
<div>
|
|
240
274
|
<CancellationPolicySelector
|
|
@@ -250,10 +284,17 @@ export function PrivateShuttleCheckoutSection({
|
|
|
250
284
|
onPolicySelect={onCancellationPolicySelect}
|
|
251
285
|
forcedPolicy={forcedCancellationPolicy}
|
|
252
286
|
rowSubtitle={
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
287
|
+
isProviderChangeMode ? (
|
|
288
|
+
<>
|
|
289
|
+
This policy applies to the updated private booking. Payments already made stay
|
|
290
|
+
on the existing booking.
|
|
291
|
+
</>
|
|
292
|
+
) : (
|
|
293
|
+
<>
|
|
294
|
+
<b>Your deposit today is 100% refundable</b> - only once we align on an itinerary
|
|
295
|
+
that you approve, will your deposit be locked in.
|
|
296
|
+
</>
|
|
297
|
+
)
|
|
257
298
|
}
|
|
258
299
|
/>
|
|
259
300
|
</div>
|
|
@@ -327,8 +368,8 @@ export function PrivateShuttleCheckoutSection({
|
|
|
327
368
|
? 'Processing...'
|
|
328
369
|
: (t('booking.creatingReservation') || 'Creating reservation...')
|
|
329
370
|
: isProviderChangeMode
|
|
330
|
-
? `Change booking (${
|
|
331
|
-
: `${partnerDeferredInvoiceSubmitLabel || t('booking.continueToPayment') || 'Continue to Payment'} (${
|
|
371
|
+
? `Change booking (updated total ${formattedDisplayedTotal})`
|
|
372
|
+
: `${partnerDeferredInvoiceSubmitLabel || t('booking.continueToPayment') || 'Continue to Payment'} (${formattedDisplayedTotal})`}
|
|
332
373
|
</button>
|
|
333
374
|
<p className={styles.secureNote}>
|
|
334
375
|
{t('booking.securePayment') || 'Secure payment powered by Stripe'}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ChangeBookingQuoteResponse } from '../../lib/booking-api';
|
|
2
|
+
|
|
3
|
+
export type AdminAdjustmentComponentOption = {
|
|
4
|
+
componentId: string;
|
|
5
|
+
label: string;
|
|
6
|
+
amount: number;
|
|
7
|
+
type: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const NON_SELECTABLE_TYPES = new Set([
|
|
11
|
+
'TAX',
|
|
12
|
+
'ROUNDING',
|
|
13
|
+
'SYSTEM',
|
|
14
|
+
'PROMO_CODE',
|
|
15
|
+
'GIFT_CARD',
|
|
16
|
+
'DEAL',
|
|
17
|
+
'DISCOUNT',
|
|
18
|
+
'BOOKING_CHANGE',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
export function selectableAdminAdjustmentComponentsFromQuote(
|
|
22
|
+
quote: ChangeBookingQuoteResponse,
|
|
23
|
+
): AdminAdjustmentComponentOption[] {
|
|
24
|
+
const pricingQuote = quote.pricingQuote ?? quote.quote;
|
|
25
|
+
const lines = pricingQuote?.amendment?.updatedReceipt?.lineItems ?? [];
|
|
26
|
+
const byId = new Map<string, AdminAdjustmentComponentOption>();
|
|
27
|
+
|
|
28
|
+
lines.forEach((line) => {
|
|
29
|
+
const componentId = line.identity?.componentId?.trim() || line.reference?.trim();
|
|
30
|
+
const type = line.type?.trim().toUpperCase() || 'ITEM';
|
|
31
|
+
const amount = Number(line.amount ?? 0);
|
|
32
|
+
if (!componentId || !Number.isFinite(amount) || amount <= 0 || NON_SELECTABLE_TYPES.has(type)) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
byId.set(componentId, {
|
|
36
|
+
componentId,
|
|
37
|
+
label: line.label?.trim() || type,
|
|
38
|
+
amount,
|
|
39
|
+
type,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return [...byId.values()];
|
|
44
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { AdminCustomReceiptLine } from './useAdminCustomReceiptLines';
|
|
2
|
+
|
|
3
|
+
export type AdminAdjustmentTaxBehavior = AdminCustomReceiptLine['taxBehavior'];
|
|
4
|
+
|
|
5
|
+
export type AdminAdjustmentTaxBehaviorOption = {
|
|
6
|
+
value: AdminAdjustmentTaxBehavior;
|
|
7
|
+
label: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const CHARGE_OPTIONS: readonly AdminAdjustmentTaxBehaviorOption[] = [
|
|
11
|
+
{ value: 'TAXABLE', label: 'Taxable' },
|
|
12
|
+
{ value: 'NON_TAXABLE', label: 'Non-taxable' },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const CREDIT_OPTIONS: readonly AdminAdjustmentTaxBehaviorOption[] = [
|
|
16
|
+
{ value: 'PRE_TAX_DISCOUNT', label: 'Pre-tax discount' },
|
|
17
|
+
{ value: 'POST_TAX_DISCOUNT', label: 'Post-tax discount' },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export function adminAdjustmentTaxBehaviorOptions(
|
|
21
|
+
amountSign: AdminCustomReceiptLine['amountSign'],
|
|
22
|
+
): readonly AdminAdjustmentTaxBehaviorOption[] {
|
|
23
|
+
return amountSign === -1 ? CREDIT_OPTIONS : CHARGE_OPTIONS;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function normalizeAdminAdjustmentTaxBehavior(
|
|
27
|
+
amountSign: AdminCustomReceiptLine['amountSign'],
|
|
28
|
+
current: AdminAdjustmentTaxBehavior,
|
|
29
|
+
): AdminAdjustmentTaxBehavior {
|
|
30
|
+
const options = adminAdjustmentTaxBehaviorOptions(amountSign);
|
|
31
|
+
return options.some((option) => option.value === current)
|
|
32
|
+
? current
|
|
33
|
+
: options[0].value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type AdminAdjustmentScope = AdminCustomReceiptLine['scope'];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* New custom lines start as charges against added value. A credit against that
|
|
40
|
+
* scope has no basis until the amendment also adds priced value, so switching
|
|
41
|
+
* direction defaults to the existing active tickets instead.
|
|
42
|
+
*/
|
|
43
|
+
export function normalizeAdminAdjustmentScopeForSign(
|
|
44
|
+
amountSign: AdminCustomReceiptLine['amountSign'],
|
|
45
|
+
current: AdminAdjustmentScope,
|
|
46
|
+
): AdminAdjustmentScope {
|
|
47
|
+
return amountSign === -1 && current === 'POSITIVE_AMENDMENT_VALUE'
|
|
48
|
+
? 'CURRENT_ACTIVE_TICKETS'
|
|
49
|
+
: current;
|
|
50
|
+
}
|
|
@@ -66,6 +66,16 @@ export function buildChangeBookingMinimumQuantities({
|
|
|
66
66
|
return minimums;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
export function shouldLockExistingAddOnQuantities({
|
|
70
|
+
isCustomerSelfServeChange,
|
|
71
|
+
isAdmin,
|
|
72
|
+
}: {
|
|
73
|
+
isCustomerSelfServeChange: boolean;
|
|
74
|
+
isAdmin: boolean;
|
|
75
|
+
}): boolean {
|
|
76
|
+
return isCustomerSelfServeChange && !isAdmin;
|
|
77
|
+
}
|
|
78
|
+
|
|
69
79
|
export function resolveInitialChangeProductOptionId({
|
|
70
80
|
initialProductId,
|
|
71
81
|
initialProductOptionId,
|
|
@@ -39,6 +39,7 @@ export interface BuildAdminChangeProviderPayloadParams {
|
|
|
39
39
|
providerPricingOverrides: AdminChangeProviderLineOverride[];
|
|
40
40
|
mergedProviderAdditionalAdjustments: AdminChangeProviderAdditionalAdjustment[];
|
|
41
41
|
adminStructuredAdjustments: NonNullable<ProviderDashboardChangeBookingPayload['structuredAdjustments']>;
|
|
42
|
+
adminReverseAdjustmentIds?: string[];
|
|
42
43
|
refundDispositionsByOperationId: NonNullable<
|
|
43
44
|
ProviderDashboardChangeBookingPayload['refundDispositionsByOperationId']
|
|
44
45
|
>;
|
|
@@ -67,6 +68,7 @@ export function buildAdminChangeProviderPayload({
|
|
|
67
68
|
providerPricingOverrides,
|
|
68
69
|
mergedProviderAdditionalAdjustments,
|
|
69
70
|
adminStructuredAdjustments,
|
|
71
|
+
adminReverseAdjustmentIds = [],
|
|
70
72
|
refundDispositionsByOperationId,
|
|
71
73
|
pricingV2QuoteId,
|
|
72
74
|
pricingV2QuotedTotal,
|
|
@@ -114,6 +116,7 @@ export function buildAdminChangeProviderPayload({
|
|
|
114
116
|
: undefined,
|
|
115
117
|
authoritativeReceipt: providerApplyAuthoritativeReceipt,
|
|
116
118
|
structuredAdjustments: adminStructuredAdjustments,
|
|
119
|
+
reverseAdjustmentIds: adminReverseAdjustmentIds,
|
|
117
120
|
refundDispositionsByOperationId,
|
|
118
121
|
pricingV2QuoteId: pricingV2QuoteId ?? null,
|
|
119
122
|
pricingV2QuotedTotal: pricingV2QuotedTotal ?? null,
|
|
@@ -15,10 +15,36 @@ interface AdminChangeQuoteRequestKeyInput {
|
|
|
15
15
|
promoCode?: string | null;
|
|
16
16
|
promoApplicationScope?: AdminPromoApplicationScope | null;
|
|
17
17
|
structuredAdjustments?: unknown[];
|
|
18
|
+
reverseAdjustmentIds?: string[];
|
|
18
19
|
refundDispositionsByOperationId?: Record<string, string>;
|
|
19
20
|
useAdminFeAuthoritativeQuote: boolean;
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
interface AdminChangeQuoteRequestGateInput {
|
|
24
|
+
isCustomerSelfServeChange: boolean;
|
|
25
|
+
hasEffectiveChangeSelection: boolean;
|
|
26
|
+
hasSelectedAvailability: boolean;
|
|
27
|
+
bookingReference?: string | null;
|
|
28
|
+
lastName: string;
|
|
29
|
+
missingRequiredReturnSelection: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether the current admin inputs are complete enough to request an authoritative quote.
|
|
34
|
+
* `hasEffectiveChangeSelection` deliberately includes adjustment-only changes, even when
|
|
35
|
+
* product, tickets, return, pickup, and itinerary are otherwise unchanged.
|
|
36
|
+
*/
|
|
37
|
+
export function canRequestAdminChangeQuote(input: AdminChangeQuoteRequestGateInput): boolean {
|
|
38
|
+
return Boolean(
|
|
39
|
+
input.isCustomerSelfServeChange &&
|
|
40
|
+
input.hasEffectiveChangeSelection &&
|
|
41
|
+
input.hasSelectedAvailability &&
|
|
42
|
+
input.bookingReference?.trim() &&
|
|
43
|
+
input.lastName.trim() &&
|
|
44
|
+
!input.missingRequiredReturnSelection,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
22
48
|
/**
|
|
23
49
|
* Identity of a price request. This must contain user-controlled quote inputs only.
|
|
24
50
|
* Server/display-derived receipt state must never invalidate the response that produced it.
|
|
@@ -41,6 +67,7 @@ export function buildAdminChangeQuoteRequestKey(input: AdminChangeQuoteRequestKe
|
|
|
41
67
|
promoCode: input.promoCode?.trim().toUpperCase() ?? null,
|
|
42
68
|
promoApplicationScope: input.promoCode ? (input.promoApplicationScope ?? 'CHANGE_ONLY') : null,
|
|
43
69
|
structuredAdjustments: input.structuredAdjustments ?? [],
|
|
70
|
+
reverseAdjustmentIds: [...(input.reverseAdjustmentIds ?? [])].sort(),
|
|
44
71
|
refundDispositionsByOperationId: Object.fromEntries(
|
|
45
72
|
Object.entries(input.refundDispositionsByOperationId ?? {}).sort(([a], [b]) => a.localeCompare(b)),
|
|
46
73
|
),
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
|
|
15
15
|
export type AdminChangeV2QuoteRequest = ChangeBookingQuoteRequest & {
|
|
16
16
|
cancellationPolicyId?: string | null;
|
|
17
|
-
|
|
17
|
+
includeSelectableAdjustmentComponents?: boolean;
|
|
18
18
|
};
|
|
19
19
|
|
|
20
20
|
export interface BuildAdminChangeV2QuoteRequestParams {
|
|
@@ -30,7 +30,9 @@ export interface BuildAdminChangeV2QuoteRequestParams {
|
|
|
30
30
|
cancellationPolicyId: string | null;
|
|
31
31
|
promoCode?: string | null;
|
|
32
32
|
promoApplicationScope?: AdminPromoApplicationScope;
|
|
33
|
-
structuredAdjustments:
|
|
33
|
+
structuredAdjustments: NonNullable<ChangeBookingQuoteRequest['structuredAdjustments']>;
|
|
34
|
+
reverseAdjustmentIds?: string[];
|
|
35
|
+
includeSelectableAdjustmentComponents?: boolean;
|
|
34
36
|
refundDispositionsByOperationId: Record<string, AdminAmendmentRefundDisposition>;
|
|
35
37
|
previousPassengerCount: number;
|
|
36
38
|
previousAvailabilityId: string | null;
|
|
@@ -56,6 +58,8 @@ export function buildAdminChangeV2QuoteRequest({
|
|
|
56
58
|
promoCode,
|
|
57
59
|
promoApplicationScope = 'CHANGE_ONLY',
|
|
58
60
|
structuredAdjustments,
|
|
61
|
+
reverseAdjustmentIds = [],
|
|
62
|
+
includeSelectableAdjustmentComponents = false,
|
|
59
63
|
refundDispositionsByOperationId,
|
|
60
64
|
previousPassengerCount,
|
|
61
65
|
previousAvailabilityId,
|
|
@@ -77,6 +81,10 @@ export function buildAdminChangeV2QuoteRequest({
|
|
|
77
81
|
promoCode: promoCode?.trim() || null,
|
|
78
82
|
promoApplicationScope: promoCode?.trim() ? promoApplicationScope : null,
|
|
79
83
|
structuredAdjustments,
|
|
84
|
+
reverseAdjustmentIds,
|
|
85
|
+
...(includeSelectableAdjustmentComponents
|
|
86
|
+
? { includeSelectableAdjustmentComponents: true }
|
|
87
|
+
: {}),
|
|
80
88
|
...(Object.keys(refundDispositionsByOperationId).length > 0
|
|
81
89
|
? { refundDispositionsByOperationId }
|
|
82
90
|
: {}),
|
|
@@ -83,3 +83,40 @@ export function selectRefundDispositionsForOperations(
|
|
|
83
83
|
),
|
|
84
84
|
);
|
|
85
85
|
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Staff removals default to a pending refund. The refund remains a separate,
|
|
89
|
+
* reviewed action; this only removes the required choice from the change form.
|
|
90
|
+
*/
|
|
91
|
+
export function defaultPendingRefundDispositions(
|
|
92
|
+
operations: readonly AdminAmendmentOperationSnapshot[],
|
|
93
|
+
dispositionsByOperationId: Record<string, AdminAmendmentRefundDisposition>,
|
|
94
|
+
): Record<string, AdminAmendmentRefundDisposition> {
|
|
95
|
+
if (operations.length === 0) return dispositionsByOperationId;
|
|
96
|
+
|
|
97
|
+
return operations.reduce<Record<string, AdminAmendmentRefundDisposition>>(
|
|
98
|
+
(next, operation) => {
|
|
99
|
+
next[operation.operationId] = 'PENDING_REFUND';
|
|
100
|
+
return next;
|
|
101
|
+
},
|
|
102
|
+
{ ...dispositionsByOperationId },
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Omitting a disposition for a return-price treatment operation tells the server
|
|
108
|
+
* to retain the original paid return-price floor.
|
|
109
|
+
*/
|
|
110
|
+
export function defaultKeepOriginalReturnPriceDispositions(
|
|
111
|
+
operations: readonly AdminAmendmentOperationSnapshot[],
|
|
112
|
+
dispositionsByOperationId: Record<string, AdminAmendmentRefundDisposition>,
|
|
113
|
+
): Record<string, AdminAmendmentRefundDisposition> {
|
|
114
|
+
if (operations.length === 0) return dispositionsByOperationId;
|
|
115
|
+
|
|
116
|
+
const returnOperationIds = new Set(operations.map((operation) => operation.operationId));
|
|
117
|
+
return Object.fromEntries(
|
|
118
|
+
Object.entries(dispositionsByOperationId).filter(
|
|
119
|
+
([operationId]) => !returnOperationIds.has(operationId),
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
}
|