@ticketboothapp/booking 1.2.177 → 1.2.179

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.
Files changed (25) hide show
  1. package/package.json +1 -1
  2. package/src/components/booking/AddOnsSection.module.css +36 -5
  3. package/src/components/booking/AddOnsSection.tsx +250 -144
  4. package/src/components/booking/AdminChangeBookingFlow.tsx +3 -2
  5. package/src/components/booking/CollapsibleAddOnItem.module.css +72 -0
  6. package/src/components/booking/CollapsibleAddOnItem.tsx +38 -0
  7. package/src/components/booking/MealDrinkAddOnSelector.tsx +70 -29
  8. package/src/components/booking/PrivateShuttleAddOnsSection.tsx +124 -57
  9. package/src/components/booking/PrivateShuttleBookingFlow.module.css +26 -0
  10. package/src/components/booking/PrivateShuttleBookingFlow.tsx +8 -5
  11. package/src/components/booking/PrivateShuttlePreferencesSection.tsx +29 -55
  12. package/src/components/booking/add-on-selection-helpers.ts +13 -0
  13. package/src/components/booking/change-booking-checkout-builders.ts +1 -1
  14. package/src/components/booking/private-shuttle-checkout-builders.ts +2 -2
  15. package/src/components/booking/private-shuttle-lunch-visibility.ts +20 -0
  16. package/src/components/booking/standard-booking-checkout-builders.ts +1 -1
  17. package/src/components/booking/useAdminChangeProtectedPricing.ts +1 -1
  18. package/src/components/booking/useBookingAvailabilityAddOns.ts +26 -4
  19. package/src/components/booking/useChangeBookingProtectedPricing.ts +1 -1
  20. package/src/components/booking/usePrivateShuttlePriceSummary.ts +13 -6
  21. package/src/components/booking/useStandardBookingPriceSummary.ts +1 -1
  22. package/src/lib/booking-api.ts +4 -1
  23. package/test/add-on-selection-helpers.test.ts +24 -0
  24. package/test/change-booking-helpers.test.ts +42 -1
  25. package/test/private-shuttle-lunch-visibility.test.ts +27 -0
