@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.
- package/package.json +29 -2
- package/src/assets/icons/minus.svg +7 -0
- package/src/assets/icons/partner-logos/getyourguide.svg +8 -0
- package/src/assets/icons/plus.svg +3 -0
- package/src/colours.css +23 -0
- package/src/components/BookingDetails.module.css +1591 -0
- package/src/components/BookingDetails.tsx +2264 -0
- package/src/components/BookingWidget.tsx +302 -0
- package/src/components/ManageBookingView.tsx +437 -0
- package/src/components/PhoneInputWithCountry.module.css +131 -0
- package/src/components/PhoneInputWithCountry.tsx +44 -0
- package/src/components/PickupLocationDialog.module.css +360 -0
- package/src/components/PickupLocationDialog.tsx +357 -0
- package/src/components/PostBookingDependentAddOnUpsell.module.css +174 -0
- package/src/components/PostBookingDependentAddOnUpsell.tsx +407 -0
- package/src/components/booking/AddOnsSection.module.css +10 -0
- package/src/components/booking/AddOnsSection.tsx +184 -0
- package/src/components/booking/AdminPaymentChoiceModal.tsx +98 -0
- package/src/components/booking/BookingDialog.module.css +643 -0
- package/src/components/booking/BookingDialog.tsx +356 -0
- package/src/components/booking/BookingFlow.tsx +4385 -0
- package/src/components/booking/BookingFlowCollage.module.css +148 -0
- package/src/components/booking/BookingFlowCollage.tsx +184 -0
- package/src/components/booking/BookingFlowPlaceholder.module.css +27 -0
- package/src/components/booking/BookingFlowPlaceholder.tsx +25 -0
- package/src/components/booking/BookingFlowPreview.tsx +51 -0
- package/src/components/booking/BookingProductGrid.module.css +359 -0
- package/src/components/booking/BookingProductGrid.tsx +497 -0
- package/src/components/booking/Calendar.module.css +616 -0
- package/src/components/booking/Calendar.tsx +1123 -0
- package/src/components/booking/CancellationPolicySelector.module.css +124 -0
- package/src/components/booking/CancellationPolicySelector.tsx +142 -0
- package/src/components/booking/ChangeBookingDialog.tsx +562 -0
- package/src/components/booking/CheckoutForm.module.css +244 -0
- package/src/components/booking/CheckoutForm.tsx +364 -0
- package/src/components/booking/CheckoutModal.tsx +451 -0
- package/src/components/booking/CurrencySwitcher.tsx +81 -0
- package/src/components/booking/DapFlowCollage.tsx +88 -0
- package/src/components/booking/DapTourDescription.tsx +35 -0
- package/src/components/booking/DependentAddOnBookingDialog.tsx +1350 -0
- package/src/components/booking/DependentAddOnPaymentForm.tsx +124 -0
- package/src/components/booking/ErrorBoundary.tsx +63 -0
- package/src/components/booking/InfoTooltip.tsx +108 -0
- package/src/components/booking/ItineraryBox.module.css +258 -0
- package/src/components/booking/ItineraryBox.tsx +550 -0
- package/src/components/booking/ItineraryBuilder.tsx +82 -0
- package/src/components/booking/ItineraryPlaceholder.module.css +45 -0
- package/src/components/booking/ItineraryPlaceholder.tsx +26 -0
- package/src/components/booking/MealDrinkAddOnSelector.tsx +338 -0
- package/src/components/booking/PickupLocationSelector.module.css +124 -0
- package/src/components/booking/PickupLocationSelector.tsx +1566 -0
- package/src/components/booking/PickupTimeSelector.module.css +134 -0
- package/src/components/booking/PickupTimeSelector.tsx +112 -0
- package/src/components/booking/PriceBreakdown.tsx +154 -0
- package/src/components/booking/PriceSummary.tsx +234 -0
- package/src/components/booking/PrivateShuttleBookingFlow.module.css +357 -0
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +2662 -0
- package/src/components/booking/PromoCodeInput.module.css +166 -0
- package/src/components/booking/PromoCodeInput.tsx +99 -0
- package/src/components/booking/ReturnTimeSelector.module.css +173 -0
- package/src/components/booking/ReturnTimeSelector.tsx +145 -0
- package/src/components/booking/TermsAcceptance.tsx +111 -0
- package/src/components/booking/TicketSelector.module.css +164 -0
- package/src/components/booking/TicketSelector.tsx +199 -0
- package/src/components/booking/TourDescription.module.css +304 -0
- package/src/components/booking/TourDescription.tsx +273 -0
- package/src/components/booking/booking-flow-ui.ts +38 -0
- package/src/components/booking/booking-flow.css +944 -0
- package/src/components/button.css +245 -0
- package/src/components/button.tsx +152 -0
- package/src/components/colorable-svg.tsx +29 -0
- package/src/components/image.css +29 -0
- package/src/components/image.tsx +113 -0
- package/src/components/partner/PartnerBookingPage.module.css +130 -0
- package/src/components/partner/PartnerBookingPage.tsx +390 -0
- package/src/components/partner/PartnerBookingPageWithBrowserMetadata.tsx +45 -0
- package/src/components/product-tag.module.css +30 -0
- package/src/components/product-tag.tsx +34 -0
- package/src/components/product-theme-pages/image-modal.tsx +248 -0
- package/src/components/product-theme-pages/photo-gallery.module.css +200 -0
- package/src/components/terms/TermsContent.tsx +178 -0
- package/src/components/value-pill.module.css +59 -0
- package/src/components/value-pill.tsx +46 -0
- package/src/constants/images.ts +556 -0
- package/src/constants/pill-values.ts +210 -0
- package/src/constants/products.ts +155 -0
- package/src/contexts/AvailabilitiesCacheContext.tsx +125 -0
- package/src/contexts/BookingAppContext.tsx +134 -0
- package/src/contexts/CompanyContext.tsx +70 -0
- package/src/data/dap-descriptions/session-couples-families-friends.en.json +61 -0
- package/src/data/dap-descriptions/session-elopements.en.json +60 -0
- package/src/data/dap-descriptions/session-proposals.en.json +60 -0
- package/src/data/product-descriptions/afternoon-delight.en.json +35 -0
- package/src/data/product-descriptions/emerald-lake-escape.en.json +68 -0
- package/src/data/product-descriptions/lake-louise-adventure.en.json +74 -0
- package/src/data/product-descriptions/moraine-lake-adventure.en.json +78 -0
- package/src/data/product-descriptions/moraine-lake-sunrise-lake-louise-golden-hour.en.json +65 -0
- package/src/data/product-descriptions/moraine-lake-sunrise.en.json +64 -0
- package/src/data/product-descriptions/private-tour.en.json +80 -0
- package/src/data/product-descriptions/two-lakes-combo.en.json +65 -0
- package/src/data/products-config.json +101 -0
- package/src/hooks/useBookingSourceMetadataFromLocation.ts +21 -0
- package/src/hooks/useIsBookingLaunchLive.ts +49 -0
- package/src/index.ts +79 -0
- package/src/lib/analytics.ts +197 -0
- package/src/lib/booking/booking-source.ts +51 -0
- package/src/lib/booking/checkout-breakdown.ts +69 -0
- package/src/lib/booking/correlation-id.ts +46 -0
- package/src/lib/booking/i18n/config.ts +21 -0
- package/src/lib/booking/i18n/index.tsx +144 -0
- package/src/lib/booking/i18n/messages/en.json +236 -0
- package/src/lib/booking/i18n/messages/fr.json +236 -0
- package/src/lib/booking/itinerary-display.ts +36 -0
- package/src/lib/booking/itinerary-labels.ts +70 -0
- package/src/lib/booking/location-calculations.ts +43 -0
- package/src/lib/booking/location-utils.ts +165 -0
- package/src/lib/booking/map-utils.ts +153 -0
- package/src/lib/booking/marker-icons.ts +113 -0
- package/src/lib/booking/normalize-booking-product-id.ts +21 -0
- package/src/lib/booking/pickup-location-types.ts +25 -0
- package/src/lib/booking/places-api.ts +154 -0
- package/src/lib/booking/pricing.ts +466 -0
- package/src/lib/booking/product-option-id.ts +35 -0
- package/src/lib/booking/source-metadata.ts +226 -0
- package/src/lib/booking/sunday-week.ts +14 -0
- package/src/lib/booking/theme.ts +83 -0
- package/src/lib/booking/trace-context.ts +62 -0
- package/src/lib/booking/utils.ts +9 -0
- package/src/lib/booking-api.ts +1793 -0
- package/src/lib/booking-constants.ts +23 -0
- package/src/lib/booking-ref.ts +13 -0
- package/src/lib/booking-types.ts +36 -0
- package/src/lib/currency.ts +81 -0
- package/src/lib/dap-descriptions.ts +50 -0
- package/src/lib/dap-itinerary-preview.ts +315 -0
- package/src/lib/dependent-add-on-api.ts +434 -0
- package/src/lib/env.ts +96 -0
- package/src/lib/firebase.ts +20 -0
- package/src/lib/job-application-api.ts +83 -0
- package/src/lib/manage-booking-embed-print.ts +16 -0
- package/src/lib/manage-booking-post-checkout.ts +68 -0
- package/src/lib/photo-dap-config.ts +228 -0
- package/src/lib/photo-packages.ts +75 -0
- package/src/lib/pickup/map-utils.ts +56 -0
- package/src/lib/pickup/marker-icons.ts +19 -0
- package/src/lib/product-descriptions.ts +66 -0
- package/src/lib/products-config.ts +73 -0
- package/src/providers/booking-dialog-provider.tsx +282 -0
- package/src/providers/dependent-add-on-dialog-provider.tsx +105 -0
- package/src/radius.css +5 -0
- package/src/spacing.css +7 -0
- package/src/strings/en.json +1774 -0
- package/src/strings/es.json +1573 -0
- package/src/strings/fr.json +1573 -0
- package/src/strings/index.js +23 -0
- package/src/text-style.css +56 -0
- package/src/utils/currency-converter.ts +101 -0
- package/tsconfig.json +8 -2
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef, type ReactNode } from 'react';
|
|
4
|
+
import { createPortal } from 'react-dom';
|
|
5
|
+
import { loadStripe } from '@stripe/stripe-js';
|
|
6
|
+
import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';
|
|
7
|
+
import { ENV } from '@/lib/env';
|
|
8
|
+
import { storePendingPurchase, trackBeginCheckout } from '@/lib/analytics';
|
|
9
|
+
import { formatCurrencyAmount } from '@/lib/currency';
|
|
10
|
+
import { PriceSummary, type PriceSummaryLine } from './PriceSummary';
|
|
11
|
+
import type { Currency } from './CurrencySwitcher';
|
|
12
|
+
import type { Locale } from '@/lib/booking/i18n/config';
|
|
13
|
+
import type { OrderSummaryTicketLine, OrderSummaryFeeLine, PriceBreakdown as PriceBreakdownType } from '@/lib/booking/pricing';
|
|
14
|
+
|
|
15
|
+
export interface CheckoutModalLineItem {
|
|
16
|
+
line: OrderSummaryTicketLine;
|
|
17
|
+
breakdown: PriceBreakdownType | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CheckoutModalProps {
|
|
21
|
+
open: boolean;
|
|
22
|
+
onClose: () => void;
|
|
23
|
+
onPaymentSubmitStart?: () => void;
|
|
24
|
+
onPaymentSubmitError?: () => void;
|
|
25
|
+
clientSecret: string;
|
|
26
|
+
reservationReference: string;
|
|
27
|
+
reservationExpiration?: string;
|
|
28
|
+
/** Last name for Stripe return_url so success page can poll and redirect to /manage */
|
|
29
|
+
customerLastName?: string;
|
|
30
|
+
/** When set (e.g. provider-dashboard), used as Stripe return_url instead of /manage */
|
|
31
|
+
successUrlOverride?: string;
|
|
32
|
+
ticketLines: CheckoutModalLineItem[];
|
|
33
|
+
feeLineItems: OrderSummaryFeeLine[];
|
|
34
|
+
returnPriceAdjustment: number;
|
|
35
|
+
cancellationPolicyFee?: number;
|
|
36
|
+
cancellationPolicyLabel?: string;
|
|
37
|
+
subtotal: number;
|
|
38
|
+
tax: number;
|
|
39
|
+
total: number;
|
|
40
|
+
promoDiscountAmount?: number;
|
|
41
|
+
discountLabel?: string | null;
|
|
42
|
+
totalQuantity: number;
|
|
43
|
+
isTaxIncludedInPrice: boolean;
|
|
44
|
+
taxRate?: number;
|
|
45
|
+
currency: Currency;
|
|
46
|
+
locale: Locale;
|
|
47
|
+
t: (key: string, params?: Record<string, string | number>) => string;
|
|
48
|
+
/** When true, show deposit messaging (balance charged N days before or pay earlier). */
|
|
49
|
+
isDepositPayment?: boolean;
|
|
50
|
+
/** Days before booking to charge balance (e.g. 7). */
|
|
51
|
+
balanceChargeDaysBefore?: number;
|
|
52
|
+
/**
|
|
53
|
+
* When `clientSecret` is not yet available, show this instead of the loading placeholder
|
|
54
|
+
* (e.g. collect email / last name before creating a PaymentIntent).
|
|
55
|
+
*/
|
|
56
|
+
prePaymentPanel?: ReactNode;
|
|
57
|
+
changeTotals?: {
|
|
58
|
+
previousTotal: number;
|
|
59
|
+
newTotal: number;
|
|
60
|
+
differenceTotal: number;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const stripePromise = ENV.STRIPE_PUBLISHABLE_KEY
|
|
65
|
+
? loadStripe(ENV.STRIPE_PUBLISHABLE_KEY)
|
|
66
|
+
: null;
|
|
67
|
+
|
|
68
|
+
function CheckoutForm({
|
|
69
|
+
successUrl,
|
|
70
|
+
onClose,
|
|
71
|
+
onPaymentSubmitStart,
|
|
72
|
+
onPaymentSubmitError,
|
|
73
|
+
t,
|
|
74
|
+
total,
|
|
75
|
+
currency,
|
|
76
|
+
locale,
|
|
77
|
+
}: {
|
|
78
|
+
successUrl: string;
|
|
79
|
+
onClose: () => void;
|
|
80
|
+
onPaymentSubmitStart?: () => void;
|
|
81
|
+
onPaymentSubmitError?: () => void;
|
|
82
|
+
t: (key: string) => string;
|
|
83
|
+
total: number;
|
|
84
|
+
currency: Currency;
|
|
85
|
+
locale: Locale;
|
|
86
|
+
}) {
|
|
87
|
+
const stripe = useStripe();
|
|
88
|
+
const elements = useElements();
|
|
89
|
+
const [loading, setLoading] = useState(false);
|
|
90
|
+
const [error, setError] = useState<string | null>(null);
|
|
91
|
+
|
|
92
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
if (!stripe || !elements) return;
|
|
95
|
+
setLoading(true);
|
|
96
|
+
setError(null);
|
|
97
|
+
const { error: submitError } = await elements.submit();
|
|
98
|
+
if (submitError) {
|
|
99
|
+
setError(submitError.message ?? 'Validation failed');
|
|
100
|
+
setLoading(false);
|
|
101
|
+
onPaymentSubmitError?.();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
onPaymentSubmitStart?.();
|
|
105
|
+
// Store before redirect so success page can fire purchase event
|
|
106
|
+
storePendingPurchase(total, currency);
|
|
107
|
+
const { error: confirmError } = await stripe.confirmPayment({
|
|
108
|
+
elements,
|
|
109
|
+
confirmParams: {
|
|
110
|
+
return_url: successUrl,
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
if (confirmError) {
|
|
114
|
+
setError(confirmError.message ?? 'Payment failed');
|
|
115
|
+
onPaymentSubmitError?.();
|
|
116
|
+
}
|
|
117
|
+
setLoading(false);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
122
|
+
<PaymentElement />
|
|
123
|
+
{error && (
|
|
124
|
+
<p className="text-sm text-red-600" role="alert">
|
|
125
|
+
{error}
|
|
126
|
+
</p>
|
|
127
|
+
)}
|
|
128
|
+
<div className="flex gap-3 pt-2">
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
onClick={onClose}
|
|
132
|
+
className="flex-1 py-3 px-4 border border-stone-300 text-stone-700 rounded-lg hover:bg-stone-50"
|
|
133
|
+
>
|
|
134
|
+
{t('common.cancel') || 'Cancel'}
|
|
135
|
+
</button>
|
|
136
|
+
<button
|
|
137
|
+
type="submit"
|
|
138
|
+
disabled={!stripe || loading}
|
|
139
|
+
className="flex-1 py-3 px-4 bg-emerald-600 text-white font-semibold rounded-lg hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
140
|
+
>
|
|
141
|
+
{loading ? (t('booking.paying') || 'Paying...') : `${t('booking.payNow') || 'Pay now'} (${formatCurrencyAmount(total, currency, locale)})`}
|
|
142
|
+
</button>
|
|
143
|
+
</div>
|
|
144
|
+
</form>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function CheckoutModal({
|
|
149
|
+
open,
|
|
150
|
+
onClose,
|
|
151
|
+
onPaymentSubmitStart,
|
|
152
|
+
onPaymentSubmitError,
|
|
153
|
+
clientSecret,
|
|
154
|
+
reservationReference,
|
|
155
|
+
reservationExpiration,
|
|
156
|
+
customerLastName,
|
|
157
|
+
successUrlOverride,
|
|
158
|
+
ticketLines,
|
|
159
|
+
feeLineItems,
|
|
160
|
+
returnPriceAdjustment,
|
|
161
|
+
cancellationPolicyFee = 0,
|
|
162
|
+
cancellationPolicyLabel,
|
|
163
|
+
subtotal,
|
|
164
|
+
tax,
|
|
165
|
+
total,
|
|
166
|
+
totalQuantity,
|
|
167
|
+
promoDiscountAmount = 0,
|
|
168
|
+
discountLabel,
|
|
169
|
+
isTaxIncludedInPrice,
|
|
170
|
+
taxRate = 0,
|
|
171
|
+
currency,
|
|
172
|
+
locale,
|
|
173
|
+
t,
|
|
174
|
+
isDepositPayment = false,
|
|
175
|
+
balanceChargeDaysBefore = 7,
|
|
176
|
+
prePaymentPanel,
|
|
177
|
+
changeTotals,
|
|
178
|
+
}: CheckoutModalProps) {
|
|
179
|
+
const baseUrl = typeof window !== 'undefined' ? window.location.origin : '';
|
|
180
|
+
const manageParams = new URLSearchParams({ reservationRef: reservationReference });
|
|
181
|
+
if (customerLastName?.trim()) manageParams.set('lastName', customerLastName.trim());
|
|
182
|
+
if (!successUrlOverride) manageParams.set('booking_complete', '1');
|
|
183
|
+
const successUrl = successUrlOverride ?? `${baseUrl}/manage-booking?${manageParams.toString()}`;
|
|
184
|
+
|
|
185
|
+
const hasFiredBeginCheckout = useRef(false);
|
|
186
|
+
/** Ensures we only fire expiry close once per modal open (avoids Strict Mode / repeated 0 remainingMs). */
|
|
187
|
+
const holdExpiryCloseSentRef = useRef(false);
|
|
188
|
+
const [nowMs, setNowMs] = useState(() => Date.now());
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
if (open && !hasFiredBeginCheckout.current) {
|
|
191
|
+
hasFiredBeginCheckout.current = true;
|
|
192
|
+
const items = ticketLines.map(({ line }) => ({
|
|
193
|
+
id: line.category,
|
|
194
|
+
name: line.category,
|
|
195
|
+
qty: line.qty,
|
|
196
|
+
price: line.itemTotal,
|
|
197
|
+
}));
|
|
198
|
+
trackBeginCheckout(total, currency, items);
|
|
199
|
+
}
|
|
200
|
+
if (!open) hasFiredBeginCheckout.current = false;
|
|
201
|
+
}, [open, total, currency, ticketLines]);
|
|
202
|
+
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
if (!open || !reservationExpiration) return;
|
|
205
|
+
const timer = window.setInterval(() => setNowMs(Date.now()), 1000);
|
|
206
|
+
return () => window.clearInterval(timer);
|
|
207
|
+
}, [open, reservationExpiration]);
|
|
208
|
+
|
|
209
|
+
const expirationMs = reservationExpiration ? Date.parse(reservationExpiration) : NaN;
|
|
210
|
+
const hasValidExpiration = Number.isFinite(expirationMs);
|
|
211
|
+
const remainingMs = hasValidExpiration ? Math.max(0, expirationMs - nowMs) : 0;
|
|
212
|
+
const remainingTotalSeconds = Math.ceil(remainingMs / 1000);
|
|
213
|
+
const remainingMinutes = Math.floor(remainingTotalSeconds / 60);
|
|
214
|
+
const remainingSeconds = remainingTotalSeconds % 60;
|
|
215
|
+
const remainingDisplay = `${String(remainingMinutes).padStart(2, '0')}:${String(remainingSeconds).padStart(2, '0')}`;
|
|
216
|
+
const holdTone =
|
|
217
|
+
remainingTotalSeconds <= 120
|
|
218
|
+
? 'critical'
|
|
219
|
+
: remainingTotalSeconds <= 300
|
|
220
|
+
? 'warning'
|
|
221
|
+
: 'normal';
|
|
222
|
+
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
if (!open) {
|
|
225
|
+
holdExpiryCloseSentRef.current = false;
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (!hasValidExpiration || remainingMs > 0) return;
|
|
229
|
+
if (holdExpiryCloseSentRef.current) return;
|
|
230
|
+
holdExpiryCloseSentRef.current = true;
|
|
231
|
+
onClose();
|
|
232
|
+
}, [open, hasValidExpiration, remainingMs, onClose]);
|
|
233
|
+
|
|
234
|
+
if (!open) return null;
|
|
235
|
+
|
|
236
|
+
if (!ENV.STRIPE_PUBLISHABLE_KEY) {
|
|
237
|
+
const noStripe = (
|
|
238
|
+
<div
|
|
239
|
+
className="booking-flow-root booking-flow-preflight fixed inset-0 z-[10050] flex items-center justify-center p-4 bg-black/50"
|
|
240
|
+
style={{ zIndex: 100_000 }}
|
|
241
|
+
>
|
|
242
|
+
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6">
|
|
243
|
+
<h3 className="text-lg font-semibold text-stone-900 mb-2">
|
|
244
|
+
{t('booking.checkout') || 'Checkout'}
|
|
245
|
+
</h3>
|
|
246
|
+
<p className="text-stone-600 mb-4">
|
|
247
|
+
{t('booking.paymentNotConfigured') || 'Payment is not configured. Please use the standard checkout.'}
|
|
248
|
+
</p>
|
|
249
|
+
<button
|
|
250
|
+
type="button"
|
|
251
|
+
onClick={onClose}
|
|
252
|
+
className="w-full py-3 bg-stone-200 text-stone-800 font-medium rounded-lg hover:bg-stone-300"
|
|
253
|
+
>
|
|
254
|
+
{t('common.close') || 'Close'}
|
|
255
|
+
</button>
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
);
|
|
259
|
+
return typeof document !== 'undefined' ? createPortal(noStripe, document.body) : null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const options = {
|
|
263
|
+
clientSecret,
|
|
264
|
+
appearance: {
|
|
265
|
+
theme: 'stripe' as const,
|
|
266
|
+
variables: {
|
|
267
|
+
colorPrimary: '#059669',
|
|
268
|
+
borderRadius: '8px',
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const checkout = (
|
|
274
|
+
<div
|
|
275
|
+
className="booking-flow-root booking-flow-preflight fixed inset-0 z-[10050] flex items-center justify-center p-4 bg-black/50"
|
|
276
|
+
style={{ zIndex: 100_000 }}
|
|
277
|
+
>
|
|
278
|
+
<div className="bg-white rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-hidden flex flex-col">
|
|
279
|
+
<div className="p-6 border-b border-stone-200 flex-shrink-0">
|
|
280
|
+
<div className="flex justify-between items-start">
|
|
281
|
+
<h3 className="text-lg font-semibold text-stone-900">
|
|
282
|
+
{t('booking.reviewAndPay') || 'Review & pay'}
|
|
283
|
+
</h3>
|
|
284
|
+
<button
|
|
285
|
+
type="button"
|
|
286
|
+
onClick={onClose}
|
|
287
|
+
className="text-stone-400 hover:text-stone-600 p-1"
|
|
288
|
+
aria-label={t('common.close') || 'Close'}
|
|
289
|
+
>
|
|
290
|
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
291
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
292
|
+
</svg>
|
|
293
|
+
</button>
|
|
294
|
+
</div>
|
|
295
|
+
|
|
296
|
+
{/* Order summary — shared PriceSummary component */}
|
|
297
|
+
<div className="mt-4 min-w-0">
|
|
298
|
+
<PriceSummary
|
|
299
|
+
lines={[
|
|
300
|
+
...ticketLines.map(({ line, breakdown }): PriceSummaryLine => ({
|
|
301
|
+
kind: 'ticket',
|
|
302
|
+
category: line.category,
|
|
303
|
+
qty: line.qty,
|
|
304
|
+
itemTotal: line.itemTotal,
|
|
305
|
+
breakdown,
|
|
306
|
+
})),
|
|
307
|
+
...(returnPriceAdjustment !== 0
|
|
308
|
+
? [
|
|
309
|
+
{
|
|
310
|
+
kind: 'line' as const,
|
|
311
|
+
label: `${t('booking.returnOption')} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`,
|
|
312
|
+
amount: returnPriceAdjustment,
|
|
313
|
+
type: 'return',
|
|
314
|
+
},
|
|
315
|
+
]
|
|
316
|
+
: []),
|
|
317
|
+
...(cancellationPolicyFee > 0
|
|
318
|
+
? [
|
|
319
|
+
{
|
|
320
|
+
kind: 'line' as const,
|
|
321
|
+
label: cancellationPolicyLabel ?? t('booking.flexibleCancellation') ?? 'Flexible cancellation',
|
|
322
|
+
amount: cancellationPolicyFee,
|
|
323
|
+
type: 'cancellation',
|
|
324
|
+
},
|
|
325
|
+
]
|
|
326
|
+
: []),
|
|
327
|
+
...feeLineItems.map((fee) => {
|
|
328
|
+
const isMoraineLakeRoadAccessFee = fee.name.toLowerCase().includes('moraine') && (fee.name.toLowerCase().includes('access') || fee.name.toLowerCase().includes('road') || fee.name.toLowerCase().includes('license'));
|
|
329
|
+
return {
|
|
330
|
+
kind: 'line' as const,
|
|
331
|
+
label: `${fee.name} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`,
|
|
332
|
+
amount: fee.totalAmount,
|
|
333
|
+
type: 'fee',
|
|
334
|
+
tooltip: isMoraineLakeRoadAccessFee
|
|
335
|
+
? "Since 2025, Parks Canada charges a per-trip fee for License of Occupation. Based on our capacity, this per-person fee contributes towards Parks Canada's Moraine Lake Road operations."
|
|
336
|
+
: undefined,
|
|
337
|
+
};
|
|
338
|
+
}),
|
|
339
|
+
]}
|
|
340
|
+
total={total}
|
|
341
|
+
currency={currency}
|
|
342
|
+
locale={locale}
|
|
343
|
+
subtotal={subtotal !== total ? subtotal : undefined}
|
|
344
|
+
discountAmount={promoDiscountAmount ?? 0}
|
|
345
|
+
discountLabel={discountLabel}
|
|
346
|
+
taxAmount={!isTaxIncludedInPrice && tax > 0 ? tax : 0}
|
|
347
|
+
taxRate={taxRate}
|
|
348
|
+
size="sm"
|
|
349
|
+
t={t}
|
|
350
|
+
depositMode={isDepositPayment && subtotal > total ? { totalLabel: t('booking.deposit') || 'Deposit', balanceAmount: subtotal - total, fullTotalAmount: subtotal } : undefined}
|
|
351
|
+
hideSubtotal={isDepositPayment}
|
|
352
|
+
extraBetweenTaxAndTotal={
|
|
353
|
+
changeTotals ? (
|
|
354
|
+
<div className="space-y-1 pt-2 border-t border-stone-200">
|
|
355
|
+
<div className="flex justify-between gap-3 min-w-0 text-sm">
|
|
356
|
+
<span className="text-stone-600 min-w-0 truncate">New Total</span>
|
|
357
|
+
<span className="flex-shrink-0 whitespace-nowrap font-medium text-stone-700">
|
|
358
|
+
{formatCurrencyAmount(changeTotals.newTotal, currency, locale)}
|
|
359
|
+
</span>
|
|
360
|
+
</div>
|
|
361
|
+
<div className="flex justify-between gap-3 min-w-0 text-sm">
|
|
362
|
+
<span className="text-stone-600 min-w-0 truncate">Previous Total</span>
|
|
363
|
+
<span className="flex-shrink-0 whitespace-nowrap font-medium text-stone-700">
|
|
364
|
+
{formatCurrencyAmount(changeTotals.previousTotal, currency, locale)}
|
|
365
|
+
</span>
|
|
366
|
+
</div>
|
|
367
|
+
</div>
|
|
368
|
+
) : undefined
|
|
369
|
+
}
|
|
370
|
+
totalLabel={changeTotals ? 'New Booking Difference' : undefined}
|
|
371
|
+
/>
|
|
372
|
+
</div>
|
|
373
|
+
|
|
374
|
+
{/* Deposit payment notice */}
|
|
375
|
+
{isDepositPayment && (
|
|
376
|
+
<div className="deposit-notice mt-4 p-3 bg-amber-50 border border-amber-200 rounded-lg text-amber-800">
|
|
377
|
+
<p className="text-xs font-medium">
|
|
378
|
+
{(t('booking.depositPaymentNotice') !== 'booking.depositPaymentNotice' ? t('booking.depositPaymentNotice') : null) ?? "You're paying the deposit today."}
|
|
379
|
+
</p>
|
|
380
|
+
<p className="mt-1 text-xs text-amber-700/90">
|
|
381
|
+
{balanceChargeDaysBefore > 0
|
|
382
|
+
? (t('booking.balanceChargeNotice', { days: balanceChargeDaysBefore }) !== 'booking.balanceChargeNotice'
|
|
383
|
+
? t('booking.balanceChargeNotice', { days: balanceChargeDaysBefore })
|
|
384
|
+
: `The remaining balance will be charged ${balanceChargeDaysBefore} days before your booking. You can also pay it earlier from your Manage Booking page.`)
|
|
385
|
+
: (t('booking.balancePayEarlier') !== 'booking.balancePayEarlier'
|
|
386
|
+
? t('booking.balancePayEarlier')
|
|
387
|
+
: 'You can pay the remaining balance anytime from your Manage Booking page.')}
|
|
388
|
+
</p>
|
|
389
|
+
</div>
|
|
390
|
+
)}
|
|
391
|
+
{hasValidExpiration && (
|
|
392
|
+
<div
|
|
393
|
+
className={`mt-4 p-3 rounded-lg border ${
|
|
394
|
+
holdTone === 'critical'
|
|
395
|
+
? 'bg-red-50 border-red-300'
|
|
396
|
+
: holdTone === 'warning'
|
|
397
|
+
? 'bg-amber-50 border-amber-300'
|
|
398
|
+
: 'bg-emerald-50 border-emerald-200'
|
|
399
|
+
}`}
|
|
400
|
+
>
|
|
401
|
+
<p
|
|
402
|
+
className={`text-xs font-semibold ${
|
|
403
|
+
holdTone === 'critical'
|
|
404
|
+
? 'text-red-900'
|
|
405
|
+
: holdTone === 'warning'
|
|
406
|
+
? 'text-amber-900'
|
|
407
|
+
: 'text-emerald-900'
|
|
408
|
+
}`}
|
|
409
|
+
>
|
|
410
|
+
Your reservation is being held for {remainingDisplay}
|
|
411
|
+
</p>
|
|
412
|
+
<p
|
|
413
|
+
className={`mt-1 text-xs ${
|
|
414
|
+
holdTone === 'critical'
|
|
415
|
+
? 'text-red-800'
|
|
416
|
+
: holdTone === 'warning'
|
|
417
|
+
? 'text-amber-800'
|
|
418
|
+
: 'text-emerald-800'
|
|
419
|
+
}`}
|
|
420
|
+
>
|
|
421
|
+
Closing this payment dialog releases the hold and you will need to reserve again.
|
|
422
|
+
</p>
|
|
423
|
+
</div>
|
|
424
|
+
)}
|
|
425
|
+
</div>
|
|
426
|
+
|
|
427
|
+
<div className="p-6 overflow-y-auto flex-1">
|
|
428
|
+
{stripePromise && clientSecret ? (
|
|
429
|
+
<Elements stripe={stripePromise} options={options}>
|
|
430
|
+
<CheckoutForm
|
|
431
|
+
successUrl={successUrl}
|
|
432
|
+
onClose={onClose}
|
|
433
|
+
onPaymentSubmitStart={onPaymentSubmitStart}
|
|
434
|
+
onPaymentSubmitError={onPaymentSubmitError}
|
|
435
|
+
t={t}
|
|
436
|
+
total={total}
|
|
437
|
+
currency={currency}
|
|
438
|
+
locale={locale}
|
|
439
|
+
/>
|
|
440
|
+
</Elements>
|
|
441
|
+
) : prePaymentPanel ? (
|
|
442
|
+
prePaymentPanel
|
|
443
|
+
) : (
|
|
444
|
+
<p className="text-stone-500">{t('booking.loadingPayment') || 'Loading payment form...'}</p>
|
|
445
|
+
)}
|
|
446
|
+
</div>
|
|
447
|
+
</div>
|
|
448
|
+
</div>
|
|
449
|
+
);
|
|
450
|
+
return typeof document !== 'undefined' ? createPortal(checkout, document.body) : null;
|
|
451
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
// Available currencies from pricing.json config
|
|
6
|
+
export const CURRENCIES = ['CAD', 'USD', 'EUR', 'GBP', 'AUD'] as const;
|
|
7
|
+
export type Currency = typeof CURRENCIES[number];
|
|
8
|
+
|
|
9
|
+
const CURRENCY_NAMES: Record<Currency, string> = {
|
|
10
|
+
CAD: 'CAD',
|
|
11
|
+
USD: 'USD',
|
|
12
|
+
EUR: 'EUR',
|
|
13
|
+
GBP: 'GBP',
|
|
14
|
+
AUD: 'AUD',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const DEFAULT_CURRENCY: Currency = 'CAD';
|
|
18
|
+
|
|
19
|
+
export function useCurrency() {
|
|
20
|
+
const [currency, setCurrencyState] = useState<Currency>(DEFAULT_CURRENCY);
|
|
21
|
+
|
|
22
|
+
// Load from localStorage after hydration to avoid SSR mismatch
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
try {
|
|
25
|
+
const saved = localStorage.getItem('booking-currency') as Currency | null;
|
|
26
|
+
if (saved && CURRENCIES.includes(saved)) {
|
|
27
|
+
setCurrencyState(saved);
|
|
28
|
+
}
|
|
29
|
+
} catch (error) {
|
|
30
|
+
console.warn('Failed to read currency from localStorage:', error);
|
|
31
|
+
}
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
const setCurrency = (newCurrency: Currency) => {
|
|
35
|
+
if (!CURRENCIES.includes(newCurrency)) {
|
|
36
|
+
console.warn(`Invalid currency: ${newCurrency}. Falling back to ${DEFAULT_CURRENCY}`);
|
|
37
|
+
setCurrencyState(DEFAULT_CURRENCY);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
setCurrencyState(newCurrency);
|
|
42
|
+
if (typeof window !== 'undefined') {
|
|
43
|
+
try {
|
|
44
|
+
localStorage.setItem('booking-currency', newCurrency);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
console.warn('Failed to save currency to localStorage:', error);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return { currency, setCurrency };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface CurrencySwitcherProps {
|
|
55
|
+
currency: Currency;
|
|
56
|
+
onCurrencyChange: (currency: Currency) => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function CurrencySwitcher({ currency, onCurrencyChange }: CurrencySwitcherProps) {
|
|
60
|
+
return (
|
|
61
|
+
<div className="flex items-center gap-1.5 sm:gap-2">
|
|
62
|
+
<span className="text-xs sm:text-sm shrink-0" style={{ color: 'var(--booking-text-muted, #a8a29e)' }}>Currency:</span>
|
|
63
|
+
<div className="flex flex-wrap gap-0.5 sm:gap-1 rounded-lg overflow-hidden" style={{ border: '1px solid var(--booking-border)', backgroundColor: 'var(--booking-header-bg, #1c1917)' }}>
|
|
64
|
+
{CURRENCIES.map((curr) => (
|
|
65
|
+
<button
|
|
66
|
+
key={curr}
|
|
67
|
+
onClick={() => onCurrencyChange(curr)}
|
|
68
|
+
className={`px-2 py-1 sm:px-3 sm:py-1.5 text-xs sm:text-sm font-medium transition-colors ${currency !== curr ? 'hover:opacity-80' : ''}`}
|
|
69
|
+
style={currency === curr
|
|
70
|
+
? { backgroundColor: 'var(--booking-primary)', color: 'white' }
|
|
71
|
+
: { backgroundColor: 'transparent', color: 'var(--booking-text-muted, #a8a29e)' }
|
|
72
|
+
}
|
|
73
|
+
>
|
|
74
|
+
{CURRENCY_NAMES[curr]}
|
|
75
|
+
</button>
|
|
76
|
+
))}
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from 'react';
|
|
4
|
+
import ViaViaImage from '@/components/image';
|
|
5
|
+
import ImageModal from '@/components/product-theme-pages/image-modal';
|
|
6
|
+
import styles from './BookingFlowCollage.module.css';
|
|
7
|
+
|
|
8
|
+
export interface DapFlowCollageProps {
|
|
9
|
+
/** Bunny CDN image IDs — first is the tall hero, next four fill the asymmetric grid */
|
|
10
|
+
imageIds: string[];
|
|
11
|
+
altPrefix?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Hero (left) + four-tile grid (right), same layout as BookingFlowCollage without video */
|
|
15
|
+
export function DapFlowCollage({ imageIds, altPrefix = 'Experience' }: DapFlowCollageProps) {
|
|
16
|
+
const slots = useMemo(() => {
|
|
17
|
+
const raw = imageIds.map((id) => id.trim()).filter(Boolean);
|
|
18
|
+
if (raw.length === 0) return [];
|
|
19
|
+
const out = [...raw];
|
|
20
|
+
while (out.length < 5) {
|
|
21
|
+
out.push(out[0]!);
|
|
22
|
+
}
|
|
23
|
+
return out.slice(0, 5);
|
|
24
|
+
}, [imageIds]);
|
|
25
|
+
|
|
26
|
+
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
|
27
|
+
|
|
28
|
+
const imageItems = useMemo(
|
|
29
|
+
() => slots.map((id, i) => ({ id, alt: `${altPrefix} - ${i + 1}` })),
|
|
30
|
+
[slots, altPrefix]
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
if (slots.length === 0) return null;
|
|
34
|
+
|
|
35
|
+
const heroId = slots[0]!;
|
|
36
|
+
const gridIds = slots.slice(1, 5);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className={styles.collage}>
|
|
40
|
+
<div className={styles.videoSlot}>
|
|
41
|
+
<div className={styles.videoWrapper}>
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
className={styles.gridCell}
|
|
45
|
+
onClick={() => setSelectedIndex(0)}
|
|
46
|
+
aria-label="View featured image"
|
|
47
|
+
>
|
|
48
|
+
<ViaViaImage imageId={heroId} alt={`${altPrefix} - featured`} context="GALLERY" />
|
|
49
|
+
</button>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
<div className={styles.imageGrid}>
|
|
53
|
+
{gridIds.map((id, i) => (
|
|
54
|
+
<button
|
|
55
|
+
key={`${id}-${i}`}
|
|
56
|
+
type="button"
|
|
57
|
+
className={styles.gridCell}
|
|
58
|
+
onClick={() => setSelectedIndex(i + 1)}
|
|
59
|
+
aria-label={`View image ${i + 2}`}
|
|
60
|
+
>
|
|
61
|
+
<ViaViaImage imageId={id} alt={`${altPrefix} - ${i + 2}`} context="GALLERY" />
|
|
62
|
+
</button>
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
{selectedIndex !== null && imageItems[selectedIndex] && (
|
|
67
|
+
<ImageModal
|
|
68
|
+
selectedImage={imageItems[selectedIndex]!}
|
|
69
|
+
currentIndex={selectedIndex}
|
|
70
|
+
totalImages={imageItems.length}
|
|
71
|
+
images={imageItems}
|
|
72
|
+
onClose={() => setSelectedIndex(null)}
|
|
73
|
+
onNext={() => {
|
|
74
|
+
if (selectedIndex < imageItems.length - 1) {
|
|
75
|
+
setSelectedIndex(selectedIndex + 1);
|
|
76
|
+
}
|
|
77
|
+
}}
|
|
78
|
+
onPrevious={() => {
|
|
79
|
+
if (selectedIndex > 0) {
|
|
80
|
+
setSelectedIndex(selectedIndex - 1);
|
|
81
|
+
}
|
|
82
|
+
}}
|
|
83
|
+
overlayZIndex={10000}
|
|
84
|
+
/>
|
|
85
|
+
)}
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
import { TourDescription } from '@/components/booking/TourDescription';
|
|
5
|
+
import { useLocale, useTranslations } from '@/lib/booking/i18n';
|
|
6
|
+
import { getDapDescription } from '@/lib/dap-descriptions';
|
|
7
|
+
import type { PhotoDapSlug } from '@/lib/photo-dap-config';
|
|
8
|
+
|
|
9
|
+
export function DapTourDescription({
|
|
10
|
+
slug,
|
|
11
|
+
locale: localeProp,
|
|
12
|
+
}: {
|
|
13
|
+
slug: PhotoDapSlug;
|
|
14
|
+
locale?: string;
|
|
15
|
+
}) {
|
|
16
|
+
const { t } = useTranslations();
|
|
17
|
+
const { locale: contextLocale } = useLocale();
|
|
18
|
+
const locale = localeProp ?? contextLocale;
|
|
19
|
+
|
|
20
|
+
const content = useMemo(() => getDapDescription(slug, locale), [slug, locale]);
|
|
21
|
+
|
|
22
|
+
if (!content || (content.paragraphs.length === 0 && (content.sections?.length ?? 0) === 0)) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<TourDescription
|
|
28
|
+
paragraphs={content.paragraphs}
|
|
29
|
+
review={content.review}
|
|
30
|
+
sections={content.sections}
|
|
31
|
+
defaultExpanded={false}
|
|
32
|
+
toggleLabel={t('booking.seeFullAddOnDescription')}
|
|
33
|
+
/>
|
|
34
|
+
);
|
|
35
|
+
}
|