@ticketboothapp/booking 1.2.24 → 1.2.25

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 (158) hide show
  1. package/package.json +29 -2
  2. package/src/assets/icons/minus.svg +7 -0
  3. package/src/assets/icons/partner-logos/getyourguide.svg +8 -0
  4. package/src/assets/icons/plus.svg +3 -0
  5. package/src/colours.css +23 -0
  6. package/src/components/BookingDetails.module.css +1591 -0
  7. package/src/components/BookingDetails.tsx +2264 -0
  8. package/src/components/BookingWidget.tsx +305 -0
  9. package/src/components/ManageBookingView.tsx +437 -0
  10. package/src/components/PhoneInputWithCountry.module.css +131 -0
  11. package/src/components/PhoneInputWithCountry.tsx +44 -0
  12. package/src/components/PickupLocationDialog.module.css +360 -0
  13. package/src/components/PickupLocationDialog.tsx +357 -0
  14. package/src/components/PostBookingDependentAddOnUpsell.module.css +174 -0
  15. package/src/components/PostBookingDependentAddOnUpsell.tsx +407 -0
  16. package/src/components/booking/AddOnsSection.module.css +10 -0
  17. package/src/components/booking/AddOnsSection.tsx +184 -0
  18. package/src/components/booking/AdminPaymentChoiceModal.tsx +98 -0
  19. package/src/components/booking/BookingDialog.module.css +643 -0
  20. package/src/components/booking/BookingDialog.tsx +356 -0
  21. package/src/components/booking/BookingFlow.tsx +4385 -0
  22. package/src/components/booking/BookingFlowCollage.module.css +148 -0
  23. package/src/components/booking/BookingFlowCollage.tsx +184 -0
  24. package/src/components/booking/BookingFlowPlaceholder.module.css +27 -0
  25. package/src/components/booking/BookingFlowPlaceholder.tsx +25 -0
  26. package/src/components/booking/BookingFlowPreview.tsx +51 -0
  27. package/src/components/booking/BookingProductGrid.module.css +359 -0
  28. package/src/components/booking/BookingProductGrid.tsx +497 -0
  29. package/src/components/booking/Calendar.module.css +616 -0
  30. package/src/components/booking/Calendar.tsx +1123 -0
  31. package/src/components/booking/CancellationPolicySelector.module.css +124 -0
  32. package/src/components/booking/CancellationPolicySelector.tsx +142 -0
  33. package/src/components/booking/ChangeBookingDialog.tsx +562 -0
  34. package/src/components/booking/CheckoutForm.module.css +244 -0
  35. package/src/components/booking/CheckoutForm.tsx +364 -0
  36. package/src/components/booking/CheckoutModal.tsx +451 -0
  37. package/src/components/booking/CurrencySwitcher.tsx +81 -0
  38. package/src/components/booking/DapFlowCollage.tsx +88 -0
  39. package/src/components/booking/DapTourDescription.tsx +35 -0
  40. package/src/components/booking/DependentAddOnBookingDialog.tsx +1350 -0
  41. package/src/components/booking/DependentAddOnPaymentForm.tsx +124 -0
  42. package/src/components/booking/ErrorBoundary.tsx +63 -0
  43. package/src/components/booking/InfoTooltip.tsx +108 -0
  44. package/src/components/booking/ItineraryBox.module.css +258 -0
  45. package/src/components/booking/ItineraryBox.tsx +550 -0
  46. package/src/components/booking/ItineraryBuilder.tsx +82 -0
  47. package/src/components/booking/ItineraryPlaceholder.module.css +45 -0
  48. package/src/components/booking/ItineraryPlaceholder.tsx +26 -0
  49. package/src/components/booking/MealDrinkAddOnSelector.tsx +338 -0
  50. package/src/components/booking/PickupLocationSelector.module.css +124 -0
  51. package/src/components/booking/PickupLocationSelector.tsx +1566 -0
  52. package/src/components/booking/PickupTimeSelector.module.css +134 -0
  53. package/src/components/booking/PickupTimeSelector.tsx +112 -0
  54. package/src/components/booking/PriceBreakdown.tsx +154 -0
  55. package/src/components/booking/PriceSummary.tsx +234 -0
  56. package/src/components/booking/PrivateShuttleBookingFlow.module.css +357 -0
  57. package/src/components/booking/PrivateShuttleBookingFlow.tsx +2662 -0
  58. package/src/components/booking/PromoCodeInput.module.css +166 -0
  59. package/src/components/booking/PromoCodeInput.tsx +99 -0
  60. package/src/components/booking/ReturnTimeSelector.module.css +173 -0
  61. package/src/components/booking/ReturnTimeSelector.tsx +145 -0
  62. package/src/components/booking/TermsAcceptance.tsx +111 -0
  63. package/src/components/booking/TicketSelector.module.css +164 -0
  64. package/src/components/booking/TicketSelector.tsx +199 -0
  65. package/src/components/booking/TourDescription.module.css +304 -0
  66. package/src/components/booking/TourDescription.tsx +273 -0
  67. package/src/components/booking/booking-flow-ui.ts +38 -0
  68. package/src/components/booking/booking-flow.css +944 -0
  69. package/src/components/button.css +245 -0
  70. package/src/components/button.tsx +152 -0
  71. package/src/components/colorable-svg.tsx +29 -0
  72. package/src/components/image.css +29 -0
  73. package/src/components/image.tsx +113 -0
  74. package/src/components/partner/PartnerBookingPage.module.css +130 -0
  75. package/src/components/partner/PartnerBookingPage.tsx +390 -0
  76. package/src/components/partner/PartnerBookingPageWithBrowserMetadata.tsx +45 -0
  77. package/src/components/product-tag.module.css +30 -0
  78. package/src/components/product-tag.tsx +34 -0
  79. package/src/components/product-theme-pages/image-modal.tsx +248 -0
  80. package/src/components/product-theme-pages/photo-gallery.module.css +200 -0
  81. package/src/components/terms/TermsContent.tsx +178 -0
  82. package/src/components/value-pill.module.css +59 -0
  83. package/src/components/value-pill.tsx +46 -0
  84. package/src/constants/images.ts +556 -0
  85. package/src/constants/pill-values.ts +210 -0
  86. package/src/constants/products.ts +155 -0
  87. package/src/contexts/AvailabilitiesCacheContext.tsx +125 -0
  88. package/src/contexts/BookingAppContext.tsx +134 -0
  89. package/src/contexts/CompanyContext.tsx +70 -0
  90. package/src/data/dap-descriptions/session-couples-families-friends.en.json +61 -0
  91. package/src/data/dap-descriptions/session-elopements.en.json +60 -0
  92. package/src/data/dap-descriptions/session-proposals.en.json +60 -0
  93. package/src/data/product-descriptions/afternoon-delight.en.json +35 -0
  94. package/src/data/product-descriptions/emerald-lake-escape.en.json +68 -0
  95. package/src/data/product-descriptions/lake-louise-adventure.en.json +74 -0
  96. package/src/data/product-descriptions/moraine-lake-adventure.en.json +78 -0
  97. package/src/data/product-descriptions/moraine-lake-sunrise-lake-louise-golden-hour.en.json +65 -0
  98. package/src/data/product-descriptions/moraine-lake-sunrise.en.json +64 -0
  99. package/src/data/product-descriptions/private-tour.en.json +80 -0
  100. package/src/data/product-descriptions/two-lakes-combo.en.json +65 -0
  101. package/src/data/products-config.json +101 -0
  102. package/src/hooks/useBookingSourceMetadataFromLocation.ts +21 -0
  103. package/src/hooks/useIsBookingLaunchLive.ts +49 -0
  104. package/src/index.ts +79 -0
  105. package/src/lib/analytics.ts +197 -0
  106. package/src/lib/booking/booking-source.ts +51 -0
  107. package/src/lib/booking/checkout-breakdown.ts +69 -0
  108. package/src/lib/booking/correlation-id.ts +46 -0
  109. package/src/lib/booking/i18n/config.ts +21 -0
  110. package/src/lib/booking/i18n/index.tsx +144 -0
  111. package/src/lib/booking/i18n/messages/en.json +236 -0
  112. package/src/lib/booking/i18n/messages/fr.json +236 -0
  113. package/src/lib/booking/itinerary-display.ts +36 -0
  114. package/src/lib/booking/itinerary-labels.ts +70 -0
  115. package/src/lib/booking/location-calculations.ts +43 -0
  116. package/src/lib/booking/location-utils.ts +165 -0
  117. package/src/lib/booking/map-utils.ts +153 -0
  118. package/src/lib/booking/marker-icons.ts +113 -0
  119. package/src/lib/booking/normalize-booking-product-id.ts +21 -0
  120. package/src/lib/booking/pickup-location-types.ts +25 -0
  121. package/src/lib/booking/places-api.ts +154 -0
  122. package/src/lib/booking/pricing.ts +466 -0
  123. package/src/lib/booking/product-option-id.ts +35 -0
  124. package/src/lib/booking/source-metadata.ts +226 -0
  125. package/src/lib/booking/sunday-week.ts +14 -0
  126. package/src/lib/booking/theme.ts +83 -0
  127. package/src/lib/booking/trace-context.ts +62 -0
  128. package/src/lib/booking/utils.ts +9 -0
  129. package/src/lib/booking-api.ts +1793 -0
  130. package/src/lib/booking-constants.ts +23 -0
  131. package/src/lib/booking-ref.ts +13 -0
  132. package/src/lib/booking-types.ts +36 -0
  133. package/src/lib/currency.ts +81 -0
  134. package/src/lib/dap-descriptions.ts +50 -0
  135. package/src/lib/dap-itinerary-preview.ts +315 -0
  136. package/src/lib/dependent-add-on-api.ts +434 -0
  137. package/src/lib/env.ts +96 -0
  138. package/src/lib/firebase.ts +20 -0
  139. package/src/lib/job-application-api.ts +83 -0
  140. package/src/lib/manage-booking-embed-print.ts +16 -0
  141. package/src/lib/manage-booking-post-checkout.ts +68 -0
  142. package/src/lib/photo-dap-config.ts +228 -0
  143. package/src/lib/photo-packages.ts +75 -0
  144. package/src/lib/pickup/map-utils.ts +56 -0
  145. package/src/lib/pickup/marker-icons.ts +19 -0
  146. package/src/lib/product-descriptions.ts +66 -0
  147. package/src/lib/products-config.ts +73 -0
  148. package/src/providers/booking-dialog-provider.tsx +282 -0
  149. package/src/providers/dependent-add-on-dialog-provider.tsx +105 -0
  150. package/src/radius.css +5 -0
  151. package/src/spacing.css +7 -0
  152. package/src/strings/en.json +1774 -0
  153. package/src/strings/es.json +1573 -0
  154. package/src/strings/fr.json +1573 -0
  155. package/src/strings/index.js +23 -0
  156. package/src/text-style.css +56 -0
  157. package/src/utils/currency-converter.ts +101 -0
  158. package/tsconfig.json +8 -2
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Pickup time selector - grid of time slot buttons
3
+ */
4
+
5
+ .label {
6
+ display: block;
7
+ font-size: 0.875rem;
8
+ font-weight: 500;
9
+ color: var(--booking-stone-700, #44403c);
10
+ margin-bottom: 0.5rem;
11
+ }
12
+
13
+ .grid {
14
+ display: grid;
15
+ grid-template-columns: repeat(3, minmax(0, 1fr));
16
+ gap: 0.5rem;
17
+ }
18
+
19
+ @media (min-width: 640px) {
20
+ .grid {
21
+ grid-template-columns: repeat(6, minmax(0, 1fr));
22
+ }
23
+ }
24
+
25
+ .btn {
26
+ padding: 0.875rem 1.25rem;
27
+ border-radius: 0.5rem;
28
+ font-size: 0.875rem;
29
+ font-weight: 500;
30
+ transition: all 0.2s;
31
+ position: relative;
32
+ }
33
+
34
+ .btnDefault {
35
+ padding-top: 0.75rem;
36
+ }
37
+
38
+ .btnWithBadge {
39
+ padding-top: 1.25rem;
40
+ }
41
+
42
+ @media (min-width: 640px) {
43
+ .btnWithBadge {
44
+ padding-top: 1rem;
45
+ }
46
+ }
47
+
48
+ .btnSelected {
49
+ background: var(--booking-emerald-600, #059669);
50
+ color: #fff;
51
+ }
52
+
53
+ .btnAvailable {
54
+ background: var(--light-orange-background-dark, #f7e4dc);
55
+ color: var(--booking-stone-700, #44403c);
56
+ }
57
+
58
+ .btnAvailable:hover {
59
+ background: var(--light-orange-background, #fff1eb);
60
+ }
61
+
62
+ .btnDisabled {
63
+ background: var(--booking-stone-100, #f5f5f4);
64
+ color: var(--booking-stone-400, #a8a29e);
65
+ cursor: not-allowed;
66
+ }
67
+
68
+ .btnSoldOutAdmin {
69
+ background: #fee2e2;
70
+ color: #b91c1c;
71
+ border: 1px solid #fca5a5;
72
+ }
73
+
74
+ .btnSoldOutAdmin:hover {
75
+ background: #fee2e2;
76
+ }
77
+
78
+ .btnSoldOutLocked {
79
+ background: #fee2e2;
80
+ color: #b91c1c;
81
+ border: 1px solid #fca5a5;
82
+ cursor: not-allowed;
83
+ }
84
+
85
+ .btnSoldOutLocked:hover {
86
+ background: #fee2e2;
87
+ }
88
+
89
+ .capacity {
90
+ font-size: 0.75rem;
91
+ margin-top: 0.125rem;
92
+ font-variant-numeric: tabular-nums;
93
+ }
94
+
95
+ /* Fitted to green selected tile — not pure white (readability) */
96
+ .capacityOnSelected {
97
+ color: rgba(255, 255, 255, 0.9);
98
+ }
99
+
100
+ .capacityDefault {
101
+ color: var(--booking-stone-500, #78716c);
102
+ }
103
+
104
+ .capacityProjected {
105
+ font-size: 0.7rem;
106
+ line-height: 1.2;
107
+ margin-top: 0.25rem;
108
+ font-weight: 600;
109
+ text-align: center;
110
+ color: #b91c1c;
111
+ font-variant-numeric: tabular-nums;
112
+ }
113
+
114
+ .badge {
115
+ position: absolute;
116
+ top: -0.75rem;
117
+ left: 50%;
118
+ transform: translateX(-50%);
119
+ font-family: 'Poppins', sans-serif;
120
+ font-size: 0.75rem;
121
+ font-weight: 600;
122
+ text-transform: lowercase;
123
+ color: #fff;
124
+ padding: 0.25rem 0.625rem;
125
+ border-radius: 9999px;
126
+ white-space: nowrap;
127
+ background-color: #ff4d00;
128
+ }
129
+
130
+ .soldOut {
131
+ font-size: 0.75rem;
132
+ font-weight: 500;
133
+ color: #b91c1c;
134
+ }
@@ -0,0 +1,112 @@
1
+ 'use client';
2
+
3
+ import type { Availability } from '@/lib/booking-api';
4
+ import type { ProductOption } from '@/lib/booking-api';
5
+ import styles from './PickupTimeSelector.module.css';
6
+
7
+ type TranslationFn = (key: string, params?: Record<string, string | number>) => string;
8
+
9
+ export interface PickupTimeInfo extends Availability {
10
+ pickupTime: string;
11
+ displayTime: string;
12
+ originalTime: string;
13
+ displayTimeRange?: string;
14
+ }
15
+
16
+ interface PickupTimeSelectorProps {
17
+ pickupTimes: PickupTimeInfo[];
18
+ selectedDateTime: string | null;
19
+ selectedTicketCount: number;
20
+ optionsMap: Map<string, ProductOption>;
21
+ hasAnyMostPopular: boolean;
22
+ isAdmin: boolean;
23
+ pickupLocationSkipped: boolean;
24
+ t: TranslationFn;
25
+ onTimeSelect: (availability: Availability) => void;
26
+ }
27
+
28
+ export function PickupTimeSelector({
29
+ pickupTimes,
30
+ selectedDateTime,
31
+ selectedTicketCount,
32
+ optionsMap,
33
+ hasAnyMostPopular,
34
+ isAdmin,
35
+ pickupLocationSkipped,
36
+ t,
37
+ onTimeSelect,
38
+ }: PickupTimeSelectorProps) {
39
+ return (
40
+ <div>
41
+ <label className={styles.label}>
42
+ {t('booking.selectPickupTime')}
43
+ </label>
44
+ <div className={styles.grid}>
45
+ {pickupTimes.map((timeInfo) => {
46
+ const isSelected = selectedDateTime === timeInfo.dateTime;
47
+ const isSoldOut = timeInfo.vacancies === 0;
48
+ const isInsufficientForParty =
49
+ selectedTicketCount > 0 && (timeInfo.vacancies ?? 0) < selectedTicketCount;
50
+ const canSelect = isAdmin || (!isSoldOut && !isInsufficientForParty);
51
+ const option = timeInfo.productOptionId
52
+ ? optionsMap.get(timeInfo.productOptionId)
53
+ : undefined;
54
+ const isMostPopular = pickupTimes.length > 1 && option?.mostPopular;
55
+ const totalCap = timeInfo.totalCapacity ?? 0;
56
+ const booked = timeInfo.bookedCapacity ?? (totalCap - (timeInfo.vacancies ?? 0));
57
+ const showCapacity = isAdmin && totalCap > 0;
58
+ const slotVacancies = timeInfo.vacancies;
59
+ const projectedSeats = booked + selectedTicketCount;
60
+ const showAdminProjectedLoad =
61
+ isSelected && showCapacity && selectedTicketCount > slotVacancies;
62
+ // Selected tile stays green; unselected + admin + tight party uses public-style tile, not "sold out admin" pink
63
+ const btnClass = (() => {
64
+ if (isSelected) return styles.btnSelected;
65
+ if (!isAdmin) {
66
+ return (isSoldOut || isInsufficientForParty) ? styles.btnSoldOutLocked : styles.btnAvailable;
67
+ }
68
+ if (isSoldOut && slotVacancies === 0) return styles.btnSoldOutAdmin;
69
+ return styles.btnAvailable;
70
+ })();
71
+ return (
72
+ <button
73
+ key={timeInfo.dateTime}
74
+ onClick={() => canSelect && onTimeSelect(timeInfo)}
75
+ disabled={!canSelect}
76
+ className={`${styles.btn} ${hasAnyMostPopular ? styles.btnWithBadge : styles.btnDefault} ${btnClass}`}
77
+ >
78
+ <div>{pickupLocationSkipped && timeInfo.displayTimeRange ? timeInfo.displayTimeRange : timeInfo.displayTime}</div>
79
+ {showCapacity && (showAdminProjectedLoad ? (
80
+ <div className={styles.capacityProjected} aria-live="polite">
81
+ <div>{t('booking.adminBookingLoadLine1', { projected: projectedSeats })}</div>
82
+ <div>{t('booking.adminBookingLoadLine2', { total: totalCap })}</div>
83
+ </div>
84
+ ) : (
85
+ <div
86
+ className={`${styles.capacity} ${isSelected ? styles.capacityOnSelected : styles.capacityDefault}`}
87
+ >
88
+ {t('calendar.spotsAvailable', { count: slotVacancies })}
89
+ </div>
90
+ ))}
91
+ {isMostPopular && (
92
+ <div className={styles.badge}>
93
+ {t('booking.mostPopular')}
94
+ </div>
95
+ )}
96
+ {isSoldOut && (
97
+ <div className={styles.soldOut}>
98
+ {t('booking.soldOut')}
99
+ </div>
100
+ )}
101
+ {isInsufficientForParty && !isAdmin && (
102
+ <div className={styles.soldOut}>
103
+ {`Only ${timeInfo.vacancies} spot${timeInfo.vacancies === 1 ? '' : 's'} left, decrease your ticket count below to select this time`}
104
+ </div>
105
+ )}
106
+ </button>
107
+ );
108
+ })}
109
+ </div>
110
+ </div>
111
+ );
112
+ }
@@ -0,0 +1,154 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { formatCurrencyAmount } from '@/lib/currency';
5
+ import type { PriceBreakdown as PriceBreakdownType } from '@/lib/booking/pricing';
6
+ import { useTranslations } from '@/lib/booking/i18n';
7
+ import { useBookingApp } from '@/contexts/BookingAppContext';
8
+ import type { Currency } from './CurrencySwitcher';
9
+ import type { Locale } from '@/lib/booking/i18n/config';
10
+
11
+ export interface PriceBreakdownProps {
12
+ category: string;
13
+ qty: number;
14
+ itemTotal: number;
15
+ breakdown: PriceBreakdownType | null;
16
+ currency: Currency;
17
+ locale: Locale;
18
+ }
19
+
20
+ /**
21
+ * Format adjustment details for display (e.g., "+10%", "-30%")
22
+ * Uses the actual price change sign to determine +/- display
23
+ */
24
+ function formatAdjustmentDetail(
25
+ adjustmentType?: string, // 'percentage' or 'fixed'
26
+ adjustmentValue?: number,
27
+ isNegativeChange?: boolean // true if changeByCurrency is negative
28
+ ): string {
29
+ if (!adjustmentType || adjustmentValue === undefined) return '';
30
+
31
+ const sign = isNegativeChange ? '-' : '+';
32
+ const absValue = Math.abs(adjustmentValue);
33
+
34
+ if (adjustmentType === 'percentage') {
35
+ return ` (${sign}${absValue}%)`;
36
+ } else if (adjustmentType === 'fixed') {
37
+ return ` (${sign}C$${absValue.toFixed(2)})`;
38
+ }
39
+ return '';
40
+ }
41
+
42
+ /**
43
+ * Renders a single price line (e.g. "Adult × 2 — $X"). When the host app grants
44
+ * permission, shows a hover tooltip with the full price breakdown (base, adjustments,
45
+ * fee, tax). Used in the booking itinerary summary.
46
+ */
47
+ export function PriceBreakdown({
48
+ category,
49
+ qty,
50
+ itemTotal,
51
+ breakdown,
52
+ currency,
53
+ locale,
54
+ }: PriceBreakdownProps) {
55
+ const [showTooltip, setShowTooltip] = useState(false);
56
+ const { t } = useTranslations();
57
+ const { permissions } = useBookingApp();
58
+ const canShowBreakdown = breakdown != null && permissions.canViewPriceBreakdown;
59
+
60
+ // Calculate base price for discount detection
61
+ // In public mode, the base price in breakdown already has dynamic increases rolled in
62
+ const basePricePerPerson = breakdown?.lineItems?.find((l) => l.type === 'base')?.amountInDisplayCurrency;
63
+ const baseTotal = basePricePerPerson != null ? basePricePerPerson * qty : 0;
64
+ const hasDiscount = baseTotal > 0 && itemTotal < baseTotal; // only when final < original
65
+
66
+ if (!breakdown) {
67
+ return (
68
+ <div className="flex items-center justify-between">
69
+ <span className="text-sm text-stone-600">
70
+ {category} {qty > 1 ? `× ${qty}` : ''}
71
+ </span>
72
+ <span className="text-sm font-medium text-stone-700">
73
+ {formatCurrencyAmount(itemTotal, currency, locale)}
74
+ </span>
75
+ </div>
76
+ );
77
+ }
78
+
79
+ return (
80
+ <div className="flex items-center justify-between gap-3 min-w-0">
81
+ <span className="text-sm text-stone-600 min-w-0 truncate">
82
+ {category} {qty > 1 ? `× ${qty}` : ''}
83
+ </span>
84
+ <div className="relative flex-shrink-0 whitespace-nowrap">
85
+ <span
86
+ className={
87
+ canShowBreakdown
88
+ ? 'text-sm font-medium text-stone-700 cursor-help underline decoration-dotted'
89
+ : 'text-sm font-medium text-stone-700'
90
+ }
91
+ onMouseEnter={canShowBreakdown ? () => setShowTooltip(true) : undefined}
92
+ onMouseLeave={canShowBreakdown ? () => setShowTooltip(false) : undefined}
93
+ >
94
+ {hasDiscount ? (
95
+ <>
96
+ <span className="line-through text-stone-400">
97
+ {formatCurrencyAmount(baseTotal, currency, locale)}
98
+ </span>
99
+ {' '}
100
+ <span className="text-emerald-600">
101
+ {formatCurrencyAmount(itemTotal, currency, locale)}
102
+ </span>
103
+ </>
104
+ ) : (
105
+ formatCurrencyAmount(itemTotal, currency, locale)
106
+ )}
107
+ </span>
108
+ {canShowBreakdown && showTooltip && (
109
+ <div className="absolute right-0 top-full mt-2 w-72 p-3 bg-stone-900 text-white text-xs rounded-lg shadow-xl z-50">
110
+ <div className="font-semibold mb-2 pb-2 border-b border-stone-700">
111
+ Price Breakdown ({category})
112
+ </div>
113
+ <div className="space-y-1.5">
114
+ {breakdown.lineItems.map((line) => (
115
+ <div key={line.id ?? line.name} className="flex justify-between">
116
+ <span className="text-stone-400">
117
+ {line.name}{line.type === 'base' ? ` (${currency})` : ''}{formatAdjustmentDetail(line.adjustmentType, line.adjustmentValue, line.amountInDisplayCurrency < 0)}
118
+ </span>
119
+ <span className={line.type === 'adjustment' && line.amountInDisplayCurrency < 0 ? 'text-red-300' : ''}>
120
+ {line.type === 'adjustment' && line.amountInDisplayCurrency >= 0 ? '+' : ''}
121
+ {formatCurrencyAmount(line.amountInDisplayCurrency, currency, locale)}
122
+ </span>
123
+ </div>
124
+ ))}
125
+ {/* Only show fees and tax in breakdown when rolled up (tax-inclusive); for CAD/USD they're already separate line items. */}
126
+ {breakdown.isTaxIncluded && (
127
+ <>
128
+ {breakdown.feeLines.map((fee) => (
129
+ <div key={fee.name} className="flex justify-between">
130
+ <span className="text-stone-400" title={fee.description}>
131
+ {fee.name}:
132
+ </span>
133
+ <span>{formatCurrencyAmount(fee.feeAmount, currency, locale)}</span>
134
+ </div>
135
+ ))}
136
+ <div className="flex justify-between">
137
+ <span className="text-stone-400">
138
+ {`${t('booking.tax') !== 'booking.tax' ? t('booking.tax') : 'Taxes and fees'} (included):`}
139
+ </span>
140
+ <span>{formatCurrencyAmount(breakdown.taxAmount, currency, locale)}</span>
141
+ </div>
142
+ </>
143
+ )}
144
+ <div className="flex justify-between pt-2 mt-2 border-t border-stone-700 font-semibold">
145
+ <span>Final price ({currency}):</span>
146
+ <span>{formatCurrencyAmount(breakdown.finalPrice, currency, locale)}</span>
147
+ </div>
148
+ </div>
149
+ </div>
150
+ )}
151
+ </div>
152
+ </div>
153
+ );
154
+ }
@@ -0,0 +1,234 @@
1
+ 'use client';
2
+
3
+ import { formatCurrencyAmount } from '@/lib/currency';
4
+ import type { PriceBreakdown as PriceBreakdownType } from '@/lib/booking/pricing';
5
+ import { PriceBreakdown } from './PriceBreakdown';
6
+ import { InfoTooltip } from './InfoTooltip';
7
+ import type { Currency } from './CurrencySwitcher';
8
+ import type { Locale } from '@/lib/booking/i18n/config';
9
+
10
+ /** One row in the price summary: either a ticket line (with optional breakdown tooltip) or a simple line. */
11
+ export type PriceSummaryLine =
12
+ | {
13
+ kind: 'ticket';
14
+ category: string;
15
+ qty: number;
16
+ itemTotal: number;
17
+ breakdown?: PriceBreakdownType | null;
18
+ }
19
+ | {
20
+ kind: 'line';
21
+ label: string;
22
+ amount: number;
23
+ /** Drives styling: discount (red, -), return add-on (green, +), default (stone). Receipt types: TICKET, FEE, RETURN_OPTION, PROMO_CODE, CANCELLATION_UPGRADE, TAX, etc. */
24
+ type?: string;
25
+ quantity?: number | null;
26
+ /** Optional tooltip text - when set, shows info icon next to label (e.g. for Moraine Lake Road Access Fee) */
27
+ tooltip?: string;
28
+ };
29
+
30
+ export interface PriceSummaryProps {
31
+ /** Lines to show (tickets with optional breakdown, then fees/return/discount/etc.) */
32
+ lines: PriceSummaryLine[];
33
+ /** Total amount (required) */
34
+ total: number;
35
+ /** Currency and locale for formatting */
36
+ currency: Currency;
37
+ locale: Locale;
38
+ /** Charges before tax. Shown as "Subtotal" row before tax (receipt: inserted before TAX line; checkout: after lines). */
39
+ subtotal?: number;
40
+ /** Checkout mode only: Tax amount when not in lines */
41
+ taxAmount?: number;
42
+ taxRate?: number;
43
+ /** Checkout mode only: Discount amount when not in lines */
44
+ discountAmount?: number;
45
+ /** Checkout mode only: Label for discount row */
46
+ discountLabel?: string | null;
47
+ /** Size: 'sm' for modal/sidebar (text-sm), 'base' for manage page */
48
+ size?: 'sm' | 'base';
49
+ /** Optional i18n; falls back to default strings */
50
+ t?: (key: string) => string;
51
+ /** Optional: extra class on the container */
52
+ className?: string;
53
+ /** Optional: render between tax row and total row (e.g. promo code input in BookingFlow) */
54
+ extraBetweenTaxAndTotal?: React.ReactNode;
55
+ /** Subtotal row spacing: 'compact' for booking flow/Stripe modal, 'relaxed' for /manage (equal top/bottom) */
56
+ subtotalSpacing?: 'compact' | 'relaxed';
57
+ /** Deposit mode: show Total (full), Deposit (amount due today), Remaining Balance */
58
+ depositMode?: { totalLabel: string; balanceAmount: number; fullTotalAmount: number };
59
+ /** When true (e.g. deposit flow with single line), hide redundant Subtotal row */
60
+ hideSubtotal?: boolean;
61
+ /** Overrides the final "Total" row label (e.g. change booking: amount due for the change) */
62
+ totalLabel?: string;
63
+ }
64
+
65
+ function getLineAmountClass(type: string | undefined, amount: number): string {
66
+ const isDiscount = amount < 0 || type === 'PROMO_CODE' || type === 'DISCOUNT' || type === 'GIFT_CARD';
67
+ const isAddOn = (type === 'RETURN_OPTION' || type === 'return') && amount > 0;
68
+ if (isDiscount) return 'font-medium text-red-600';
69
+ if (isAddOn) return 'font-medium text-emerald-600';
70
+ return 'text-stone-700';
71
+ }
72
+
73
+ function formatLineAmount(
74
+ kind: 'line',
75
+ amount: number,
76
+ type: string | undefined,
77
+ currency: Currency,
78
+ locale: Locale
79
+ ): string {
80
+ const formatted = formatCurrencyAmount(amount, currency, locale);
81
+ const isAddOn = (type === 'RETURN_OPTION' || type === 'return') && amount > 0;
82
+ if (isAddOn) return `+${formatted}`;
83
+ return formatted;
84
+ }
85
+
86
+ /**
87
+ * Reusable price breakdown/summary used in:
88
+ * - BookingFlow sidebar (order summary)
89
+ * - CheckoutModal ("Review & pay")
90
+ * - BookingDetails /manage (Payment Summary from receipt)
91
+ * - PrivateShuttleBookingFlow sidebar
92
+ */
93
+ export function PriceSummary({
94
+ lines,
95
+ total,
96
+ currency,
97
+ locale,
98
+ subtotal,
99
+ taxAmount = 0,
100
+ taxRate: _taxRate,
101
+ discountAmount = 0,
102
+ discountLabel,
103
+ size = 'sm',
104
+ t = (k) => k,
105
+ className = '',
106
+ extraBetweenTaxAndTotal,
107
+ subtotalSpacing = 'compact',
108
+ depositMode,
109
+ hideSubtotal = false,
110
+ totalLabel,
111
+ }: PriceSummaryProps) {
112
+ const textSize = size === 'sm' ? 'text-sm' : 'text-base';
113
+ const totalSize = size === 'sm' ? 'text-xl' : 'text-2xl';
114
+ const subtotalRowClass = subtotalSpacing === 'relaxed'
115
+ ? 'pt-3 pb-3 mt-2 border-t border-stone-200'
116
+ : 'mt-2 pt-1.5 border-t border-stone-200';
117
+
118
+ let subtotalShown = false;
119
+
120
+ return (
121
+ <div className={`space-y-2 min-w-0 ${className}`}>
122
+ {lines.map((row, index) => {
123
+ if (row.kind === 'ticket') {
124
+ return (
125
+ <PriceBreakdown
126
+ key={`${row.category}-${index}`}
127
+ category={row.category}
128
+ qty={row.qty}
129
+ itemTotal={row.itemTotal}
130
+ breakdown={row.breakdown ?? null}
131
+ currency={currency}
132
+ locale={locale}
133
+ />
134
+ );
135
+ }
136
+ const { label, amount, type, quantity, tooltip } = row;
137
+ // Receipt mode: insert Subtotal row before first TAX line
138
+ const isTaxLine = type === 'TAX';
139
+ const showSubtotalBeforeTax = isTaxLine && subtotal != null && subtotal > 0 && !subtotalShown;
140
+ if (showSubtotalBeforeTax) subtotalShown = true;
141
+ return (
142
+ <div key={`${label}-${index}`}>
143
+ {showSubtotalBeforeTax && (
144
+ <div className={`flex justify-between gap-3 min-w-0 ${textSize} ${subtotalRowClass}`}>
145
+ <span className="text-stone-600 min-w-0 truncate">{t('booking.subtotal') || 'Subtotal'}</span>
146
+ <span className="flex-shrink-0 whitespace-nowrap font-medium text-stone-700">{formatCurrencyAmount(subtotal!, currency, locale)}</span>
147
+ </div>
148
+ )}
149
+ <div className={`flex justify-between gap-3 min-w-0 ${textSize}`}>
150
+ <span className="text-stone-600 min-w-0 flex items-center gap-1">
151
+ <span className="min-w-0 truncate">
152
+ {label}
153
+ {type === 'TICKET' && quantity != null && quantity > 1 ? ` (x${quantity})` : ''}
154
+ </span>
155
+ {tooltip && <InfoTooltip text={tooltip} />}
156
+ </span>
157
+ <span className={`flex-shrink-0 whitespace-nowrap font-medium ${getLineAmountClass(type, amount)}`}>
158
+ {formatLineAmount('line', amount, type, currency, locale)}
159
+ </span>
160
+ </div>
161
+ </div>
162
+ );
163
+ })}
164
+
165
+ {/* Checkout mode: subtotal/tax/discount not in lines (e.g. Stripe Review & pay modal) */}
166
+ {subtotal != null && !subtotalShown && !hideSubtotal && (subtotal !== total || discountAmount > 0) && (
167
+ <div className={`flex justify-between gap-3 min-w-0 ${textSize} ${subtotalRowClass}`}>
168
+ <span className="text-stone-600 min-w-0 truncate">{t('booking.subtotal') || 'Subtotal'}</span>
169
+ <span className="flex-shrink-0 whitespace-nowrap font-medium text-stone-700">{formatCurrencyAmount(subtotal, currency, locale)}</span>
170
+ </div>
171
+ )}
172
+ {taxAmount > 0 && (
173
+ <div className={`flex justify-between gap-3 min-w-0 ${textSize}`}>
174
+ <span className="text-stone-600 min-w-0 truncate">
175
+ {t('booking.tax') && t('booking.tax') !== 'booking.tax' ? t('booking.tax') : 'Taxes and fees'}
176
+ </span>
177
+ <span className="flex-shrink-0 whitespace-nowrap font-medium text-stone-700">
178
+ {formatCurrencyAmount(taxAmount, currency, locale)}
179
+ </span>
180
+ </div>
181
+ )}
182
+
183
+ {discountAmount > 0 && (
184
+ <div className={`flex justify-between gap-3 min-w-0 ${textSize}`}>
185
+ <span className="text-stone-600 min-w-0 truncate">{discountLabel ?? t('booking.discount') ?? 'Discount'}</span>
186
+ <span className="flex-shrink-0 whitespace-nowrap font-medium text-red-600">
187
+ -{formatCurrencyAmount(discountAmount, currency, locale)}
188
+ </span>
189
+ </div>
190
+ )}
191
+
192
+ {extraBetweenTaxAndTotal}
193
+
194
+ <div className="space-y-0">
195
+ {depositMode ? (
196
+ <>
197
+ <div className={`flex justify-between gap-3 pt-2 border-t border-stone-200 min-w-0 ${totalSize}`}>
198
+ <span className="font-semibold text-stone-900 min-w-0 truncate">
199
+ {t('common.total') && t('common.total') !== 'common.total' ? t('common.total') : 'Total'}
200
+ </span>
201
+ <span className="flex-shrink-0 whitespace-nowrap font-semibold text-stone-900">
202
+ {formatCurrencyAmount(depositMode.fullTotalAmount, currency, locale)}
203
+ </span>
204
+ </div>
205
+ <div className={`flex justify-between gap-3 min-w-0 pt-1 ${textSize}`}>
206
+ <span className="text-stone-600 min-w-0 truncate">{depositMode.totalLabel}</span>
207
+ <span className="flex-shrink-0 whitespace-nowrap font-medium text-stone-700">
208
+ {formatCurrencyAmount(total, currency, locale)}
209
+ </span>
210
+ </div>
211
+ {depositMode.balanceAmount > 0 && (
212
+ <div className={`flex justify-between gap-3 min-w-0 pt-1 ${textSize}`}>
213
+ <span className="text-stone-600 min-w-0 truncate">{t('booking.remainingBalance') || 'Remaining Balance'}</span>
214
+ <span className="flex-shrink-0 whitespace-nowrap font-medium text-stone-700">
215
+ {formatCurrencyAmount(depositMode.balanceAmount, currency, locale)}
216
+ </span>
217
+ </div>
218
+ )}
219
+ </>
220
+ ) : (
221
+ <div className={`flex justify-between gap-3 pt-2 border-t border-stone-200 min-w-0 ${totalSize}`}>
222
+ <span className="font-semibold text-stone-900 min-w-0 truncate">
223
+ {totalLabel ??
224
+ (t('common.total') && t('common.total') !== 'common.total' ? t('common.total') : 'Total')}
225
+ </span>
226
+ <span className="flex-shrink-0 whitespace-nowrap font-semibold text-stone-900">
227
+ {formatCurrencyAmount(total, currency, locale)}
228
+ </span>
229
+ </div>
230
+ )}
231
+ </div>
232
+ </div>
233
+ );
234
+ }