@ticketboothapp/booking 1.2.24 → 1.2.25-rc.0

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 +302 -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,166 @@
1
+ /**
2
+ * Promo row — all layout in this module so it matches on viavia (full Tailwind) and in
3
+ * embedded hosts (e.g. provider dashboard) that only load booking-flow.css, not every Tailwind utility.
4
+ */
5
+
6
+ /* Mobile: label above field (like CheckoutForm). sm+: label left of field. */
7
+ .promoRow {
8
+ display: flex;
9
+ flex-direction: column;
10
+ align-items: stretch;
11
+ gap: 0.25rem;
12
+ padding-top: 1rem;
13
+ padding-bottom: 0.5rem;
14
+ font-size: 0.875rem;
15
+ line-height: 1.25rem;
16
+ border-top: 1px solid var(--booking-stone-200, #e7e5e4);
17
+ min-width: 0;
18
+ }
19
+
20
+ @media (min-width: 640px) {
21
+ .promoRow {
22
+ flex-direction: row;
23
+ align-items: center;
24
+ gap: 0.5rem;
25
+ }
26
+ }
27
+
28
+ .promoLabel {
29
+ flex-shrink: 0;
30
+ white-space: nowrap;
31
+ color: var(--booking-stone-600, #57534e);
32
+ }
33
+
34
+ .promoFieldRow {
35
+ display: flex;
36
+ align-items: center;
37
+ gap: 0.5rem;
38
+ flex: 1 1 0;
39
+ min-width: 0;
40
+ }
41
+
42
+ .promoDiscount {
43
+ margin-left: auto;
44
+ flex-shrink: 0;
45
+ white-space: nowrap;
46
+ font-weight: 500;
47
+ color: #dc2626;
48
+ }
49
+
50
+ .promoErrorInline {
51
+ font-size: 0.875rem;
52
+ line-height: 1.25rem;
53
+ color: #dc2626;
54
+ white-space: nowrap;
55
+ }
56
+
57
+ .inputWrap {
58
+ position: relative;
59
+ flex: 1 1 0;
60
+ min-width: 0;
61
+ max-width: 100%;
62
+ }
63
+
64
+ @media (min-width: 640px) {
65
+ .inputWrap {
66
+ max-width: 250px;
67
+ }
68
+ }
69
+
70
+ /* Box with icons inside (x and check) */
71
+ .input {
72
+ width: 100%;
73
+ min-width: 0;
74
+ padding: 0.375rem 2.25rem 0.375rem 0.5rem;
75
+ font-size: 0.875rem;
76
+ border-radius: 0.25rem;
77
+ border: 1px solid var(--booking-stone-300, #d6d3d1);
78
+ background: #fff;
79
+ color: var(--booking-stone-900, #1c1917);
80
+ box-sizing: border-box;
81
+ }
82
+
83
+ .input:focus {
84
+ outline: none;
85
+ border-color: var(--booking-stone-500, #78716c);
86
+ }
87
+
88
+ .input:read-only {
89
+ background: var(--booking-stone-50, #fafaf9);
90
+ cursor: default;
91
+ padding-right: 3.5rem;
92
+ }
93
+
94
+ /* X and check icons inside the box - dashboard style */
95
+ .removeBtn {
96
+ position: absolute;
97
+ right: 0.25rem;
98
+ top: 50%;
99
+ transform: translateY(-50%);
100
+ width: 1.5rem;
101
+ height: 1.5rem;
102
+ padding: 0;
103
+ border-radius: 9999px;
104
+ color: var(--booking-stone-500, #78716c);
105
+ background: var(--booking-stone-200, #e7e5e4);
106
+ border: none;
107
+ cursor: pointer;
108
+ display: flex;
109
+ align-items: center;
110
+ justify-content: center;
111
+ }
112
+
113
+ .removeBtn:hover {
114
+ background: var(--booking-stone-300, #d6d3d1);
115
+ color: var(--booking-stone-700, #44403c);
116
+ }
117
+
118
+ .loading {
119
+ position: absolute;
120
+ right: 0.5rem;
121
+ top: 50%;
122
+ transform: translateY(-50%);
123
+ width: 1.25rem;
124
+ height: 1.25rem;
125
+ border-radius: 9999px;
126
+ background: var(--booking-stone-100, #f5f5f4);
127
+ animation: pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
128
+ }
129
+
130
+ @keyframes pulse {
131
+ 0%, 100% { opacity: 1; }
132
+ 50% { opacity: 0.5; }
133
+ }
134
+
135
+ .errorIcon {
136
+ position: absolute;
137
+ right: 0.5rem;
138
+ top: 50%;
139
+ transform: translateY(-50%);
140
+ width: 1.25rem;
141
+ height: 1.25rem;
142
+ border-radius: 9999px;
143
+ background: #ef4444;
144
+ color: #fff;
145
+ display: flex;
146
+ align-items: center;
147
+ justify-content: center;
148
+ }
149
+
150
+ /* Check icon - inside box, next to remove btn */
151
+ .appliedBadge {
152
+ position: absolute;
153
+ right: 2rem;
154
+ top: 50%;
155
+ transform: translateY(-50%);
156
+ width: 1.5rem;
157
+ height: 1.5rem;
158
+ border-radius: 9999px;
159
+ background: var(--booking-emerald-500, #10b981);
160
+ color: #fff;
161
+ display: flex;
162
+ align-items: center;
163
+ justify-content: center;
164
+ flex-shrink: 0;
165
+ }
166
+
@@ -0,0 +1,99 @@
1
+ 'use client';
2
+
3
+ import { Check, X } from 'lucide-react';
4
+ import { formatCurrencyAmount } from '@/lib/currency';
5
+ import type { Currency } from './CurrencySwitcher';
6
+ import styles from './PromoCodeInput.module.css';
7
+
8
+ type TranslationFn = (key: string, params?: Record<string, string>) => string;
9
+
10
+ interface PromoCodeInputProps {
11
+ promoCodeInput: string;
12
+ appliedPromoCode: string | null;
13
+ promoCodeError: string;
14
+ promoCodeValidating: boolean;
15
+ promoDiscountAmount: number;
16
+ currency: Currency;
17
+ locale: string;
18
+ t: TranslationFn;
19
+ onInputChange: (value: string) => void;
20
+ onApply: () => void;
21
+ onRemove: () => void;
22
+ locked?: boolean;
23
+ }
24
+
25
+ export function PromoCodeInput({
26
+ promoCodeInput,
27
+ appliedPromoCode,
28
+ promoCodeError,
29
+ promoCodeValidating,
30
+ promoDiscountAmount,
31
+ currency,
32
+ locale,
33
+ t,
34
+ onInputChange,
35
+ onApply,
36
+ onRemove,
37
+ locked = false,
38
+ }: PromoCodeInputProps) {
39
+ return (
40
+ <div className={styles.promoRow}>
41
+ <label htmlFor="booking-promo-code" className={styles.promoLabel}>
42
+ {t('booking.optionalPromoCode') || 'Promo / voucher / gift card'}
43
+ </label>
44
+ <div className={styles.promoFieldRow}>
45
+ <div className={styles.inputWrap}>
46
+ <input
47
+ type="text"
48
+ name="promoCode"
49
+ id="booking-promo-code"
50
+ value={promoCodeInput}
51
+ onChange={(e) => onInputChange(e.target.value.toUpperCase())}
52
+ onKeyDown={(e) => {
53
+ if (e.key === 'Enter') {
54
+ e.preventDefault();
55
+ onApply();
56
+ }
57
+ }}
58
+ placeholder={t('booking.promoCodePlaceholder')}
59
+ autoComplete="off"
60
+ readOnly={locked || !!appliedPromoCode}
61
+ disabled={locked}
62
+ className={styles.input}
63
+ />
64
+ {appliedPromoCode ? (
65
+ <>
66
+ <span className={styles.appliedBadge} aria-label={t('booking.promoApplied', { code: appliedPromoCode })}>
67
+ <Check className="w-3.5 h-3.5" strokeWidth={3} />
68
+ </span>
69
+ {!locked && (
70
+ <button
71
+ type="button"
72
+ onClick={onRemove}
73
+ className={styles.removeBtn}
74
+ aria-label={t('booking.removePromo')}
75
+ >
76
+ <X className="w-4 h-4" strokeWidth={2.5} />
77
+ </button>
78
+ )}
79
+ </>
80
+ ) : promoCodeValidating ? (
81
+ <span className={styles.loading} aria-hidden />
82
+ ) : promoCodeError ? (
83
+ <span className={styles.errorIcon} aria-label={promoCodeError}>
84
+ <X className="w-3 h-3" strokeWidth={3} />
85
+ </span>
86
+ ) : null}
87
+ </div>
88
+ {promoDiscountAmount > 0 && (
89
+ <span className={styles.promoDiscount}>
90
+ -{formatCurrencyAmount(promoDiscountAmount, currency, locale as 'en' | 'fr')}
91
+ </span>
92
+ )}
93
+ {promoCodeError && (
94
+ <span className={styles.promoErrorInline}>{promoCodeError}</span>
95
+ )}
96
+ </div>
97
+ </div>
98
+ );
99
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Return time selector - list of return options with stay summary
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: 1rem;
11
+ }
12
+
13
+ .list {
14
+ display: flex;
15
+ flex-direction: column;
16
+ gap: 0.5rem;
17
+ }
18
+
19
+ .btn {
20
+ width: 100%;
21
+ padding: 1rem 1.25rem;
22
+ border-radius: 0.5rem;
23
+ border: 2px solid;
24
+ transition: all 0.2s;
25
+ text-align: left;
26
+ position: relative;
27
+ }
28
+
29
+ .btnSelected {
30
+ border-color: var(--booking-emerald-600, #059669);
31
+ background: var(--booking-emerald-600, #059669);
32
+ color: #fff;
33
+ }
34
+
35
+ .btnSelected .time,
36
+ .btnSelected .location {
37
+ color: rgba(255, 255, 255, 0.95);
38
+ }
39
+
40
+ .btnSelected .staySummary {
41
+ color: rgba(255, 255, 255, 0.85);
42
+ }
43
+
44
+ .btnSelected .price {
45
+ color: rgba(255, 255, 255, 0.95);
46
+ }
47
+
48
+ .btnAvailable {
49
+ border-color: var(--booking-stone-200, #e7e5e4);
50
+ background: var(--light-orange-background-dark, #f7e4dc);
51
+ }
52
+
53
+ .btnAvailable:hover {
54
+ border-color: #6ee7b7;
55
+ background: var(--light-orange-background, #fff1eb);
56
+ }
57
+
58
+ .btnDisabled {
59
+ border-color: var(--booking-stone-200, #e7e5e4);
60
+ background: var(--booking-stone-100, #f5f5f4);
61
+ color: var(--booking-stone-400, #a8a29e);
62
+ cursor: not-allowed;
63
+ }
64
+
65
+ .btnSoldOutAdmin {
66
+ border-color: #fca5a5;
67
+ background: #fee2e2;
68
+ color: #b91c1c;
69
+ }
70
+
71
+ .btnSoldOutAdmin:hover {
72
+ border-color: #fca5a5;
73
+ background: #fee2e2;
74
+ }
75
+
76
+ .btnSoldOutLocked {
77
+ border-color: #fca5a5;
78
+ background: #fee2e2;
79
+ color: #b91c1c;
80
+ cursor: not-allowed;
81
+ }
82
+
83
+ .btnSoldOutLocked:hover {
84
+ border-color: #fca5a5;
85
+ background: #fee2e2;
86
+ }
87
+
88
+ .badge {
89
+ position: absolute;
90
+ top: -0.5rem;
91
+ right: -0.25rem;
92
+ font-family: 'Poppins', sans-serif;
93
+ font-size: 0.75rem;
94
+ font-weight: 600;
95
+ text-transform: lowercase;
96
+ color: #fff;
97
+ padding: 0.25rem 0.5rem;
98
+ border-radius: 9999px;
99
+ background-color: #ff4d00;
100
+ }
101
+
102
+ .content {
103
+ display: flex;
104
+ align-items: center;
105
+ justify-content: space-between;
106
+ }
107
+
108
+ .details {
109
+ flex: 1;
110
+ }
111
+
112
+ .time {
113
+ font-weight: 500;
114
+ color: var(--booking-stone-900, #1c1917);
115
+ }
116
+
117
+ .location {
118
+ font-size: 0.75rem;
119
+ color: var(--booking-stone-600, #57534e);
120
+ margin-top: 0.125rem;
121
+ }
122
+
123
+ .capacity {
124
+ font-size: 0.75rem;
125
+ color: var(--booking-stone-500, #78716c);
126
+ margin-top: 0.125rem;
127
+ font-variant-numeric: tabular-nums;
128
+ }
129
+
130
+ .btnSelected .capacityDefault {
131
+ color: rgba(255, 255, 255, 0.9);
132
+ }
133
+
134
+ .capacityProjected {
135
+ font-size: 0.7rem;
136
+ line-height: 1.2;
137
+ margin-top: 0.25rem;
138
+ font-weight: 600;
139
+ color: #b91c1c;
140
+ font-variant-numeric: tabular-nums;
141
+ }
142
+
143
+ .staySummary {
144
+ font-size: 0.75rem;
145
+ color: var(--booking-stone-500, #78716c);
146
+ margin-top: 0.25rem;
147
+ font-weight: 600;
148
+ }
149
+
150
+ .soldOut {
151
+ font-size: 0.75rem;
152
+ font-weight: 500;
153
+ margin-top: 0.25rem;
154
+ color: #b91c1c;
155
+ }
156
+
157
+ .price {
158
+ font-size: 0.875rem;
159
+ font-weight: 600;
160
+ margin-left: 1rem;
161
+ }
162
+
163
+ .priceIncluded {
164
+ color: var(--booking-stone-600, #57534e);
165
+ }
166
+
167
+ .pricePositive {
168
+ color: var(--booking-emerald-600, #059669);
169
+ }
170
+
171
+ .priceNegative {
172
+ color: #b91c1c;
173
+ }
@@ -0,0 +1,145 @@
1
+ 'use client';
2
+
3
+ import { parseISO } from 'date-fns';
4
+ import { formatInTimeZone } from 'date-fns-tz';
5
+ import { formatCurrencyAmount } from '@/lib/currency';
6
+ import type { ReturnOption } from '@/lib/booking-api';
7
+ import type { Currency } from './CurrencySwitcher';
8
+ import styles from './ReturnTimeSelector.module.css';
9
+
10
+ type TranslationFn = (key: string, params?: Record<string, string | number>) => string;
11
+
12
+ interface ReturnTimeSelectorProps {
13
+ returnOptions: ReturnOption[];
14
+ selectedReturnOption: ReturnOption | null;
15
+ selectedTicketCount: number;
16
+ companyTimezone: string;
17
+ currency: Currency;
18
+ locale: string;
19
+ isAdmin: boolean;
20
+ t: TranslationFn;
21
+ onReturnSelect: (option: ReturnOption) => void;
22
+ getStaySummary: (returnDateTime: Date) => string | null;
23
+ }
24
+
25
+ export function ReturnTimeSelector({
26
+ returnOptions,
27
+ selectedReturnOption,
28
+ selectedTicketCount,
29
+ companyTimezone,
30
+ currency,
31
+ locale,
32
+ isAdmin,
33
+ t,
34
+ onReturnSelect,
35
+ getStaySummary,
36
+ }: ReturnTimeSelectorProps) {
37
+ const sortedReturnOptions = [...returnOptions].sort((a, b) => {
38
+ const timeA = parseISO(a.dateTime).getTime();
39
+ const timeB = parseISO(b.dateTime).getTime();
40
+ return timeA - timeB;
41
+ });
42
+
43
+ return (
44
+ <div>
45
+ <label className={styles.label}>
46
+ {t('booking.selectReturnTime') || 'Select Return Time'}
47
+ </label>
48
+
49
+ <div className={styles.list}>
50
+ {sortedReturnOptions.map((returnOption, index) => {
51
+ const returnDateTime = parseISO(returnOption.dateTime);
52
+ const displayTime = formatInTimeZone(returnDateTime, companyTimezone, 'h:mm a');
53
+ const isSelected = selectedReturnOption?.returnAvailabilityId === returnOption.returnAvailabilityId;
54
+ const isSoldOut = returnOption.vacancies === 0;
55
+ const returnVacancies = returnOption.vacancies;
56
+ const returnTotalCap = returnOption.totalCapacity ?? 0;
57
+ const returnBooked =
58
+ returnOption.bookedCapacity ?? (returnTotalCap - returnVacancies);
59
+ const projectedSeats = returnBooked + selectedTicketCount;
60
+ const isInsufficientForParty =
61
+ selectedTicketCount > 0 && (returnOption.vacancies ?? 0) < selectedTicketCount;
62
+ const canSelectReturn = isAdmin || (!isSoldOut && !isInsufficientForParty);
63
+ const priceAdjustmentPerPerson = returnOption.priceAdjustmentByCurrency?.[currency] ?? 0;
64
+ const returnOptionsCount = returnOptions.length;
65
+ const isMostPopular = returnOptionsCount > 1 && index === 0;
66
+ const staySummary = getStaySummary(returnDateTime);
67
+ const showAdminProjectedLoad =
68
+ isSelected && isAdmin && returnTotalCap > 0 && selectedTicketCount > returnVacancies;
69
+ const btnClass = (() => {
70
+ if (isSelected) return styles.btnSelected;
71
+ if (!isAdmin) {
72
+ return (isSoldOut || isInsufficientForParty) ? styles.btnSoldOutLocked : styles.btnAvailable;
73
+ }
74
+ if (isSoldOut && returnVacancies === 0) return styles.btnSoldOutAdmin;
75
+ return styles.btnAvailable;
76
+ })();
77
+ const priceClass = priceAdjustmentPerPerson === 0
78
+ ? styles.priceIncluded
79
+ : priceAdjustmentPerPerson > 0
80
+ ? styles.pricePositive
81
+ : styles.priceNegative;
82
+
83
+ return (
84
+ <button
85
+ key={returnOption.returnAvailabilityId}
86
+ onClick={() => canSelectReturn && onReturnSelect(returnOption)}
87
+ disabled={!canSelectReturn}
88
+ className={`${styles.btn} ${btnClass} ${isSelected ? 'booking-return-selected' : ''}`}
89
+ >
90
+ {isMostPopular && (
91
+ <div className={styles.badge}>
92
+ {t('booking.mostPopular')}
93
+ </div>
94
+ )}
95
+ <div className={styles.content}>
96
+ <div className={styles.details}>
97
+ <div className={styles.time}>{displayTime}</div>
98
+ <div className={styles.location}>from {returnOption.returnLocation}</div>
99
+ {isAdmin && returnTotalCap > 0 && (showAdminProjectedLoad ? (
100
+ <div className={styles.capacityProjected} aria-live="polite">
101
+ <div>{t('booking.adminBookingLoadLine1', { projected: projectedSeats })}</div>
102
+ <div>{t('booking.adminBookingLoadLine2', { total: returnTotalCap })}</div>
103
+ </div>
104
+ ) : (
105
+ <div
106
+ className={
107
+ isSelected
108
+ ? `${styles.capacity} ${styles.capacityDefault}`
109
+ : styles.capacity
110
+ }
111
+ >
112
+ {t('calendar.spotsAvailable', { count: returnVacancies })}
113
+ </div>
114
+ ))}
115
+ {staySummary && (
116
+ <div className={styles.staySummary}>
117
+ {staySummary}
118
+ </div>
119
+ )}
120
+ {isSoldOut && (
121
+ <div className={styles.soldOut}>
122
+ {t('booking.soldOut')}
123
+ </div>
124
+ )}
125
+ {isInsufficientForParty && !isAdmin && (
126
+ <div className={styles.soldOut}>
127
+ {`Only ${returnOption.vacancies} spot${returnOption.vacancies === 1 ? '' : 's'} left, decrease your ticket count below to select this time`}
128
+ </div>
129
+ )}
130
+ </div>
131
+ <div className={`${styles.price} ${priceClass}`}>
132
+ {priceAdjustmentPerPerson === 0
133
+ ? 'Included'
134
+ : priceAdjustmentPerPerson > 0
135
+ ? `+${formatCurrencyAmount(priceAdjustmentPerPerson, currency, locale as 'en' | 'fr')} per person`
136
+ : `${formatCurrencyAmount(priceAdjustmentPerPerson, currency, locale as 'en' | 'fr')} per person`}
137
+ </div>
138
+ </div>
139
+ </button>
140
+ );
141
+ })}
142
+ </div>
143
+ </div>
144
+ );
145
+ }
@@ -0,0 +1,111 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { TermsContent } from '@/components/terms/TermsContent';
5
+
6
+ export interface TermsAcceptanceProps {
7
+ checked: boolean;
8
+ onChange: (checked: boolean) => void;
9
+ /** Translation function for labels */
10
+ t: (key: string) => string;
11
+ /** Optional custom terms content (React node). If not provided, uses full TermsContent from terms-conditions page. */
12
+ termsContent?: React.ReactNode;
13
+ }
14
+
15
+ /**
16
+ * Checkbox to accept terms & conditions, with a "View terms" link that opens
17
+ * a small modal showing the full text (in-page, not a separate page).
18
+ * Least intrusive: user can proceed by checking the box; they can open the
19
+ * modal to read the full terms if they want.
20
+ */
21
+ export function TermsAcceptance({
22
+ checked,
23
+ onChange,
24
+ t,
25
+ termsContent,
26
+ }: TermsAcceptanceProps) {
27
+ const [modalOpen, setModalOpen] = useState(false);
28
+ const content = termsContent ?? <TermsContent className="text-xs [&_h1]:text-base [&_h2]:text-sm [&_h3]:text-xs [&_p]:text-xs" />;
29
+
30
+ return (
31
+ <>
32
+ <label className="flex items-center gap-3 cursor-pointer group">
33
+ <input
34
+ type="checkbox"
35
+ checked={checked}
36
+ onChange={(e) => onChange(e.target.checked)}
37
+ className="shrink-0 rounded border-stone-300 text-emerald-600 focus:ring-emerald-500"
38
+ aria-describedby="terms-view-link"
39
+ />
40
+ <span className="text-sm text-stone-700">
41
+ {t('terms.acceptPrefix')}{' '}
42
+ <button
43
+ type="button"
44
+ id="terms-view-link"
45
+ onClick={(e) => {
46
+ e.preventDefault();
47
+ setModalOpen(true);
48
+ }}
49
+ className="text-emerald-600 hover:text-emerald-700 underline focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-1 rounded"
50
+ >
51
+ {t('terms.viewTerms')}
52
+ </button>
53
+ </span>
54
+ </label>
55
+
56
+ {/* Modal: small overlay with scrollable terms */}
57
+ {modalOpen && (
58
+ <div
59
+ className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40"
60
+ role="dialog"
61
+ aria-modal="true"
62
+ aria-labelledby="terms-modal-title"
63
+ onClick={() => setModalOpen(false)}
64
+ >
65
+ <div
66
+ className="bg-white rounded-xl shadow-xl max-w-lg w-full max-h-[80vh] flex flex-col"
67
+ onClick={(e) => e.stopPropagation()}
68
+ >
69
+ <div className="flex items-center justify-between px-5 py-4 border-b border-stone-200 shrink-0">
70
+ <h2 id="terms-modal-title" className="text-lg font-semibold text-stone-900">
71
+ {t('terms.title')}
72
+ </h2>
73
+ <button
74
+ type="button"
75
+ onClick={() => setModalOpen(false)}
76
+ className="p-2 rounded-lg text-stone-500 hover:text-stone-700 hover:bg-stone-100 focus:outline-none focus:ring-2 focus:ring-emerald-500"
77
+ aria-label={t('common.close')}
78
+ >
79
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
80
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
81
+ </svg>
82
+ </button>
83
+ </div>
84
+ <div className="flex-1 min-h-0 overflow-y-auto px-5 py-4">
85
+ {content}
86
+ </div>
87
+ <div className="px-5 py-4 border-t border-stone-200 shrink-0 flex gap-3 justify-end">
88
+ <button
89
+ type="button"
90
+ onClick={() => setModalOpen(false)}
91
+ className="px-4 py-3 text-sm font-medium text-stone-700 bg-stone-100 rounded-lg hover:bg-stone-200"
92
+ >
93
+ {t('common.close')}
94
+ </button>
95
+ <button
96
+ type="button"
97
+ onClick={() => {
98
+ onChange(true);
99
+ setModalOpen(false);
100
+ }}
101
+ className="px-4 py-3 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700"
102
+ >
103
+ {t('terms.acceptAndClose')}
104
+ </button>
105
+ </div>
106
+ </div>
107
+ </div>
108
+ )}
109
+ </>
110
+ );
111
+ }