@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ticketboothapp/booking",
3
- "version": "1.2.184",
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
+ );
@@ -196,6 +196,7 @@ const checkoutPanelPropKeys = [
196
196
  'onUpdateAdminCustomReceiptLine',
197
197
  'onRemoveAdminCustomReceiptLine',
198
198
  'onApplyAdminCustomReceiptLines',
199
+ 'onSetAdminReceiptLineOverride',
199
200
  'firstName',
200
201
  'lastName',
201
202
  'email',
@@ -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 { ReactNode } from 'react';
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 summaryDisplayLines = isProductFamilyConversion
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
- <PriceSummary
307
- lines={summaryDisplayLines}
308
- total={summaryTotal ?? amountDue}
309
- totalLabel={summaryTotalLabel}
310
- showPositiveTotalSign={!isProductFamilyConversion}
311
- currency={currency}
312
- locale={displayLocale}
313
- subtotal={summarySubtotal}
314
- size="sm"
315
- t={t}
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 next-video BackgroundPlayer output */
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 BackgroundPlayer from 'next-video/background-player';
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
- <BackgroundPlayer
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 BackgroundPlayer from 'next-video/background-player';
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 `VideoSources` onto BackgroundPlayer (`longSrc`/`longWebm` are not valid DOM props). */
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
- <BackgroundPlayer
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,
@@ -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": "1500+ reviews",
243
+ "googleReviewsCount": "2500+ reviews",
244
244
  "centerCallout": "now open ✨"
245
245
  },
246
246
  "seasonClosure": {
@@ -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 1500 opioniones",
42
+ "googleReviewsCount": "Mas de 2500 opioniones",
43
43
  "centerCallout": "ahora abierto ✨"
44
44
  },
45
45
  "seasonClosure": {
@@ -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": "1500+ avis",
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({