@ticketboothapp/booking 1.2.184 → 1.2.186
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 +2 -1
- package/src/components/BackgroundVideo.tsx +126 -0
- package/src/components/booking/AdminChangeBookingContent.tsx +1 -0
- package/src/components/booking/AdminChangeBookingFlow.tsx +2 -0
- package/src/components/booking/AdminChangeCheckoutPanel.tsx +13 -2
- package/src/components/booking/AdminChangeReceiptComparison.tsx +143 -15
- package/src/components/booking/BookingFlowCollage.module.css +1 -1
- package/src/components/booking/BookingFlowCollage.tsx +4 -2
- package/src/components/booking/BookingProductGrid.tsx +5 -3
- package/src/components/booking/PriceBreakdown.tsx +24 -0
- package/src/components/booking/PriceSummary.tsx +21 -1
- package/src/components/booking/useAdminCustomReceiptLines.ts +59 -0
- package/src/index.ts +2 -0
- package/src/lib/booking/change-booking-server-preview.ts +6 -0
- package/src/strings/en.json +1 -1
- package/src/strings/es.json +1 -1
- package/src/strings/fr.json +1 -1
- package/test/change-booking-helpers.test.ts +47 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ticketboothapp/booking",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.186",
|
|
4
4
|
"private": false,
|
|
5
5
|
"sideEffects": [
|
|
6
6
|
"**/*.css",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
},
|
|
12
12
|
"exports": {
|
|
13
13
|
".": "./src/index.ts",
|
|
14
|
+
"./background-video": "./src/components/BackgroundVideo.tsx",
|
|
14
15
|
"./booking-flow.css": "./src/components/booking/booking-flow.css",
|
|
15
16
|
"./runtime": "./src/runtime/index.ts",
|
|
16
17
|
"./contexts/booking-app-context": "./src/contexts/BookingAppContext.tsx",
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
forwardRef,
|
|
5
|
+
useCallback,
|
|
6
|
+
useEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type CSSProperties,
|
|
10
|
+
type ForwardedRef,
|
|
11
|
+
type VideoHTMLAttributes,
|
|
12
|
+
} from 'react';
|
|
13
|
+
|
|
14
|
+
export type BackgroundVideoLoading = 'eager' | 'viewport';
|
|
15
|
+
|
|
16
|
+
export interface BackgroundVideoProps
|
|
17
|
+
extends Omit<VideoHTMLAttributes<HTMLVideoElement>, 'children' | 'poster' | 'preload' | 'src'> {
|
|
18
|
+
src: string;
|
|
19
|
+
webm?: string;
|
|
20
|
+
poster?: string;
|
|
21
|
+
preload?: 'auto' | 'metadata' | 'none';
|
|
22
|
+
loading?: BackgroundVideoLoading;
|
|
23
|
+
className?: string;
|
|
24
|
+
style?: CSSProperties;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function assignRef(ref: ForwardedRef<HTMLVideoElement>, value: HTMLVideoElement | null) {
|
|
28
|
+
if (typeof ref === 'function') ref(value);
|
|
29
|
+
else if (ref) ref.current = value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Lightweight background video with real multi-format sources. Viewport videos
|
|
34
|
+
* do not request media until they are near the screen and pause again offscreen.
|
|
35
|
+
*/
|
|
36
|
+
export const BackgroundVideo = forwardRef<HTMLVideoElement, BackgroundVideoProps>(
|
|
37
|
+
function BackgroundVideo(
|
|
38
|
+
{
|
|
39
|
+
src,
|
|
40
|
+
webm,
|
|
41
|
+
poster,
|
|
42
|
+
preload,
|
|
43
|
+
loading = 'viewport',
|
|
44
|
+
className,
|
|
45
|
+
style,
|
|
46
|
+
autoPlay = true,
|
|
47
|
+
muted = true,
|
|
48
|
+
loop = true,
|
|
49
|
+
playsInline = true,
|
|
50
|
+
...videoProps
|
|
51
|
+
},
|
|
52
|
+
forwardedRef,
|
|
53
|
+
) {
|
|
54
|
+
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
55
|
+
const videoRef = useRef<HTMLVideoElement | null>(null);
|
|
56
|
+
const [shouldLoad, setShouldLoad] = useState(loading === 'eager');
|
|
57
|
+
const [shouldPlay, setShouldPlay] = useState(loading === 'eager');
|
|
58
|
+
|
|
59
|
+
const setVideoRef = useCallback((element: HTMLVideoElement | null) => {
|
|
60
|
+
videoRef.current = element;
|
|
61
|
+
assignRef(forwardedRef, element);
|
|
62
|
+
}, [forwardedRef]);
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (loading === 'eager') {
|
|
66
|
+
setShouldLoad(true);
|
|
67
|
+
setShouldPlay(true);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const container = containerRef.current;
|
|
72
|
+
if (!container || typeof IntersectionObserver === 'undefined') {
|
|
73
|
+
setShouldLoad(true);
|
|
74
|
+
setShouldPlay(true);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const observer = new IntersectionObserver(
|
|
79
|
+
([entry]) => {
|
|
80
|
+
if (entry.isIntersecting) setShouldLoad(true);
|
|
81
|
+
setShouldPlay(entry.isIntersecting);
|
|
82
|
+
},
|
|
83
|
+
{ rootMargin: '300px 0px' },
|
|
84
|
+
);
|
|
85
|
+
observer.observe(container);
|
|
86
|
+
return () => observer.disconnect();
|
|
87
|
+
}, [loading]);
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
const video = videoRef.current;
|
|
91
|
+
if (!video || !shouldLoad || !autoPlay) return;
|
|
92
|
+
if (shouldPlay) void video.play().catch(() => undefined);
|
|
93
|
+
else video.pause();
|
|
94
|
+
}, [autoPlay, shouldLoad, shouldPlay, src, webm]);
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div
|
|
98
|
+
ref={containerRef}
|
|
99
|
+
className={[className, 'next-video-bg'].filter(Boolean).join(' ')}
|
|
100
|
+
style={{ width: '100%', height: '100%', display: 'grid', ...style }}
|
|
101
|
+
>
|
|
102
|
+
<video
|
|
103
|
+
{...videoProps}
|
|
104
|
+
ref={setVideoRef}
|
|
105
|
+
className="next-video-bg-video"
|
|
106
|
+
autoPlay={autoPlay && shouldPlay}
|
|
107
|
+
muted={muted}
|
|
108
|
+
loop={loop}
|
|
109
|
+
playsInline={playsInline}
|
|
110
|
+
poster={poster}
|
|
111
|
+
preload={shouldLoad ? (preload ?? (loading === 'eager' ? 'auto' : 'metadata')) : 'none'}
|
|
112
|
+
style={{
|
|
113
|
+
gridArea: '1 / 1',
|
|
114
|
+
width: '100%',
|
|
115
|
+
height: '100%',
|
|
116
|
+
minHeight: 0,
|
|
117
|
+
objectFit: 'cover',
|
|
118
|
+
}}
|
|
119
|
+
>
|
|
120
|
+
{shouldLoad && webm ? <source src={webm} type="video/webm" /> : null}
|
|
121
|
+
{shouldLoad ? <source src={src} type="video/mp4" /> : null}
|
|
122
|
+
</video>
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
},
|
|
126
|
+
);
|
|
@@ -337,6 +337,7 @@ export function AdminChangeBookingFlow({
|
|
|
337
337
|
handleUpdateAdminCustomReceiptLine,
|
|
338
338
|
handleRemoveAdminCustomReceiptLine,
|
|
339
339
|
handleApplyAdminCustomReceiptLines: handleApplyAdminCustomReceiptLineDrafts,
|
|
340
|
+
handleSetAdminReceiptLineOverride,
|
|
340
341
|
} = useAdminCustomReceiptLines(
|
|
341
342
|
selectedChangeProductId,
|
|
342
343
|
latestChangeQuote?.reversibleAdjustments,
|
|
@@ -1299,6 +1300,7 @@ export function AdminChangeBookingFlow({
|
|
|
1299
1300
|
onUpdateAdminCustomReceiptLine: handleUpdateAdminCustomReceiptLine,
|
|
1300
1301
|
onRemoveAdminCustomReceiptLine: handleRemoveAdminCustomReceiptLine,
|
|
1301
1302
|
onApplyAdminCustomReceiptLines: handleApplyAdminCustomReceiptLines,
|
|
1303
|
+
onSetAdminReceiptLineOverride: handleSetAdminReceiptLineOverride,
|
|
1302
1304
|
onFirstNameChange: setFirstName, onLastNameChange: setLastName, onEmailChange: setEmail,
|
|
1303
1305
|
onClearError: () => setError(''),
|
|
1304
1306
|
pickupLocations: product.pickupLocations, destinations: product.destinations, highlightedPickupLocationIds,
|
|
@@ -80,6 +80,13 @@ export interface AdminChangeCheckoutPanelProps {
|
|
|
80
80
|
onUpdateAdminCustomReceiptLine: (id: string, patch: AdminCustomReceiptLinePatch) => void;
|
|
81
81
|
onRemoveAdminCustomReceiptLine: (id: string) => void;
|
|
82
82
|
onApplyAdminCustomReceiptLines: () => void;
|
|
83
|
+
onSetAdminReceiptLineOverride: (input: {
|
|
84
|
+
componentId: string;
|
|
85
|
+
lineKey: string;
|
|
86
|
+
label: string;
|
|
87
|
+
baseAmount: number;
|
|
88
|
+
targetAmount: number;
|
|
89
|
+
}) => void;
|
|
83
90
|
firstName: string;
|
|
84
91
|
lastName: string;
|
|
85
92
|
email: string;
|
|
@@ -131,7 +138,6 @@ export function AdminChangeCheckoutPanel({
|
|
|
131
138
|
changeFlowAmountDue,
|
|
132
139
|
receiptSubtotal,
|
|
133
140
|
receiptTax,
|
|
134
|
-
receiptTotal,
|
|
135
141
|
originalReceipt,
|
|
136
142
|
priceSummaryLinesIncludeTaxRow,
|
|
137
143
|
serverAmendmentLines,
|
|
@@ -172,6 +178,7 @@ export function AdminChangeCheckoutPanel({
|
|
|
172
178
|
onUpdateAdminCustomReceiptLine,
|
|
173
179
|
onRemoveAdminCustomReceiptLine,
|
|
174
180
|
onApplyAdminCustomReceiptLines,
|
|
181
|
+
onSetAdminReceiptLineOverride,
|
|
175
182
|
firstName,
|
|
176
183
|
lastName,
|
|
177
184
|
email,
|
|
@@ -232,7 +239,9 @@ export function AdminChangeCheckoutPanel({
|
|
|
232
239
|
showProviderPricingInlineEditor={showProviderPricingInlineEditor}
|
|
233
240
|
providerPricingUi={providerPricingUi}
|
|
234
241
|
showAdminCustomLineEditor={showAdminCustomLineEditor}
|
|
235
|
-
adminCustomReceiptLines={adminCustomReceiptLines
|
|
242
|
+
adminCustomReceiptLines={adminCustomReceiptLines.filter(
|
|
243
|
+
(line) => !line.overrideComponentId,
|
|
244
|
+
)}
|
|
236
245
|
hasPendingAdminCustomReceiptLineChanges={hasPendingAdminCustomReceiptLineChanges}
|
|
237
246
|
pendingAdminCustomReceiptLineIds={pendingAdminCustomReceiptLineIds}
|
|
238
247
|
adjustmentComponentOptions={adjustmentComponentOptions}
|
|
@@ -273,6 +282,8 @@ export function AdminChangeCheckoutPanel({
|
|
|
273
282
|
promoEditor={promoEditor}
|
|
274
283
|
promoCode={promoCode}
|
|
275
284
|
adjustments={pricingAdjustments}
|
|
285
|
+
adminCustomReceiptLines={adminCustomReceiptLines}
|
|
286
|
+
onSetReceiptLineOverride={onSetAdminReceiptLineOverride}
|
|
276
287
|
/>
|
|
277
288
|
) : null;
|
|
278
289
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { useState, type ReactNode } from 'react';
|
|
2
2
|
import type {
|
|
3
3
|
AdminAmendmentOperationSnapshot,
|
|
4
4
|
AdminAmendmentRefundDisposition,
|
|
@@ -10,6 +10,7 @@ import type { ChangeBookingFlowProps } from './booking-flow-types';
|
|
|
10
10
|
import type { Currency } from './CurrencySwitcher';
|
|
11
11
|
import { PriceSummary, type PriceSummaryLine } from './PriceSummary';
|
|
12
12
|
import { collapseAdminPromoLines } from './admin-change-receipt-lines';
|
|
13
|
+
import type { AdminCustomReceiptLine } from './useAdminCustomReceiptLines';
|
|
13
14
|
import {
|
|
14
15
|
noRefundRemovedValue,
|
|
15
16
|
refundDecisionsComplete,
|
|
@@ -63,6 +64,14 @@ export interface AdminChangeReceiptComparisonProps {
|
|
|
63
64
|
refundQuoteLoading?: boolean;
|
|
64
65
|
sourceProductName?: string | null;
|
|
65
66
|
targetProductName?: string | null;
|
|
67
|
+
adminCustomReceiptLines?: AdminCustomReceiptLine[];
|
|
68
|
+
onSetReceiptLineOverride?: (input: {
|
|
69
|
+
componentId: string;
|
|
70
|
+
lineKey: string;
|
|
71
|
+
label: string;
|
|
72
|
+
baseAmount: number;
|
|
73
|
+
targetAmount: number;
|
|
74
|
+
}) => void;
|
|
66
75
|
}
|
|
67
76
|
|
|
68
77
|
function normalizeLocale(locale: string): Locale {
|
|
@@ -117,7 +126,10 @@ export function AdminChangeReceiptComparison({
|
|
|
117
126
|
removedValueByOperationId = {},
|
|
118
127
|
refundDispositionsByOperationId = {},
|
|
119
128
|
refundQuoteLoading = false,
|
|
129
|
+
adminCustomReceiptLines = [],
|
|
130
|
+
onSetReceiptLineOverride,
|
|
120
131
|
}: AdminChangeReceiptComparisonProps) {
|
|
132
|
+
const [overrideDrafts, setOverrideDrafts] = useState<Record<string, string>>({});
|
|
121
133
|
const readableQuoteError = friendlyChangeBookingError(quoteError);
|
|
122
134
|
const displayLocale = normalizeLocale(locale);
|
|
123
135
|
const existingLines = originalLines(originalReceipt);
|
|
@@ -202,9 +214,99 @@ export function AdminChangeReceiptComparison({
|
|
|
202
214
|
existingLines,
|
|
203
215
|
familyConversionReceiptLines,
|
|
204
216
|
);
|
|
205
|
-
const
|
|
217
|
+
const rawSummaryDisplayLines = isProductFamilyConversion
|
|
206
218
|
? familyConversionReceiptLines
|
|
207
219
|
: amendmentDisplayLines;
|
|
220
|
+
const overrideByComponentId = new Map(
|
|
221
|
+
adminCustomReceiptLines.flatMap((line) =>
|
|
222
|
+
line.overrideComponentId ? [[line.overrideComponentId, line] as const] : [],
|
|
223
|
+
),
|
|
224
|
+
);
|
|
225
|
+
const allSummaryDisplayLines = rawSummaryDisplayLines
|
|
226
|
+
.filter((line) => !(line.kind === 'line' && line.label.startsWith('Override · ')))
|
|
227
|
+
.map((line) => {
|
|
228
|
+
const type = line.kind === 'ticket' ? 'TICKET' : String(line.type ?? '').toUpperCase();
|
|
229
|
+
const protectedType = ['TAX', 'PROMO_CODE', 'DISCOUNT', 'GIFT_CARD', 'PAYMENT_CREDIT', 'BOOKING_CHANGE']
|
|
230
|
+
.includes(type);
|
|
231
|
+
return {
|
|
232
|
+
...line,
|
|
233
|
+
editable: Boolean(
|
|
234
|
+
!protectedType && line.componentId && line.lineKey && onSetReceiptLineOverride,
|
|
235
|
+
),
|
|
236
|
+
};
|
|
237
|
+
});
|
|
238
|
+
const isRemovedSummaryLine = (line: PriceSummaryLine) => {
|
|
239
|
+
if (!line.editable || !line.componentId) return false;
|
|
240
|
+
const override = overrideByComponentId.get(line.componentId);
|
|
241
|
+
if (!override) return false;
|
|
242
|
+
const baseAmount = line.kind === 'ticket' ? line.itemTotal : line.amount;
|
|
243
|
+
const targetAmount = baseAmount + override.amountSign * Number(override.amountInput);
|
|
244
|
+
return Number.isFinite(targetAmount) && Math.abs(targetAmount) < 0.005;
|
|
245
|
+
};
|
|
246
|
+
const summaryDisplayLines = allSummaryDisplayLines.filter(
|
|
247
|
+
(line) => !isRemovedSummaryLine(line),
|
|
248
|
+
);
|
|
249
|
+
const removedSummaryDisplayLines = allSummaryDisplayLines.filter(isRemovedSummaryLine);
|
|
250
|
+
const quotedOverrideAmountInputs = Object.fromEntries(
|
|
251
|
+
summaryDisplayLines.flatMap((line) => {
|
|
252
|
+
if (!line.componentId || !line.lineKey) return [];
|
|
253
|
+
const baseAmount = line.kind === 'ticket' ? line.itemTotal : line.amount;
|
|
254
|
+
const override = overrideByComponentId.get(line.componentId);
|
|
255
|
+
const target = override
|
|
256
|
+
? baseAmount + (override.amountSign ?? 1) * Number(override.amountInput)
|
|
257
|
+
: baseAmount;
|
|
258
|
+
return [[line.lineKey, Number.isFinite(target) ? target.toFixed(2) : baseAmount.toFixed(2)]];
|
|
259
|
+
}),
|
|
260
|
+
);
|
|
261
|
+
const handleOverrideChange = (lineKey: string, value: string) => {
|
|
262
|
+
setOverrideDrafts((current) => ({ ...current, [lineKey]: value }));
|
|
263
|
+
};
|
|
264
|
+
const handleOverrideBlur = (lineKey: string) => {
|
|
265
|
+
const line = summaryDisplayLines.find((candidate) => candidate.lineKey === lineKey);
|
|
266
|
+
const targetAmount = Number(overrideDrafts[lineKey] ?? quotedOverrideAmountInputs[lineKey]);
|
|
267
|
+
if (!line?.componentId || !Number.isFinite(targetAmount)) return;
|
|
268
|
+
const baseAmount = line.kind === 'ticket' ? line.itemTotal : line.amount;
|
|
269
|
+
onSetReceiptLineOverride?.({
|
|
270
|
+
componentId: line.componentId,
|
|
271
|
+
lineKey,
|
|
272
|
+
label: line.kind === 'ticket' ? line.category : line.label,
|
|
273
|
+
baseAmount,
|
|
274
|
+
targetAmount,
|
|
275
|
+
});
|
|
276
|
+
setOverrideDrafts((current) => {
|
|
277
|
+
const next = { ...current };
|
|
278
|
+
delete next[lineKey];
|
|
279
|
+
return next;
|
|
280
|
+
});
|
|
281
|
+
};
|
|
282
|
+
const handleOverrideRemove = (lineKey: string) => {
|
|
283
|
+
const line = summaryDisplayLines.find((candidate) => candidate.lineKey === lineKey);
|
|
284
|
+
if (!line?.componentId) return;
|
|
285
|
+
const baseAmount = line.kind === 'ticket' ? line.itemTotal : line.amount;
|
|
286
|
+
onSetReceiptLineOverride?.({
|
|
287
|
+
componentId: line.componentId,
|
|
288
|
+
lineKey,
|
|
289
|
+
label: line.kind === 'ticket' ? line.category : line.label,
|
|
290
|
+
baseAmount,
|
|
291
|
+
targetAmount: 0,
|
|
292
|
+
});
|
|
293
|
+
setOverrideDrafts((current) => {
|
|
294
|
+
const next = { ...current };
|
|
295
|
+
delete next[lineKey];
|
|
296
|
+
return next;
|
|
297
|
+
});
|
|
298
|
+
};
|
|
299
|
+
const handleOverrideRestore = (line: PriceSummaryLine) => {
|
|
300
|
+
if (!line.componentId || !line.lineKey) return;
|
|
301
|
+
const baseAmount = line.kind === 'ticket' ? line.itemTotal : line.amount;
|
|
302
|
+
onSetReceiptLineOverride?.({
|
|
303
|
+
componentId: line.componentId,
|
|
304
|
+
lineKey: line.lineKey,
|
|
305
|
+
label: line.kind === 'ticket' ? line.category : line.label,
|
|
306
|
+
baseAmount,
|
|
307
|
+
targetAmount: baseAmount,
|
|
308
|
+
});
|
|
309
|
+
};
|
|
208
310
|
const summarySubtotal = isProductFamilyConversion
|
|
209
311
|
? familyConversionReceiptLines.reduce((sum, line) => {
|
|
210
312
|
if (line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX') return sum;
|
|
@@ -291,29 +393,55 @@ export function AdminChangeReceiptComparison({
|
|
|
291
393
|
<div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
|
|
292
394
|
<p className="text-sm font-medium text-stone-700">Loading price change…</p>
|
|
293
395
|
</div>
|
|
294
|
-
) : summaryDisplayLines.length === 0 && Math.abs(amountDue) < 0.005 && removedWithoutRefund > 0 ? (
|
|
396
|
+
) : summaryDisplayLines.length === 0 && removedSummaryDisplayLines.length === 0 && Math.abs(amountDue) < 0.005 && removedWithoutRefund > 0 ? (
|
|
295
397
|
<div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
|
|
296
398
|
<p className="text-sm font-medium text-stone-800">No refund will be created</p>
|
|
297
399
|
<p className="mt-1 text-xs text-stone-600">
|
|
298
400
|
{formatCurrencyAmount(removedWithoutRefund, currency, displayLocale)} of booking value will be removed without customer credit.
|
|
299
401
|
</p>
|
|
300
402
|
</div>
|
|
301
|
-
) : summaryDisplayLines.length === 0 && Math.abs(amountDue) < 0.005 ? (
|
|
403
|
+
) : summaryDisplayLines.length === 0 && removedSummaryDisplayLines.length === 0 && Math.abs(amountDue) < 0.005 ? (
|
|
302
404
|
<div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
|
|
303
405
|
<p className="text-sm font-medium text-stone-700">No pricing changes</p>
|
|
304
406
|
</div>
|
|
305
407
|
) : (
|
|
306
|
-
<
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
408
|
+
<div>
|
|
409
|
+
<PriceSummary
|
|
410
|
+
lines={summaryDisplayLines}
|
|
411
|
+
total={summaryTotal ?? amountDue}
|
|
412
|
+
totalLabel={summaryTotalLabel}
|
|
413
|
+
showPositiveTotalSign={!isProductFamilyConversion}
|
|
414
|
+
currency={currency}
|
|
415
|
+
locale={displayLocale}
|
|
416
|
+
subtotal={summarySubtotal}
|
|
417
|
+
size="sm"
|
|
418
|
+
t={t}
|
|
419
|
+
lineAmountInputs={{ ...quotedOverrideAmountInputs, ...overrideDrafts }}
|
|
420
|
+
onLineAmountInputChange={handleOverrideChange}
|
|
421
|
+
onLineAmountInputBlur={handleOverrideBlur}
|
|
422
|
+
onLineRemove={handleOverrideRemove}
|
|
423
|
+
/>
|
|
424
|
+
{removedSummaryDisplayLines.length > 0 ? (
|
|
425
|
+
<div className="mt-3 border-t border-stone-200 pt-2 text-xs text-stone-500">
|
|
426
|
+
<span className="font-medium text-stone-600">Removed lines:</span>{' '}
|
|
427
|
+
{removedSummaryDisplayLines.map((line, index) => {
|
|
428
|
+
const label = line.kind === 'ticket' ? line.category : line.label;
|
|
429
|
+
return (
|
|
430
|
+
<span key={line.lineKey ?? `${label}-${index}`}>
|
|
431
|
+
{index > 0 ? ', ' : ''}{label}{' '}
|
|
432
|
+
<button
|
|
433
|
+
type="button"
|
|
434
|
+
className="font-medium text-stone-700 underline underline-offset-2 hover:text-stone-950"
|
|
435
|
+
onClick={() => handleOverrideRestore(line)}
|
|
436
|
+
>
|
|
437
|
+
Undo
|
|
438
|
+
</button>
|
|
439
|
+
</span>
|
|
440
|
+
);
|
|
441
|
+
})}
|
|
442
|
+
</div>
|
|
443
|
+
) : null}
|
|
444
|
+
</div>
|
|
317
445
|
)}
|
|
318
446
|
</section>
|
|
319
447
|
</div>
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
min-height: 100% !important;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
/* Constrain
|
|
71
|
+
/* Constrain the shared background video output. */
|
|
72
72
|
.videoSlot :global(video),
|
|
73
73
|
.videoSlot :global(.next-video-bg-video),
|
|
74
74
|
.videoSlot :global(.next-video-bg-poster),
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
'use client';
|
|
3
3
|
|
|
4
4
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
5
|
-
import
|
|
5
|
+
import { BackgroundVideo } from '../BackgroundVideo';
|
|
6
6
|
import type { VideoSources } from '../../constants/products';
|
|
7
7
|
import { resolvePublicAssetUrl, useBookingHost } from '../../runtime';
|
|
8
8
|
import { useTranslations } from '../../lib/booking/i18n';
|
|
@@ -100,9 +100,11 @@ export function BookingFlowCollage({
|
|
|
100
100
|
<div className={`${styles.collage} ${!hasGridImages ? styles.videoOnly : ''}`}>
|
|
101
101
|
<div className={styles.videoSlot}>
|
|
102
102
|
<div className={styles.videoWrapper}>
|
|
103
|
-
<
|
|
103
|
+
<BackgroundVideo
|
|
104
104
|
ref={videoRef}
|
|
105
105
|
{...videoForCollage}
|
|
106
|
+
loading="eager"
|
|
107
|
+
preload="auto"
|
|
106
108
|
autoPlay
|
|
107
109
|
muted
|
|
108
110
|
loop
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
import { resolvePublicAssetUrl, useBookingHost } from '../../runtime';
|
|
11
11
|
import { useLocale, useTranslations } from '../../lib/booking/i18n';
|
|
12
12
|
import type { Product } from '../../constants/products';
|
|
13
|
-
import
|
|
13
|
+
import { BackgroundVideo } from '../BackgroundVideo';
|
|
14
14
|
import styles from './BookingProductGrid.module.css';
|
|
15
15
|
|
|
16
16
|
/** Locale strings needed by the product grid tiles (Via Via host). */
|
|
@@ -48,7 +48,7 @@ const FILTER_PRODUCT_IDS: Record<string, string[]> = {
|
|
|
48
48
|
export const FILTER_IDS = ['all', 'sunrise', 'moraine-lake', 'lake-louise', 'emerald-lake', 'private'] as const;
|
|
49
49
|
export type FilterId = (typeof FILTER_IDS)[number];
|
|
50
50
|
|
|
51
|
-
/** Only `src` / `webm` — never spread catalog
|
|
51
|
+
/** Only `src` / `webm` — never spread catalog long-source fields onto the DOM. */
|
|
52
52
|
function backgroundPlayerSources(
|
|
53
53
|
product: Product,
|
|
54
54
|
staticAssetOrigin: string,
|
|
@@ -220,8 +220,10 @@ function BookingProductTileExpanded({
|
|
|
220
220
|
<path d="M10 6l8 6-8 6" />
|
|
221
221
|
</motion.svg>
|
|
222
222
|
</button>
|
|
223
|
-
<
|
|
223
|
+
<BackgroundVideo
|
|
224
224
|
{...backgroundPlayerSources(product, env.STATIC_ASSET_ORIGIN)}
|
|
225
|
+
loading="eager"
|
|
226
|
+
preload="auto"
|
|
225
227
|
autoPlay
|
|
226
228
|
muted
|
|
227
229
|
loop
|
|
@@ -28,6 +28,7 @@ export interface PriceBreakdownProps {
|
|
|
28
28
|
onEditableChange?: (value: string) => void;
|
|
29
29
|
onEditableBlur?: () => void;
|
|
30
30
|
onEditableReset?: () => void;
|
|
31
|
+
onEditableRemove?: () => void;
|
|
31
32
|
editableLabelValue?: string;
|
|
32
33
|
onEditableLabelChange?: (value: string) => void;
|
|
33
34
|
}
|
|
@@ -76,6 +77,7 @@ export function PriceBreakdown({
|
|
|
76
77
|
onEditableChange,
|
|
77
78
|
onEditableBlur,
|
|
78
79
|
onEditableReset,
|
|
80
|
+
onEditableRemove,
|
|
79
81
|
editableLabelValue,
|
|
80
82
|
onEditableLabelChange,
|
|
81
83
|
}: PriceBreakdownProps) {
|
|
@@ -129,6 +131,17 @@ export function PriceBreakdown({
|
|
|
129
131
|
onChange={(e) => onEditableChange(e.target.value)}
|
|
130
132
|
onBlur={onEditableBlur}
|
|
131
133
|
/>
|
|
134
|
+
{onEditableRemove ? (
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
className="rounded p-0.5 text-stone-400 hover:bg-red-50 hover:text-red-600"
|
|
138
|
+
onClick={onEditableRemove}
|
|
139
|
+
aria-label={`Remove ${category} line`}
|
|
140
|
+
title="Remove line"
|
|
141
|
+
>
|
|
142
|
+
<span className="inline-block h-4 w-4 text-center text-base leading-4">×</span>
|
|
143
|
+
</button>
|
|
144
|
+
) : null}
|
|
132
145
|
</div>
|
|
133
146
|
) : (
|
|
134
147
|
<span className="text-sm font-medium text-stone-700">
|
|
@@ -191,6 +204,17 @@ export function PriceBreakdown({
|
|
|
191
204
|
onChange={(e) => onEditableChange(e.target.value)}
|
|
192
205
|
onBlur={onEditableBlur}
|
|
193
206
|
/>
|
|
207
|
+
{onEditableRemove ? (
|
|
208
|
+
<button
|
|
209
|
+
type="button"
|
|
210
|
+
className="rounded p-0.5 text-stone-400 hover:bg-red-50 hover:text-red-600"
|
|
211
|
+
onClick={onEditableRemove}
|
|
212
|
+
aria-label={`Remove ${category} line`}
|
|
213
|
+
title="Remove line"
|
|
214
|
+
>
|
|
215
|
+
<span className="inline-block h-4 w-4 text-center text-base leading-4">×</span>
|
|
216
|
+
</button>
|
|
217
|
+
) : null}
|
|
194
218
|
</div>
|
|
195
219
|
) : (
|
|
196
220
|
<span
|
|
@@ -16,6 +16,7 @@ export type PriceSummaryLine =
|
|
|
16
16
|
lineKey?: string;
|
|
17
17
|
/** Pricing V2 amendment operation that produced this row, when applicable. */
|
|
18
18
|
operationId?: string;
|
|
19
|
+
componentId?: string;
|
|
19
20
|
editable?: boolean;
|
|
20
21
|
category: string;
|
|
21
22
|
qty: number;
|
|
@@ -34,6 +35,7 @@ export type PriceSummaryLine =
|
|
|
34
35
|
| {
|
|
35
36
|
kind: 'line';
|
|
36
37
|
lineKey?: string;
|
|
38
|
+
componentId?: string;
|
|
37
39
|
label: string;
|
|
38
40
|
amount: number;
|
|
39
41
|
/** Drives styling: discount (red, -), return add-on (green, +), default (stone). Receipt types: TICKET, FEE, RETURN_OPTION, PROMO_CODE, CANCELLATION_UPGRADE, TAX, etc. */
|
|
@@ -96,6 +98,8 @@ export interface PriceSummaryProps {
|
|
|
96
98
|
onLineAmountInputBlur?: (lineKey: string) => void;
|
|
97
99
|
/** Optional reset handler for inline editable line amounts. */
|
|
98
100
|
onLineAmountReset?: (lineKey: string) => void;
|
|
101
|
+
/** Optional remove handler for inline editable lines. */
|
|
102
|
+
onLineRemove?: (lineKey: string) => void;
|
|
99
103
|
}
|
|
100
104
|
|
|
101
105
|
function getLineAmountClass(type: string | undefined, amount: number): string {
|
|
@@ -172,7 +176,6 @@ export function PriceSummary({
|
|
|
172
176
|
locale,
|
|
173
177
|
subtotal,
|
|
174
178
|
taxAmount = 0,
|
|
175
|
-
taxRate: _taxRate,
|
|
176
179
|
discountAmount = 0,
|
|
177
180
|
discountLabel,
|
|
178
181
|
size = 'sm',
|
|
@@ -192,6 +195,7 @@ export function PriceSummary({
|
|
|
192
195
|
onLineLabelInputChange,
|
|
193
196
|
onLineAmountInputBlur,
|
|
194
197
|
onLineAmountReset,
|
|
198
|
+
onLineRemove,
|
|
195
199
|
}: PriceSummaryProps) {
|
|
196
200
|
const textSize = size === 'sm' ? 'text-sm' : 'text-base';
|
|
197
201
|
const totalSize = size === 'sm' ? 'text-xl' : 'text-2xl';
|
|
@@ -245,6 +249,11 @@ export function PriceSummary({
|
|
|
245
249
|
? () => onLineAmountReset(row.lineKey!)
|
|
246
250
|
: undefined
|
|
247
251
|
}
|
|
252
|
+
onEditableRemove={
|
|
253
|
+
row.editable && isBeforeSubtotalBoundary && row.lineKey && onLineRemove
|
|
254
|
+
? () => onLineRemove(row.lineKey!)
|
|
255
|
+
: undefined
|
|
256
|
+
}
|
|
248
257
|
editableLabelValue={row.lineKey ? lineLabelInputs?.[row.lineKey] : undefined}
|
|
249
258
|
onEditableLabelChange={
|
|
250
259
|
row.editable && isBeforeSubtotalBoundary && row.lineKey && onLineLabelInputChange
|
|
@@ -311,6 +320,17 @@ export function PriceSummary({
|
|
|
311
320
|
onChange={(e) => onLineAmountInputChange(lineKey, e.target.value)}
|
|
312
321
|
onBlur={() => onLineAmountInputBlur?.(lineKey)}
|
|
313
322
|
/>
|
|
323
|
+
{isBeforeSubtotalBoundary && onLineRemove ? (
|
|
324
|
+
<button
|
|
325
|
+
type="button"
|
|
326
|
+
className="rounded p-0.5 text-stone-400 hover:bg-red-50 hover:text-red-600"
|
|
327
|
+
onClick={() => onLineRemove(lineKey)}
|
|
328
|
+
aria-label={`Remove ${label} line`}
|
|
329
|
+
title="Remove line"
|
|
330
|
+
>
|
|
331
|
+
<span className="inline-block h-4 w-4 text-center text-base leading-4">×</span>
|
|
332
|
+
</button>
|
|
333
|
+
) : null}
|
|
314
334
|
</div>
|
|
315
335
|
) : (
|
|
316
336
|
<span className={`flex-shrink-0 whitespace-nowrap font-medium ${getLineAmountClass(type, amount)}`}>
|
|
@@ -19,6 +19,9 @@ export type AdminCustomReceiptLine = {
|
|
|
19
19
|
sourceAdjustmentId?: string;
|
|
20
20
|
replacementAdjustmentId?: string;
|
|
21
21
|
isDirty?: boolean;
|
|
22
|
+
overrideComponentId?: string;
|
|
23
|
+
overrideBaseAmount?: number;
|
|
24
|
+
overrideLineKey?: string;
|
|
22
25
|
};
|
|
23
26
|
|
|
24
27
|
type AdminCustomReceiptLinePatch = Partial<
|
|
@@ -40,6 +43,10 @@ export function lineFromActiveAdjustment(
|
|
|
40
43
|
replacementAdjustmentId: string,
|
|
41
44
|
): AdminCustomReceiptLine {
|
|
42
45
|
const percentage = adjustment.mode !== 'FIXED_AMOUNT';
|
|
46
|
+
const overrideComponentId =
|
|
47
|
+
adjustment.reasonCode === 'LINE_ITEM_OVERRIDE' && adjustment.affectedComponentIds.length === 1
|
|
48
|
+
? adjustment.affectedComponentIds[0]
|
|
49
|
+
: undefined;
|
|
43
50
|
return {
|
|
44
51
|
id: `existing:${adjustment.adjustmentId}`,
|
|
45
52
|
label:
|
|
@@ -59,6 +66,7 @@ export function lineFromActiveAdjustment(
|
|
|
59
66
|
sourceAdjustmentId: adjustment.adjustmentId,
|
|
60
67
|
replacementAdjustmentId,
|
|
61
68
|
isDirty: false,
|
|
69
|
+
overrideComponentId,
|
|
62
70
|
};
|
|
63
71
|
}
|
|
64
72
|
|
|
@@ -146,6 +154,56 @@ export function useAdminCustomReceiptLines(
|
|
|
146
154
|
setAppliedRemovedExistingAdjustmentIds(removedExistingAdjustmentIds);
|
|
147
155
|
}, [adminCustomReceiptLines, removedExistingAdjustmentIds]);
|
|
148
156
|
|
|
157
|
+
const handleSetAdminReceiptLineOverride = useCallback((input: {
|
|
158
|
+
componentId: string;
|
|
159
|
+
lineKey: string;
|
|
160
|
+
label: string;
|
|
161
|
+
baseAmount: number;
|
|
162
|
+
targetAmount: number;
|
|
163
|
+
}) => {
|
|
164
|
+
const delta = Math.round((input.targetAmount - input.baseAmount) * 100) / 100;
|
|
165
|
+
const existing = adminCustomReceiptLines.find(
|
|
166
|
+
(line) => line.overrideComponentId === input.componentId,
|
|
167
|
+
);
|
|
168
|
+
const sourceAdjustmentId = existing?.sourceAdjustmentId;
|
|
169
|
+
const restoringQuotedLine = Math.abs(delta) < 0.005;
|
|
170
|
+
const next = Math.abs(delta) < 0.005
|
|
171
|
+
? adminCustomReceiptLines.filter((line) => line.id !== existing?.id)
|
|
172
|
+
: [
|
|
173
|
+
...adminCustomReceiptLines.filter((line) => line.id !== existing?.id),
|
|
174
|
+
{
|
|
175
|
+
id: existing?.id ?? nextDraftId('admin-rcpt'),
|
|
176
|
+
label: `Override · ${input.label}`,
|
|
177
|
+
amountInput: Math.abs(delta).toFixed(2),
|
|
178
|
+
amountSign: (delta < 0 ? -1 : 1) as 1 | -1,
|
|
179
|
+
calculation: 'FIXED_AMOUNT' as const,
|
|
180
|
+
scope: 'SELECTED_COMPONENTS' as const,
|
|
181
|
+
selectedComponentIds: [input.componentId],
|
|
182
|
+
taxBehavior: delta < 0 ? 'PRE_TAX_DISCOUNT' as const : 'TAXABLE' as const,
|
|
183
|
+
reasonCode: 'LINE_ITEM_OVERRIDE',
|
|
184
|
+
overrideComponentId: input.componentId,
|
|
185
|
+
overrideBaseAmount: input.baseAmount,
|
|
186
|
+
overrideLineKey: input.lineKey,
|
|
187
|
+
...(existing?.sourceAdjustmentId
|
|
188
|
+
? {
|
|
189
|
+
sourceAdjustmentId: existing.sourceAdjustmentId,
|
|
190
|
+
replacementAdjustmentId: existing.replacementAdjustmentId,
|
|
191
|
+
isDirty: true,
|
|
192
|
+
}
|
|
193
|
+
: {}),
|
|
194
|
+
},
|
|
195
|
+
];
|
|
196
|
+
if (sourceAdjustmentId) {
|
|
197
|
+
const updateRemovedIds = (current: string[]) => restoringQuotedLine
|
|
198
|
+
? Array.from(new Set([...current, sourceAdjustmentId]))
|
|
199
|
+
: current.filter((id) => id !== sourceAdjustmentId);
|
|
200
|
+
setRemovedExistingAdjustmentIds(updateRemovedIds);
|
|
201
|
+
setAppliedRemovedExistingAdjustmentIds(updateRemovedIds);
|
|
202
|
+
}
|
|
203
|
+
setAdminCustomReceiptLines(next);
|
|
204
|
+
setAppliedAdminCustomReceiptLines(next);
|
|
205
|
+
}, [adminCustomReceiptLines, nextDraftId]);
|
|
206
|
+
|
|
149
207
|
useEffect(() => {
|
|
150
208
|
if (previousResetKeyRef.current === resetKey) return;
|
|
151
209
|
previousResetKeyRef.current = resetKey;
|
|
@@ -225,5 +283,6 @@ export function useAdminCustomReceiptLines(
|
|
|
225
283
|
handleUpdateAdminCustomReceiptLine,
|
|
226
284
|
handleRemoveAdminCustomReceiptLine,
|
|
227
285
|
handleApplyAdminCustomReceiptLines,
|
|
286
|
+
handleSetAdminReceiptLineOverride,
|
|
228
287
|
};
|
|
229
288
|
}
|
package/src/index.ts
CHANGED
|
@@ -62,6 +62,8 @@ export type {
|
|
|
62
62
|
export { formatBookingRefForDisplay } from './lib/format-booking-ref';
|
|
63
63
|
export { default as BookingProductGrid } from './components/booking/BookingProductGrid';
|
|
64
64
|
export { DefaultTermsContent as TermsContent } from './components/booking/DefaultTermsContent';
|
|
65
|
+
export { BackgroundVideo } from './components/BackgroundVideo';
|
|
66
|
+
export type { BackgroundVideoLoading, BackgroundVideoProps } from './components/BackgroundVideo';
|
|
65
67
|
|
|
66
68
|
export type {
|
|
67
69
|
BookingAppMode,
|
|
@@ -212,7 +212,9 @@ export function mapQuoteLineItemsToPriceSummaryLines(
|
|
|
212
212
|
: type;
|
|
213
213
|
out.push({
|
|
214
214
|
kind: 'ticket',
|
|
215
|
+
lineKey: isPricingV2Line(item) ? item.lineId ?? undefined : undefined,
|
|
215
216
|
operationId: isPricingV2Line(item) ? item.metadata?.operationId : undefined,
|
|
217
|
+
componentId: isPricingV2Line(item) ? item.metadata?.componentId : undefined,
|
|
216
218
|
category,
|
|
217
219
|
qty: qty > 0 ? qty : 1,
|
|
218
220
|
itemTotal: amount,
|
|
@@ -234,7 +236,9 @@ export function mapQuoteLineItemsToPriceSummaryLines(
|
|
|
234
236
|
) {
|
|
235
237
|
out.push({
|
|
236
238
|
kind: 'ticket',
|
|
239
|
+
lineKey: isPricingV2Line(item) ? item.lineId ?? undefined : undefined,
|
|
237
240
|
operationId: isPricingV2Line(item) ? item.metadata?.operationId : undefined,
|
|
241
|
+
componentId: isPricingV2Line(item) ? item.metadata?.componentId : undefined,
|
|
238
242
|
category: lettersOnly,
|
|
239
243
|
qty,
|
|
240
244
|
itemTotal: amount,
|
|
@@ -247,6 +251,8 @@ export function mapQuoteLineItemsToPriceSummaryLines(
|
|
|
247
251
|
/** Align with checkout drift keys (`ChangeBookingFlow` uses `type: 'return'`). */
|
|
248
252
|
out.push({
|
|
249
253
|
kind: 'line',
|
|
254
|
+
lineKey: isPricingV2Line(item) ? item.lineId ?? undefined : undefined,
|
|
255
|
+
componentId: isPricingV2Line(item) ? item.metadata?.componentId : undefined,
|
|
250
256
|
label: label || type || 'Line',
|
|
251
257
|
amount,
|
|
252
258
|
type,
|
package/src/strings/en.json
CHANGED
|
@@ -240,7 +240,7 @@
|
|
|
240
240
|
"home": {
|
|
241
241
|
"hero": {
|
|
242
242
|
"tagline": "Tours to Moraine Lake, Lake Louise, Emerald Lake & more for families, friends, and unforgettable days",
|
|
243
|
-
"googleReviewsCount": "
|
|
243
|
+
"googleReviewsCount": "2500+ reviews",
|
|
244
244
|
"centerCallout": "now open ✨"
|
|
245
245
|
},
|
|
246
246
|
"seasonClosure": {
|
package/src/strings/es.json
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"home": {
|
|
40
40
|
"hero": {
|
|
41
41
|
"tagline": "Descubre Moraine Lake, Lake Louise, y mucho más. Excursiones inolvidables para todos los públicos.",
|
|
42
|
-
"googleReviewsCount": "Mas de
|
|
42
|
+
"googleReviewsCount": "Mas de 2500 opioniones",
|
|
43
43
|
"centerCallout": "ahora abierto ✨"
|
|
44
44
|
},
|
|
45
45
|
"seasonClosure": {
|
package/src/strings/fr.json
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"home": {
|
|
40
40
|
"hero": {
|
|
41
41
|
"tagline": "Partez à la découverte du lac Moraine, du lac Louise, du lac Émeraude et plus encore ! Excursions en famille ou entre amis, pour vivre une expérience inoubliable",
|
|
42
|
-
"googleReviewsCount": "
|
|
42
|
+
"googleReviewsCount": "2500+ avis",
|
|
43
43
|
"centerCallout": "maintenant ouvert ✨"
|
|
44
44
|
},
|
|
45
45
|
"seasonClosure": {
|
|
@@ -234,6 +234,31 @@ test('existing admin adjustment hydrates for immutable reverse-and-replace editi
|
|
|
234
234
|
assert.deepEqual(reverseAdjustmentIdsForAdminCustomLines([], ['admin-rcpt-1']), ['admin-rcpt-1']);
|
|
235
235
|
});
|
|
236
236
|
|
|
237
|
+
test('saved line-item override hydrates its targeted receipt component', () => {
|
|
238
|
+
const line = lineFromActiveAdjustment({
|
|
239
|
+
adjustmentId: 'override-adult-1',
|
|
240
|
+
operationId: 'op-override-adult-1',
|
|
241
|
+
mode: 'FIXED_AMOUNT',
|
|
242
|
+
scope: 'SELECTED_COMPONENTS',
|
|
243
|
+
basisCents: 19_799,
|
|
244
|
+
amountCents: 19_799,
|
|
245
|
+
taxDeltaCents: 990,
|
|
246
|
+
affectedComponentIds: ['adult-component'],
|
|
247
|
+
taxBehavior: 'TAXABLE',
|
|
248
|
+
reasonCode: 'LINE_ITEM_OVERRIDE',
|
|
249
|
+
reason: 'Override · Refund · ADULT',
|
|
250
|
+
createdAt: '2026-08-09T00:00:00Z',
|
|
251
|
+
}, 'replacement-override-adult-1');
|
|
252
|
+
|
|
253
|
+
assert.equal(line.overrideComponentId, 'adult-component');
|
|
254
|
+
assert.deepEqual(line.selectedComponentIds, ['adult-component']);
|
|
255
|
+
assert.equal(line.sourceAdjustmentId, 'override-adult-1');
|
|
256
|
+
assert.deepEqual(
|
|
257
|
+
reverseAdjustmentIdsForAdminCustomLines([{ ...line, isDirty: true }]),
|
|
258
|
+
['override-adult-1'],
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
|
|
237
262
|
test('private resource quantity is not carried into a regular-tour conversion', () => {
|
|
238
263
|
const quantities = deriveCompatibleInitialBookingQuantities(
|
|
239
264
|
[{ category: 'RESOURCE', count: 1 }],
|
|
@@ -314,6 +339,28 @@ test('legacy booking-change comparison rows are omitted from the purchase breakd
|
|
|
314
339
|
);
|
|
315
340
|
});
|
|
316
341
|
|
|
342
|
+
test('Pricing V2 amendment rows retain line and component identity for admin overrides', () => {
|
|
343
|
+
const [line] = mapQuoteLineItemsToPriceSummaryLines([
|
|
344
|
+
{
|
|
345
|
+
lineId: 'component_adult:amendment:gross',
|
|
346
|
+
type: 'TICKET',
|
|
347
|
+
label: 'Refund · ADULT',
|
|
348
|
+
amount: -197.99,
|
|
349
|
+
quantity: 9,
|
|
350
|
+
billable: true,
|
|
351
|
+
metadata: {
|
|
352
|
+
category: 'ADULT',
|
|
353
|
+
operationId: 'op_move_adult',
|
|
354
|
+
componentId: 'component_adult',
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
]);
|
|
358
|
+
|
|
359
|
+
assert.equal(line?.lineKey, 'component_adult:amendment:gross');
|
|
360
|
+
assert.equal(line?.componentId, 'component_adult');
|
|
361
|
+
assert.equal(line?.kind === 'ticket' ? line.operationId : null, 'op_move_adult');
|
|
362
|
+
});
|
|
363
|
+
|
|
317
364
|
test('server preview exposes the resulting receipt separately from ledger allocation lines', () => {
|
|
318
365
|
const preview = buildChangeBookingServerPreview(
|
|
319
366
|
quote({
|