@@ -0,0 +1,20 @@
1
+ export const EMERALD_LAKE_LUNCH_ADD_ON_ID = 'addon_el_lunch';
2
+ export const WILD_FLOUR_LUNCH_ADD_ON_ID = 'addon_wild_flour_bakery_lunch_package';
3
+
4
+ const EMERALD_LAKE_DESTINATION_ID = 'emerald_lake';
5
+
6
+ export function privateShuttleLunchAddOnIsVisible(
7
+ addOnId: string,
8
+ selectedDestinationIds: string[],
9
+ ): boolean {
10
+ const includesEmeraldLake = selectedDestinationIds.includes(EMERALD_LAKE_DESTINATION_ID);
11
+ if (addOnId === EMERALD_LAKE_LUNCH_ADD_ON_ID) return includesEmeraldLake;
12
+ if (addOnId === WILD_FLOUR_LUNCH_ADD_ON_ID) return !includesEmeraldLake;
13
+ return true;
14
+ }
15
+
16
+ export function privateShuttleHiddenLunchAddOnId(selectedDestinationIds: string[]): string {
17
+ return selectedDestinationIds.includes(EMERALD_LAKE_DESTINATION_ID)
18
+ ? WILD_FLOUR_LUNCH_ADD_ON_ID
19
+ : EMERALD_LAKE_LUNCH_ADD_ON_ID;
20
+ }
@@ -104,7 +104,7 @@ function buildStandardBookingAddOnLineItems(
104
104
  const amount = ((addOn.price ?? 0) + (variant?.priceAdjustment ?? 0)) * quantity;
105
105
  const label = variant?.label
106
106
  ? `${addOn.name} (${variant.label})${quantity > 1 ? ` \u00d7 ${quantity}` : ''}`
107
- : addOn.name;
107
+ : `${addOn.name}${quantity > 1 ? ` \u00d7 ${quantity}` : ''}`;
108
108
 
109
109
  checkoutLines.push({
110
110
  label,
@@ -283,7 +283,7 @@ export function useAdminChangeProtectedPricing({
283
283
  : null;
284
284
  const name = variantLabel
285
285
  ? `${addOn.name} (${variantLabel})${qty > 1 ? ` \u00d7 ${qty}` : ''}`
286
- : addOn.name;
286
+ : `${addOn.name}${qty > 1 ? ` \u00d7 ${qty}` : ''}`;
287
287
  return { name, totalAmount: amt, description: addOn.description ?? undefined };
288
288
  })
289
289
  .filter((x): x is NonNullable<typeof x> => x != null);
@@ -1,15 +1,19 @@
1
1
  import { useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react';
2
+ import { formatInTimeZone } from 'date-fns-tz';
2
3
  import {
3
4
  getAddOns,
4
5
  type AddOn,
5
6
  type Availability,
6
7
  } from '../../lib/booking-api';
7
- import { getAvailabilityOptionId } from './change-booking-flow-helpers';
8
+ import { getAvailabilityOptionId, parseAvailabilityDateTime } from './change-booking-flow-helpers';
8
9
 
9
10
  export interface UseBookingAvailabilityAddOnsParams {
10
11
  selectedAvailability: Availability | null;
11
12
  companyId?: string | null;
12
13
  onProductOptionChange?: () => void;
14
+ /** Ignore lead-time cutoffs for admin changes while continuing to honor blackout dates. */
15
+ ignoreCutoff?: boolean;
16
+ companyTimezone?: string;
13
17
  }
14
18
 
15
19
  export interface UseBookingAvailabilityAddOnsResult {
@@ -23,6 +27,8 @@ export function useBookingAvailabilityAddOns({
23
27
  selectedAvailability,
24
28
  companyId,
25
29
  onProductOptionChange,
30
+ ignoreCutoff = false,
31
+ companyTimezone,
26
32
  }: UseBookingAvailabilityAddOnsParams): UseBookingAvailabilityAddOnsResult {
27
33
  const [addOns, setAddOns] = useState<AddOn[]>([]);
28
34
  const [loadedProductOptionId, setLoadedProductOptionId] = useState<string | null>(null);
@@ -50,11 +56,20 @@ export function useBookingAvailabilityAddOns({
50
56
  getAddOns(companyId, {
51
57
  productOptionId: availabilityProductOptionId,
52
58
  preCheckout: true,
53
- dateTime: availabilityDateTime ?? undefined,
59
+ dateTime: ignoreCutoff ? undefined : (availabilityDateTime ?? undefined),
54
60
  })
55
61
  .then((rows) => {
56
62
  if (cancelled) return;
57
- setAddOns(rows);
63
+ let visibleRows = rows;
64
+ if (ignoreCutoff && availabilityDateTime && companyTimezone) {
65
+ const localDate = formatInTimeZone(
66
+ parseAvailabilityDateTime(availabilityDateTime),
67
+ companyTimezone,
68
+ 'yyyy-MM-dd',
69
+ );
70
+ visibleRows = rows.filter((addOn) => !addOn.blackoutDates?.includes(localDate));
71
+ }
72
+ setAddOns(visibleRows);
58
73
  setLoadedProductOptionId(availabilityProductOptionId);
59
74
  })
60
75
  .catch(() => {
@@ -65,7 +80,14 @@ export function useBookingAvailabilityAddOns({
65
80
  return () => {
66
81
  cancelled = true;
67
82
  };
68
- }, [availabilityProductOptionId, availabilityDateTime, companyId, onProductOptionChange]);
83
+ }, [
84
+ availabilityProductOptionId,
85
+ availabilityDateTime,
86
+ companyTimezone,
87
+ companyId,
88
+ ignoreCutoff,
89
+ onProductOptionChange,
90
+ ]);
69
91
 
70
92
  return {
71
93
  addOns,
@@ -252,7 +252,7 @@ export function useChangeBookingProtectedPricing({
252
252
  : null;
253
253
  const name = variantLabel
254
254
  ? `${addOn.name} (${variantLabel})${qty > 1 ? ` \u00d7 ${qty}` : ''}`
255
- : addOn.name;
255
+ : `${addOn.name}${qty > 1 ? ` \u00d7 ${qty}` : ''}`;
256
256
  return { name, totalAmount: amt, description: addOn.description ?? undefined };
257
257
  })
258
258
  .filter((x): x is NonNullable<typeof x> => x != null);
@@ -305,9 +305,12 @@ export function usePrivateShuttlePriceSummary({
305
305
  : null;
306
306
  const amount =
307
307
  ((addOn.price ?? 0) + (variant?.priceAdjustment ?? 0)) * (selection.quantity ?? 1);
308
+ const quantity = selection.quantity ?? 1;
308
309
  lineItems.push({
309
310
  type: 'FEE',
310
- label: variant?.label ? `${addOn.name} (${variant.label})` : addOn.name,
311
+ label: variant?.label
312
+ ? `${addOn.name} (${variant.label})${quantity > 1 ? ` \u00d7 ${quantity}` : ''}`
313
+ : `${addOn.name}${quantity > 1 ? ` \u00d7 ${quantity}` : ''}`,
311
314
  amount: roundMoney(amount),
312
315
  });
313
316
  }
@@ -419,13 +422,17 @@ export function usePrivateShuttlePriceSummary({
419
422
  const hasVariant =
420
423
  (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') &&
421
424
  selection.variantId;
422
- const adjustment = hasVariant
423
- ? (addOn.variants?.find((variant) => variant.id === selection.variantId)?.priceAdjustment ?? 0)
424
- : 0;
425
+ const variant = hasVariant
426
+ ? addOn.variants?.find((candidate) => candidate.id === selection.variantId)
427
+ : null;
428
+ const adjustment = variant?.priceAdjustment ?? 0;
429
+ const quantity = selection.quantity ?? 1;
425
430
  return {
426
431
  kind: 'line',
427
- label: addOn.name,
428
- amount: (base + adjustment) * (selection.quantity ?? 1),
432
+ label: variant?.label
433
+ ? `${addOn.name} (${variant.label})${quantity > 1 ? ` \u00d7 ${quantity}` : ''}`
434
+ : `${addOn.name}${quantity > 1 ? ` \u00d7 ${quantity}` : ''}`,
435
+ amount: (base + adjustment) * quantity,
429
436
  type: 'fee',
430
437
  };
431
438
  })
@@ -267,7 +267,7 @@ export function useStandardBookingPriceSummary({
267
267
  : null;
268
268
  const name = variantLabel
269
269
  ? `${addOn.name} (${variantLabel})${quantity > 1 ? ` \u00d7 ${quantity}` : ''}`
270
- : addOn.name;
270
+ : `${addOn.name}${quantity > 1 ? ` \u00d7 ${quantity}` : ''}`;
271
271
  return { name, totalAmount: amount, description: addOn.description ?? undefined };
272
272
  })
273
273
  .filter((line): line is NonNullable<typeof line> => line != null);
@@ -802,19 +802,22 @@ export interface AddOnVariant {
802
802
  id: string;
803
803
  label: string;
804
804
  priceAdjustment: number;
805
+ description?: string | null;
805
806
  }
806
807
 
807
808
  export interface AddOn {
808
809
  addOnId: string;
809
810
  name: string;
810
811
  description?: string | null;
812
+ emoji?: string | null;
811
813
  price: number;
812
814
  currency: string;
813
815
  preCheckout: boolean;
814
- postCheckout: boolean;
815
816
  variantType: 'none' | 'single_choice' | 'multi_quantity';
816
817
  variants: AddOnVariant[];
817
818
  productOptionIds: string[];
819
+ minHoursBeforeStart?: number | null;
820
+ blackoutDates?: string[];
818
821
  }
819
822
 
820
823
  export interface CompanySettings {
@@ -0,0 +1,24 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { selectedAddOnQuantity } from '../src/components/booking/add-on-selection-helpers';
4
+
5
+ test('selectedAddOnQuantity totals variants and treats an omitted quantity as one', () => {
6
+ assert.equal(
7
+ selectedAddOnQuantity('addon_lunch', [
8
+ { addOnId: 'addon_lunch', quantity: 2 },
9
+ { addOnId: 'addon_lunch' },
10
+ { addOnId: 'addon_other', quantity: 9 },
11
+ ]),
12
+ 3,
13
+ );
14
+ });
15
+
16
+ test('selectedAddOnQuantity ignores zero and negative quantities', () => {
17
+ assert.equal(
18
+ selectedAddOnQuantity('addon_lunch', [
19
+ { addOnId: 'addon_lunch', quantity: 0 },
20
+ { addOnId: 'addon_lunch', quantity: -2 },
21
+ ]),
22
+ 0,
23
+ );
24
+ });
@@ -114,6 +114,7 @@ import {
114
114
  resolveInitialPrivateShuttlePassengerCount,
115
115
  } from '../src/components/booking/private-shuttle-passenger-count';
116
116
  import { hasEffectiveAdminChangeSelection } from '../src/components/booking/useAdminProviderPricingAdjustments';
117
+ import { buildPrivateShuttleCheckoutArtifacts } from '../src/components/booking/private-shuttle-checkout-builders';
117
118
  import {
118
119
  lineFromActiveAdjustment,
119
120
  reverseAdjustmentIdsForAdminCustomLines,
@@ -1903,7 +1904,6 @@ test('keeps an incompatible add-on visible until the admin explicitly removes it
1903
1904
  price: 28,
1904
1905
  currency: 'CAD',
1905
1906
  preCheckout: true,
1906
- postCheckout: false,
1907
1907
  variantType: 'multi_quantity' as const,
1908
1908
  variants: [
1909
1909
  {
@@ -2264,3 +2264,44 @@ test('exposes only authoritative selectable amendment components to admin adjust
2264
2264
  },
2265
2265
  ]);
2266
2266
  });
2267
+
2268
+ test('private shuttle checkout receipt labels add-on variants and quantities', () => {
2269
+ const artifacts = buildPrivateShuttleCheckoutArtifacts({
2270
+ resourceCount: 1,
2271
+ resourcePrice: 2199.99,
2272
+ basePrice: 2199.99,
2273
+ addOnSelections: [
2274
+ { addOnId: 'addon_croissants', variantId: 'butter', quantity: 2 },
2275
+ ],
2276
+ addOns: [
2277
+ {
2278
+ addOnId: 'addon_croissants',
2279
+ name: 'Croissants',
2280
+ price: 5.99,
2281
+ currency: 'CAD',
2282
+ preCheckout: true,
2283
+ variantType: 'multi_quantity',
2284
+ variants: [{ id: 'butter', label: 'Butter Croissant', priceAdjustment: 0 }],
2285
+ productOptionIds: [],
2286
+ },
2287
+ ],
2288
+ additionalHoursAmount: 0,
2289
+ additionalHoursCount: 0,
2290
+ perBookingFeeLineItems: [],
2291
+ cancellationPolicyFee: 0,
2292
+ effectiveTaxAmount: 0,
2293
+ taxLabel: 'Taxes and fees',
2294
+ effectivePromoDiscountAmount: 0,
2295
+ discountLabel: 'Discount',
2296
+ promoLineType: 'PROMO_CODE',
2297
+ depositInfo: null,
2298
+ totalPrice: 2211.97,
2299
+ currency: 'CAD',
2300
+ pricingV2Quote: null,
2301
+ roundingLabel: 'Rounding adjustment',
2302
+ });
2303
+
2304
+ assert.deepEqual(artifacts.feeLineItemsForModal, [
2305
+ { name: 'Croissants (Butter Croissant) \u00d7 2', totalAmount: 11.98 },
2306
+ ]);
2307
+ });
@@ -0,0 +1,27 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ EMERALD_LAKE_LUNCH_ADD_ON_ID,
5
+ WILD_FLOUR_LUNCH_ADD_ON_ID,
6
+ privateShuttleHiddenLunchAddOnId,
7
+ privateShuttleLunchAddOnIsVisible,
8
+ } from '../src/components/booking/private-shuttle-lunch-visibility';
9
+
10
+ test('private shuttle shows only Emerald Lake lunch when Emerald Lake is selected', () => {
11
+ const destinations = ['moraine_lake', 'emerald_lake'];
12
+ assert.equal(privateShuttleLunchAddOnIsVisible(EMERALD_LAKE_LUNCH_ADD_ON_ID, destinations), true);
13
+ assert.equal(privateShuttleLunchAddOnIsVisible(WILD_FLOUR_LUNCH_ADD_ON_ID, destinations), false);
14
+ assert.equal(privateShuttleHiddenLunchAddOnId(destinations), WILD_FLOUR_LUNCH_ADD_ON_ID);
15
+ });
16
+
17
+ test('private shuttle shows only Wild Flour lunch without Emerald Lake', () => {
18
+ const destinations = ['moraine_lake', 'lake_louise'];
19
+ assert.equal(privateShuttleLunchAddOnIsVisible(EMERALD_LAKE_LUNCH_ADD_ON_ID, destinations), false);
20
+ assert.equal(privateShuttleLunchAddOnIsVisible(WILD_FLOUR_LUNCH_ADD_ON_ID, destinations), true);
21
+ assert.equal(privateShuttleHiddenLunchAddOnId(destinations), EMERALD_LAKE_LUNCH_ADD_ON_ID);
22
+ });
23
+
24
+ test('private shuttle leaves non-lunch add-ons visible', () => {
25
+ assert.equal(privateShuttleLunchAddOnIsVisible('addon_animals', ['emerald_lake']), true);
26
+ assert.equal(privateShuttleLunchAddOnIsVisible('addon_breakfast', []), true);
27
+ });