@ticketboothapp/booking 0.1.11 → 0.1.13
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 +2 -1
- package/src/app/photo-sessions/photo-packages.ts +75 -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 +2072 -354
- package/src/components/BookingWidget.tsx +28 -248
- package/src/components/JobApplicationDialog.module.css +440 -0
- package/src/components/JobApplicationDialog.tsx +620 -0
- package/src/components/ManageBookingView.tsx +28 -36
- 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/PickupLocationMap.tsx +110 -0
- package/src/components/PostBookingDependentAddOnUpsell.module.css +174 -0
- package/src/components/PostBookingDependentAddOnUpsell.tsx +407 -0
- package/src/components/accordion.css +27 -0
- package/src/components/accordion.tsx +29 -0
- package/src/components/analytics/AnalyticsConsentRestore.tsx +19 -0
- package/src/components/analytics/AnalyticsScripts.tsx +106 -0
- package/src/components/analytics/CookieConsentBanner.css +86 -0
- package/src/components/analytics/CookieConsentBanner.tsx +102 -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/{Calendar.tsx → booking/Calendar.tsx} +464 -247
- 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/{CheckoutModal.tsx → booking/CheckoutModal.tsx} +176 -19
- 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/InfoTooltip.tsx +108 -0
- package/src/components/booking/ItineraryBox.module.css +258 -0
- package/src/components/booking/ItineraryBox.tsx +550 -0
- package/src/components/{ItineraryBuilder.tsx → booking/ItineraryBuilder.tsx} +1 -2
- package/src/components/booking/ItineraryPlaceholder.module.css +45 -0
- package/src/components/booking/ItineraryPlaceholder.tsx +26 -0
- package/src/components/{MealDrinkAddOnSelector.tsx → booking/MealDrinkAddOnSelector.tsx} +21 -13
- package/src/components/booking/PickupLocationSelector.module.css +124 -0
- package/src/components/{PickupLocationSelector.tsx → booking/PickupLocationSelector.tsx} +315 -290
- package/src/components/booking/PickupTimeSelector.module.css +134 -0
- package/src/components/booking/PickupTimeSelector.tsx +112 -0
- package/src/components/{PriceBreakdown.tsx → booking/PriceBreakdown.tsx} +3 -3
- package/src/components/{PriceSummary.tsx → booking/PriceSummary.tsx} +51 -28
- 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/{TermsAcceptance.tsx → booking/TermsAcceptance.tsx} +9 -8
- 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 +15 -1
- package/src/components/booking/booking-flow.css +944 -0
- package/src/components/bottom-sheet.module.css +78 -0
- package/src/components/bottom-sheet.tsx +60 -0
- package/src/components/breadcrumb.module.css +40 -0
- package/src/components/breadcrumb.tsx +36 -0
- package/src/components/button.css +245 -0
- package/src/components/button.tsx +152 -0
- package/src/components/client-bottom-sheet.tsx +14 -0
- package/src/components/colorable-svg.tsx +29 -0
- package/src/components/conditional-footer.tsx +27 -0
- package/src/components/contact-us.module.css +147 -0
- package/src/components/contact-us.tsx +49 -0
- package/src/components/email-signup.css +151 -0
- package/src/components/email-signup.tsx +63 -0
- package/src/components/faq-wrapper.module.css +47 -0
- package/src/components/faq-wrapper.tsx +15 -0
- package/src/components/footer.css +187 -0
- package/src/components/footer.tsx +143 -0
- package/src/components/global-simple-modal.tsx +33 -0
- package/src/components/google-review-summary.module.css +77 -0
- package/src/components/google-review-summary.tsx +50 -0
- package/src/components/hero-image.css +13 -0
- package/src/components/hero-image.tsx +44 -0
- package/src/components/image.css +29 -0
- package/src/components/image.tsx +113 -0
- package/src/components/language-aware-link.tsx +72 -0
- package/src/components/language-switcher.module.css +124 -0
- package/src/components/language-switcher.tsx +75 -0
- package/src/components/map-section.css +59 -0
- package/src/components/map-section.tsx +63 -0
- package/src/components/navbar.module.css +152 -0
- package/src/components/navbar.tsx +125 -0
- package/src/components/parallax-provider.tsx +11 -0
- package/src/components/partner/PartnerBookingPage.module.css +130 -0
- package/src/components/partner/PartnerBookingPage.tsx +390 -0
- package/src/components/partner/PartnerBookingPageWithBrowserMetadata.tsx +19 -35
- package/src/components/product-tag.module.css +30 -0
- package/src/components/product-tag.tsx +34 -0
- package/src/components/product-theme-pages/best-option.module.css +70 -0
- package/src/components/product-theme-pages/best-option.tsx +35 -0
- package/src/components/product-theme-pages/extended-tour-options.module.css +22 -0
- package/src/components/product-theme-pages/extended-tour-options.tsx +11 -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/product-theme-pages/photo-gallery.tsx +90 -0
- package/src/components/product-theme-pages/product-theme-page-layout.module.css +13 -0
- package/src/components/product-theme-pages/product-theme-page-layout.tsx +67 -0
- package/src/components/product-theme-pages/top-of-fold.module.css +179 -0
- package/src/components/product-theme-pages/top-of-fold.tsx +80 -0
- package/src/components/product-tile/image-only-product-tile-desktop.module.css +106 -0
- package/src/components/product-tile/image-only-product-tile-desktop.tsx +56 -0
- package/src/components/product-tile/image-only-product-tile-mobile.module.css +122 -0
- package/src/components/product-tile/image-only-product-tile-mobile.tsx +89 -0
- package/src/components/product-tile/image-only-product-tile.tsx +44 -0
- package/src/components/product-tile/product-tile-card.module.css +84 -0
- package/src/components/product-tile/product-tile-card.tsx +61 -0
- package/src/components/review-highlights-section.css +85 -0
- package/src/components/review-highlights-section.tsx +127 -0
- package/src/components/season-closure-overlay.module.css +99 -0
- package/src/components/season-closure-overlay.tsx +98 -0
- package/src/components/simple-modal.tsx +69 -0
- package/src/components/simple-top-of-fold.module.css +76 -0
- package/src/components/simple-top-of-fold.tsx +34 -0
- package/src/components/spacer.css +41 -0
- package/src/components/spacer.tsx +23 -0
- package/src/components/star-rating.module.css +74 -0
- package/src/components/star-rating.tsx +48 -0
- package/src/components/terms/TermsContent.tsx +178 -0
- package/src/components/title-subtitle.module.css +10 -0
- package/src/components/title-subtitle.tsx +30 -0
- package/src/components/translatable-reviews.tsx +75 -0
- package/src/components/value-pill.module.css +59 -0
- package/src/components/value-pill.tsx +46 -0
- package/src/components/value-props.css +185 -0
- package/src/components/value-props.tsx +88 -0
- package/src/constants/booking-guide-quiz.ts +64 -0
- package/src/constants/contact-info.ts +2 -0
- package/src/constants/faq.ts +44 -0
- package/src/constants/images.ts +556 -0
- package/src/constants/json-ld/faq-json-ld.tsx +170 -0
- package/src/constants/json-ld/homepage-json-ld.tsx +138 -0
- package/src/constants/json-ld/job-posting-json-ld.tsx +92 -0
- package/src/constants/json-ld/organization-json-ld.tsx +62 -0
- package/src/constants/json-ld/page-json-ld.tsx +6 -0
- package/src/constants/json-ld/product-json-ld.tsx +154 -0
- package/src/constants/json-ld/review-json-ld.tsx +377 -0
- package/src/constants/navigation-links/footer-links.ts +48 -0
- package/src/constants/navigation-links/nav-bar-links.ts +41 -0
- package/src/constants/navigation-links/navigation-link.ts +6 -0
- package/src/constants/pill-values.ts +210 -0
- package/src/constants/products.ts +155 -0
- package/src/constants/quiz-recommendations.ts +506 -0
- package/src/constants/reviews.ts +75 -0
- package/src/constants/staff.ts +197 -0
- package/src/constants/value-props.ts +58 -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/use-bottom-sheet.tsx +15 -0
- package/src/hooks/use-simple-modal.tsx +27 -0
- package/src/hooks/useBookingSourceMetadataFromLocation.ts +21 -0
- package/src/hooks/useEmailSubscription.tsx +103 -0
- package/src/hooks/useEmbeddedInIframe.ts +16 -0
- package/src/hooks/useIsBookingLaunchLive.ts +49 -0
- package/src/hooks/useQuiz.tsx +210 -0
- package/src/index.ts +27 -2
- package/src/lib/analytics.ts +197 -0
- package/src/lib/booking/booking-source.ts +20 -2
- package/src/lib/{checkout-breakdown.ts → booking/checkout-breakdown.ts} +1 -1
- package/src/lib/booking/correlation-id.ts +46 -0
- package/src/lib/{i18n → booking/i18n}/messages/en.json +48 -4
- package/src/lib/{i18n → booking/i18n}/messages/fr.json +48 -4
- package/src/lib/booking/itinerary-display.ts +36 -0
- package/src/lib/{itinerary-labels.ts → booking/itinerary-labels.ts} +1 -1
- package/src/lib/{location-calculations.ts → booking/location-calculations.ts} +4 -4
- package/src/lib/{location-utils.ts → booking/location-utils.ts} +26 -0
- package/src/lib/{map-utils.ts → booking/map-utils.ts} +3 -3
- package/src/lib/booking/normalize-booking-product-id.ts +7 -0
- package/src/lib/{pickup-location-types.ts → booking/pickup-location-types.ts} +2 -2
- package/src/lib/{pricing.ts → booking/pricing.ts} +2 -2
- package/src/lib/booking/product-option-id.ts +35 -0
- package/src/lib/booking/source-metadata.ts +72 -7
- package/src/lib/booking/sunday-week.ts +14 -0
- package/src/lib/booking/trace-context.ts +62 -0
- package/src/lib/booking-api.ts +1793 -0
- package/src/lib/{constants.ts → booking-constants.ts} +11 -5
- package/src/lib/booking-types.ts +36 -0
- package/src/lib/currency.ts +38 -45
- 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 +89 -5
- 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/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 +107 -38
- package/src/providers/bottom-sheet-provider.tsx +40 -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 +97 -0
- package/src/types/fareharbor.d.ts +12 -0
- package/src/types/quiz.ts +59 -0
- package/src/utils/currency-converter.ts +101 -0
- package/src/components/BookingFlow.tsx +0 -2952
- package/src/components/LanguageSwitcher.tsx +0 -30
- package/src/components/PrivateShuttleBookingFlow.tsx +0 -2290
- package/src/components/ProductList.tsx +0 -78
- package/src/components/WhatsAppPhoneInput.tsx +0 -224
- package/src/components/index.ts +0 -31
- package/src/lib/api.ts +0 -801
- package/src/lib/booking-api-auth.ts +0 -9
- package/src/lib/checkout-breakdown.test.ts +0 -70
- package/src/types/google-maps.d.ts +0 -2
- /package/src/components/{CurrencySwitcher.tsx → booking/CurrencySwitcher.tsx} +0 -0
- /package/src/components/{ErrorBoundary.tsx → booking/ErrorBoundary.tsx} +0 -0
- /package/src/lib/{i18n → booking/i18n}/config.ts +0 -0
- /package/src/lib/{i18n → booking/i18n}/index.tsx +0 -0
- /package/src/lib/{marker-icons.ts → booking/marker-icons.ts} +0 -0
- /package/src/lib/{places-api.ts → booking/places-api.ts} +0 -0
- /package/src/lib/{theme.ts → booking/theme.ts} +0 -0
- /package/src/lib/{utils.ts → booking/utils.ts} +0 -0
|
@@ -1,2290 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
|
4
|
-
import { format, addWeeks, parseISO, isBefore, isAfter, startOfDay, endOfDay } from 'date-fns';
|
|
5
|
-
import { formatInTimeZone } from 'date-fns-tz';
|
|
6
|
-
import {
|
|
7
|
-
getAvailabilities,
|
|
8
|
-
createReservation,
|
|
9
|
-
createPaymentIntent,
|
|
10
|
-
confirmFreeBooking,
|
|
11
|
-
confirmBookingWithoutPayment,
|
|
12
|
-
getAddOns,
|
|
13
|
-
validatePromoCode,
|
|
14
|
-
getPromoDiscount,
|
|
15
|
-
type Product,
|
|
16
|
-
type Availability,
|
|
17
|
-
type ItineraryDisplayStep,
|
|
18
|
-
type AddOn,
|
|
19
|
-
ItineraryStepType,
|
|
20
|
-
} from '@/lib/api';
|
|
21
|
-
import {
|
|
22
|
-
EARLIEST_AVAILABILITY_DATE,
|
|
23
|
-
LATEST_AVAILABILITY_DATE,
|
|
24
|
-
INITIAL_FETCH_WEEKS,
|
|
25
|
-
} from '@/lib/constants';
|
|
26
|
-
import { formatCurrencyAmount } from '@/lib/currency';
|
|
27
|
-
import { formatBookingRefForDisplay } from '@/lib/booking-ref';
|
|
28
|
-
import { buildCheckoutBreakdown } from '@/lib/checkout-breakdown';
|
|
29
|
-
import type { PricingConfig, PrecomputedPricesByCategory } from '@/lib/api';
|
|
30
|
-
import { Calendar } from './Calendar';
|
|
31
|
-
import { PickupLocationSelector } from './PickupLocationSelector';
|
|
32
|
-
import { useTranslations, useLocale } from '@/lib/i18n';
|
|
33
|
-
import { type Currency } from './CurrencySwitcher';
|
|
34
|
-
import { useCompanyTimezone } from '@/contexts/CompanyContext';
|
|
35
|
-
import { useBookingApp } from '@/contexts/BookingAppContext';
|
|
36
|
-
import { CheckoutModal, type CheckoutModalLineItem } from './CheckoutModal';
|
|
37
|
-
import { Check, X } from 'lucide-react';
|
|
38
|
-
import { PriceSummary } from './PriceSummary';
|
|
39
|
-
import { TermsAcceptance } from './TermsAcceptance';
|
|
40
|
-
import { ItineraryBuilder } from './ItineraryBuilder';
|
|
41
|
-
import { MealDrinkAddOnSelector, canUseMealDrinkSelector } from './MealDrinkAddOnSelector';
|
|
42
|
-
|
|
43
|
-
interface PrivateShuttleBookingFlowProps {
|
|
44
|
-
product: Product;
|
|
45
|
-
onBack: () => void;
|
|
46
|
-
currency: Currency;
|
|
47
|
-
/**
|
|
48
|
-
* Optional callback called when reservation is successfully created (before checkout redirect)
|
|
49
|
-
* If provided, indicates we're in an embedded context (e.g., provider dashboard)
|
|
50
|
-
*/
|
|
51
|
-
onSuccess?: (data: { reservationReference: string }) => void;
|
|
52
|
-
/** Change mode: pre-fill from existing booking and call onChangeBooking on submit */
|
|
53
|
-
initialBooking?: {
|
|
54
|
-
bookingReference: string;
|
|
55
|
-
productId: string;
|
|
56
|
-
dateTime: string;
|
|
57
|
-
originalTotalAmount?: number;
|
|
58
|
-
originalCurrency?: string;
|
|
59
|
-
bookingItems: Array<{ category: string; count: number }>;
|
|
60
|
-
returnAvailabilityId?: string | null;
|
|
61
|
-
pickupLocationId?: string | null;
|
|
62
|
-
travelerHotel?: string | null;
|
|
63
|
-
startTime?: string | null;
|
|
64
|
-
privateShuttleDetails?: { passengerCount?: number };
|
|
65
|
-
cancellationPolicyId?: string | null;
|
|
66
|
-
promoCode?: string | null;
|
|
67
|
-
/** Current additional billable hours on the booking (change mode pre-fill). */
|
|
68
|
-
additionalHoursCount?: number | null;
|
|
69
|
-
addOnSelections?: Array<{ addOnId: string; variantId?: string; quantity?: number }> | null;
|
|
70
|
-
};
|
|
71
|
-
onChangeBooking?: (data: {
|
|
72
|
-
productId: string;
|
|
73
|
-
dateTime: string;
|
|
74
|
-
bookingItems: Array<{ category: string; count: number }>;
|
|
75
|
-
returnAvailabilityId?: string | null;
|
|
76
|
-
pickupLocationId?: string | null;
|
|
77
|
-
travelerHotel?: string | null;
|
|
78
|
-
startTime?: string | null;
|
|
79
|
-
passengerCount?: number | null;
|
|
80
|
-
childSafetySeatsCount?: number | null;
|
|
81
|
-
foodRestrictions?: string | null;
|
|
82
|
-
addOnSelections?: Array<{ addOnId: string; variantId?: string; quantity?: number }> | null;
|
|
83
|
-
cancellationPolicyId?: string | null;
|
|
84
|
-
promoCode?: string | null;
|
|
85
|
-
newTotalAmount?: number;
|
|
86
|
-
keepOriginalPrice?: boolean;
|
|
87
|
-
/** Billable extra hours; sent on every private shuttle change so backend can update duration and total. */
|
|
88
|
-
additionalHoursCount?: number | null;
|
|
89
|
-
}) => Promise<void>;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export function PrivateShuttleBookingFlow({ product, onBack, currency, onSuccess, initialBooking, onChangeBooking }: PrivateShuttleBookingFlowProps) {
|
|
93
|
-
const { t } = useTranslations();
|
|
94
|
-
const { locale } = useLocale();
|
|
95
|
-
const companyTimezone = useCompanyTimezone(); // Get timezone from context
|
|
96
|
-
const { permissions, onShowManage, getSuccessUrl } = useBookingApp();
|
|
97
|
-
const isAdmin = permissions.viewerRole === 'admin';
|
|
98
|
-
const [availabilities, setAvailabilities] = useState<Availability[]>([]);
|
|
99
|
-
const [selectedDate, setSelectedDate] = useState<string>('');
|
|
100
|
-
const [selectedAvailability, setSelectedAvailability] = useState<Availability | null>(null);
|
|
101
|
-
const [selectedOption, setSelectedOption] = useState<string>('');
|
|
102
|
-
const [selectedStartTime, setSelectedStartTime] = useState<string>('');
|
|
103
|
-
const [isCustomTimeMode, setIsCustomTimeMode] = useState(false);
|
|
104
|
-
const isChangeMode = !!(initialBooking && onChangeBooking);
|
|
105
|
-
const [passengerCount, setPassengerCount] = useState<number>(initialBooking?.privateShuttleDetails?.passengerCount ?? 1);
|
|
106
|
-
const [email, setEmail] = useState('');
|
|
107
|
-
const [firstName, setFirstName] = useState('');
|
|
108
|
-
const [lastName, setLastName] = useState('');
|
|
109
|
-
const [pickupLocationId, setPickupLocationId] = useState<string | null>(initialBooking?.pickupLocationId ?? null);
|
|
110
|
-
const [customPickupAddress, setCustomPickupAddress] = useState<string | null>(null);
|
|
111
|
-
const [pickupLocationSkipped, setPickupLocationSkipped] = useState(false);
|
|
112
|
-
/** Draft itinerary for Private Shuttle (destinations, lunch stop, planning notes) */
|
|
113
|
-
const [draftItineraryDestinations, setDraftItineraryDestinations] = useState<string[]>([]);
|
|
114
|
-
const [draftItineraryPlanningNotes, setDraftItineraryPlanningNotes] = useState('');
|
|
115
|
-
/** Child safety seats count (0 to passengerCount) */
|
|
116
|
-
const [childSafetySeatsCount, setChildSafetySeatsCount] = useState(0);
|
|
117
|
-
/** Food restrictions / dietary notes */
|
|
118
|
-
const [foodRestrictions, setFoodRestrictions] = useState('');
|
|
119
|
-
/** Add-on selections (lunch, animals, etc.) - single source of truth */
|
|
120
|
-
const [addOnSelections, setAddOnSelections] = useState<Array<{ addOnId: string; variantId?: string; quantity?: number }>>(() =>
|
|
121
|
-
initialBooking?.addOnSelections?.length
|
|
122
|
-
? initialBooking.addOnSelections.map((s) => ({
|
|
123
|
-
addOnId: s.addOnId,
|
|
124
|
-
variantId: s.variantId,
|
|
125
|
-
quantity: s.quantity ?? 1,
|
|
126
|
-
}))
|
|
127
|
-
: []
|
|
128
|
-
);
|
|
129
|
-
/** Fetched add-ons for the selected option */
|
|
130
|
-
const [addOns, setAddOns] = useState<AddOn[]>([]);
|
|
131
|
-
|
|
132
|
-
// Get selected pickup location (memoized for performance)
|
|
133
|
-
const selectedPickupLocation = useMemo(() =>
|
|
134
|
-
pickupLocationId
|
|
135
|
-
? product.pickupLocations?.find(loc => loc.id === pickupLocationId)
|
|
136
|
-
: null,
|
|
137
|
-
[pickupLocationId, product.pickupLocations]
|
|
138
|
-
);
|
|
139
|
-
const [loading, setLoading] = useState(false);
|
|
140
|
-
const [loadingAvailabilities, setLoadingAvailabilities] = useState(true);
|
|
141
|
-
const [error, setError] = useState('');
|
|
142
|
-
const [showCheckoutModal, setShowCheckoutModal] = useState(false);
|
|
143
|
-
const [termsAccepted, setTermsAccepted] = useState(false);
|
|
144
|
-
const [termsAcceptedAt, setTermsAcceptedAt] = useState<string | null>(null);
|
|
145
|
-
const [checkoutClientSecret, setCheckoutClientSecret] = useState('');
|
|
146
|
-
const [checkoutModalData, setCheckoutModalData] = useState<{
|
|
147
|
-
reservationReference: string;
|
|
148
|
-
customerLastName?: string;
|
|
149
|
-
ticketLines: CheckoutModalLineItem[];
|
|
150
|
-
feeLineItems: { name: string; totalAmount: number; description?: string }[];
|
|
151
|
-
returnPriceAdjustment: number;
|
|
152
|
-
subtotal: number;
|
|
153
|
-
tax: number;
|
|
154
|
-
total: number;
|
|
155
|
-
totalQuantity: number;
|
|
156
|
-
isTaxIncludedInPrice: boolean;
|
|
157
|
-
taxRate: number;
|
|
158
|
-
/** When true, show deposit messaging (balance charged 7 days before or pay earlier). */
|
|
159
|
-
isDepositPayment?: boolean;
|
|
160
|
-
balanceChargeDaysBefore?: number;
|
|
161
|
-
cancellationPolicyFee?: number;
|
|
162
|
-
cancellationPolicyLabel?: string;
|
|
163
|
-
promoDiscountAmount?: number;
|
|
164
|
-
discountLabel?: string | null;
|
|
165
|
-
} | null>(null);
|
|
166
|
-
/** Admin only: skip sending confirmation at creation (provider dashboard). */
|
|
167
|
-
const [skipConfirmationCommunications, setSkipConfirmationCommunications] = useState(false);
|
|
168
|
-
/** Admin only: disable all auto communications for this booking (provider dashboard). */
|
|
169
|
-
const [disableAutoCommunications, setDisableAutoCommunications] = useState(false);
|
|
170
|
-
/** Admin + deposit: show choice to pay or confirm without payment. */
|
|
171
|
-
const [showAdminPaymentChoice, setShowAdminPaymentChoice] = useState(false);
|
|
172
|
-
/** Change mode: when true, apply change but keep original receipt/price — no charge or refund. */
|
|
173
|
-
const [keepOriginalPrice, setKeepOriginalPrice] = useState(false);
|
|
174
|
-
/** Promo/voucher (admin only): input, applied code, error, validating, discount amount */
|
|
175
|
-
const [promoCodeInput, setPromoCodeInput] = useState(() => initialBooking?.promoCode?.trim() ?? '');
|
|
176
|
-
const [appliedPromoCode, setAppliedPromoCode] = useState<string | null>(() => initialBooking?.promoCode?.trim() || null);
|
|
177
|
-
const [promoCodeError, setPromoCodeError] = useState('');
|
|
178
|
-
const [promoCodeValidating, setPromoCodeValidating] = useState(false);
|
|
179
|
-
const [promoDiscountAmount, setPromoDiscountAmount] = useState(0);
|
|
180
|
-
const [isGiftCard, setIsGiftCard] = useState(false);
|
|
181
|
-
const [isVoucher, setIsVoucher] = useState(false);
|
|
182
|
-
const lastValidatedInputRef = useRef<string | null>(null);
|
|
183
|
-
/** Admin only: additional hours add-on ($170/hour, extends duration) */
|
|
184
|
-
const [additionalHoursCount, setAdditionalHoursCount] = useState(
|
|
185
|
-
() => initialBooking?.additionalHoursCount ?? 0
|
|
186
|
-
);
|
|
187
|
-
|
|
188
|
-
const ADDITIONAL_HOUR_PRICE = 170;
|
|
189
|
-
/** Data for admin choice step (reservation, breakdown, etc.). */
|
|
190
|
-
const [adminChoiceData, setAdminChoiceData] = useState<{
|
|
191
|
-
reservationReference: string;
|
|
192
|
-
checkoutBreakdown: { lineItems: Array<{ label: string; amount: number; type?: string; quantity?: number }>; totalAmount: number; currency: string };
|
|
193
|
-
depositAmount: number;
|
|
194
|
-
balanceAmount: number;
|
|
195
|
-
totalAmount: number;
|
|
196
|
-
balanceChargeDaysBefore: number;
|
|
197
|
-
itineraryDisplay?: ItineraryDisplayStep[];
|
|
198
|
-
pickupLocationId?: string;
|
|
199
|
-
} | null>(null);
|
|
200
|
-
const [pricingConfig, setPricingConfig] = useState<PricingConfig | null>(null);
|
|
201
|
-
const [cancellationPolicyId, setCancellationPolicyId] = useState<string | null>(null);
|
|
202
|
-
/** When promo forces a cancellation policy (id + label from validate response). */
|
|
203
|
-
const [forcedCancellationPolicyFromPromo, setForcedCancellationPolicyFromPromo] = useState<{ id: string; label: string } | null>(null);
|
|
204
|
-
/** Precomputed prices from ticketbooth-product-prices (category -> currency -> price). Used for display; API prices are for GYG only. */
|
|
205
|
-
const [precomputedPrices, setPrecomputedPrices] = useState<PrecomputedPricesByCategory | null>(null);
|
|
206
|
-
/** Private Shuttle only: RESOURCE price per currency from API; use this, no conversion. */
|
|
207
|
-
const [resourcePriceByCurrency, setResourcePriceByCurrency] = useState<Record<string, number> | null>(null);
|
|
208
|
-
/** When allOptions: optionId -> (currency -> price) for each option. */
|
|
209
|
-
const [resourcePriceByOption, setResourcePriceByOption] = useState<Record<string, Record<string, number>> | null>(null);
|
|
210
|
-
const pricingConfigSetRef = useRef(false); // Track if pricingConfig has been set (optimize: only set once)
|
|
211
|
-
const fetchingRef = useRef(false); // Prevent concurrent fetches
|
|
212
|
-
const fetchedRangesRef = useRef<Array<{ start: Date; end: Date }>>([]); // Track fetched date ranges
|
|
213
|
-
const [refreshKey, setRefreshKey] = useState(0); // Bump to force refetch when tab regains focus (e.g. after reconciling on dashboard)
|
|
214
|
-
const [visibleRange, setVisibleRange] = useState<{ start: Date; end: Date } | null>(null);
|
|
215
|
-
const itineraryRef = useRef<HTMLDivElement>(null);
|
|
216
|
-
|
|
217
|
-
// Get all active product options
|
|
218
|
-
const activeOptions = useMemo(() => {
|
|
219
|
-
return product.options?.filter(opt => opt.status === 'ACTIVE') || [];
|
|
220
|
-
}, [product.options]);
|
|
221
|
-
|
|
222
|
-
// Get selected option config
|
|
223
|
-
const selectedOptionConfig = activeOptions.find(opt => opt.optionId === selectedOption);
|
|
224
|
-
const privateShuttleConfig = selectedOptionConfig?.privateShuttleConfig;
|
|
225
|
-
|
|
226
|
-
// Helper function to check if we need to fetch a date range
|
|
227
|
-
const needsFetch = (start: Date, end: Date): boolean => {
|
|
228
|
-
if (fetchedRangesRef.current.length === 0) return true;
|
|
229
|
-
|
|
230
|
-
// Check if the requested range is fully covered by fetched ranges
|
|
231
|
-
// For simplicity, check if any single fetched range fully covers the requested range
|
|
232
|
-
return !fetchedRangesRef.current.some(range => {
|
|
233
|
-
const rangeStart = range.start.getTime();
|
|
234
|
-
const rangeEnd = range.end.getTime();
|
|
235
|
-
const reqStart = start.getTime();
|
|
236
|
-
const reqEnd = end.getTime();
|
|
237
|
-
|
|
238
|
-
// Check if this fetched range fully covers the requested range
|
|
239
|
-
return rangeStart <= reqStart && rangeEnd >= reqEnd;
|
|
240
|
-
});
|
|
241
|
-
};
|
|
242
|
-
|
|
243
|
-
// Initialize visible range only once on mount
|
|
244
|
-
useEffect(() => {
|
|
245
|
-
if (!visibleRange) {
|
|
246
|
-
// Initial load - fetch initial weeks (visible + buffer)
|
|
247
|
-
const initialEnd = addWeeks(EARLIEST_AVAILABILITY_DATE, INITIAL_FETCH_WEEKS);
|
|
248
|
-
setVisibleRange({ start: EARLIEST_AVAILABILITY_DATE, end: initialEnd });
|
|
249
|
-
}
|
|
250
|
-
}, [visibleRange]);
|
|
251
|
-
|
|
252
|
-
// When user changes the product option (after selecting date), clear start time only.
|
|
253
|
-
// Don't clear selectedAvailability - handleOptionSelect already sets the new one, and clearing
|
|
254
|
-
// it here caused custom fields to flicker/disappear (effect ran after handleOptionSelect).
|
|
255
|
-
const prevSelectedOptionRef = useRef<string | null>(null);
|
|
256
|
-
useEffect(() => {
|
|
257
|
-
if (selectedOption && prevSelectedOptionRef.current !== null && prevSelectedOptionRef.current !== selectedOption) {
|
|
258
|
-
setSelectedStartTime('');
|
|
259
|
-
setIsCustomTimeMode(false);
|
|
260
|
-
}
|
|
261
|
-
prevSelectedOptionRef.current = selectedOption;
|
|
262
|
-
}, [selectedOption]);
|
|
263
|
-
|
|
264
|
-
// Fetch add-ons when option is selected (for lunch package, animals, etc.)
|
|
265
|
-
useEffect(() => {
|
|
266
|
-
if (!selectedOption || !product.companyId) return;
|
|
267
|
-
getAddOns(product.companyId, { productOptionId: selectedOption, preCheckout: true })
|
|
268
|
-
.then(setAddOns)
|
|
269
|
-
.catch(() => setAddOns([]));
|
|
270
|
-
}, [selectedOption, product.companyId]);
|
|
271
|
-
|
|
272
|
-
// Clear EL lunch add-on when Emerald Lake is deselected from destinations
|
|
273
|
-
useEffect(() => {
|
|
274
|
-
const hasEmeraldLake = draftItineraryDestinations.includes('emerald_lake');
|
|
275
|
-
if (!hasEmeraldLake) {
|
|
276
|
-
setAddOnSelections((prev) => prev.filter((s) => s.addOnId !== 'addon_el_lunch'));
|
|
277
|
-
}
|
|
278
|
-
}, [draftItineraryDestinations]);
|
|
279
|
-
|
|
280
|
-
// When applied promo code changes, clear fetched ranges so we refetch with new pricing
|
|
281
|
-
useEffect(() => {
|
|
282
|
-
fetchedRangesRef.current = [];
|
|
283
|
-
}, [appliedPromoCode]);
|
|
284
|
-
|
|
285
|
-
// Fetch availabilities for visible range - date-first flow: use productId + allOptions to get all options
|
|
286
|
-
useEffect(() => {
|
|
287
|
-
if (activeOptions.length === 0) {
|
|
288
|
-
setError('No active product options available');
|
|
289
|
-
setLoadingAvailabilities(false);
|
|
290
|
-
return;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
if (!visibleRange) {
|
|
294
|
-
// Wait for initial range to be set
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
async function fetchAvailabilities() {
|
|
299
|
-
// Prevent concurrent fetches
|
|
300
|
-
if (fetchingRef.current) {
|
|
301
|
-
return;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// Clamp to available date range
|
|
305
|
-
if (!visibleRange) return;
|
|
306
|
-
|
|
307
|
-
const clampedStart = isBefore(visibleRange.start, EARLIEST_AVAILABILITY_DATE)
|
|
308
|
-
? EARLIEST_AVAILABILITY_DATE
|
|
309
|
-
: visibleRange.start;
|
|
310
|
-
const endOfLatestDay = endOfDay(LATEST_AVAILABILITY_DATE);
|
|
311
|
-
let clampedEnd = isAfter(visibleRange.end, endOfLatestDay)
|
|
312
|
-
? endOfLatestDay
|
|
313
|
-
: visibleRange.end;
|
|
314
|
-
|
|
315
|
-
// Ensure we include the selected date if it's after the visible range end
|
|
316
|
-
if (selectedDate) {
|
|
317
|
-
try {
|
|
318
|
-
const selectedDateObj = parseISO(selectedDate);
|
|
319
|
-
if (isAfter(selectedDateObj, clampedEnd)) {
|
|
320
|
-
clampedEnd = selectedDateObj;
|
|
321
|
-
}
|
|
322
|
-
} catch {
|
|
323
|
-
// Ignore parse errors
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// Check if we need to fetch this range
|
|
328
|
-
if (!needsFetch(clampedStart, clampedEnd)) {
|
|
329
|
-
setLoadingAvailabilities(false);
|
|
330
|
-
return;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
fetchingRef.current = true;
|
|
334
|
-
setLoadingAvailabilities(true);
|
|
335
|
-
|
|
336
|
-
try {
|
|
337
|
-
const startDate = format(startOfDay(clampedStart), 'yyyy-MM-dd');
|
|
338
|
-
const endDateStr = format(endOfDay(clampedEnd), 'yyyy-MM-dd');
|
|
339
|
-
|
|
340
|
-
// Date-first flow: fetch all options at once (productId + allOptions=true)
|
|
341
|
-
const result = await getAvailabilities(product.productId, startDate, endDateStr, {
|
|
342
|
-
allOptions: true,
|
|
343
|
-
promoCode: appliedPromoCode || undefined,
|
|
344
|
-
});
|
|
345
|
-
const allFetchedAvailabilities = result.availabilities;
|
|
346
|
-
|
|
347
|
-
if (result.pricingConfig && !pricingConfigSetRef.current) {
|
|
348
|
-
setPricingConfig(result.pricingConfig);
|
|
349
|
-
pricingConfigSetRef.current = true;
|
|
350
|
-
}
|
|
351
|
-
if (result.precomputedPrices) {
|
|
352
|
-
setPrecomputedPrices(result.precomputedPrices);
|
|
353
|
-
}
|
|
354
|
-
if (result.resourcePriceByCurrency) {
|
|
355
|
-
setResourcePriceByCurrency(result.resourcePriceByCurrency);
|
|
356
|
-
}
|
|
357
|
-
if (result.resourcePriceByOption) {
|
|
358
|
-
setResourcePriceByOption(result.resourcePriceByOption);
|
|
359
|
-
} else {
|
|
360
|
-
setResourcePriceByOption(null);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// Merge: key by dateTime:productId so we keep all options per date
|
|
364
|
-
setAvailabilities(prev => {
|
|
365
|
-
const existingMap = new Map(prev.map(avail => [`${avail.dateTime}:${avail.productId || avail.productOptionId || avail.availabilityId}`, avail]));
|
|
366
|
-
allFetchedAvailabilities.forEach(avail => {
|
|
367
|
-
const key = `${avail.dateTime}:${avail.productId || avail.productOptionId || avail.availabilityId}`;
|
|
368
|
-
existingMap.set(key, avail);
|
|
369
|
-
});
|
|
370
|
-
return Array.from(existingMap.values());
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
// Mark this range as fetched
|
|
374
|
-
fetchedRangesRef.current.push({ start: new Date(clampedStart), end: new Date(clampedEnd) });
|
|
375
|
-
// Sort and merge overlapping ranges
|
|
376
|
-
fetchedRangesRef.current.sort((a, b) => a.start.getTime() - b.start.getTime());
|
|
377
|
-
const merged: Array<{ start: Date; end: Date }> = [];
|
|
378
|
-
for (const r of fetchedRangesRef.current) {
|
|
379
|
-
if (merged.length === 0 || merged[merged.length - 1].end < r.start) {
|
|
380
|
-
merged.push({ start: r.start, end: r.end });
|
|
381
|
-
} else {
|
|
382
|
-
merged[merged.length - 1].end = r.end > merged[merged.length - 1].end
|
|
383
|
-
? r.end
|
|
384
|
-
: merged[merged.length - 1].end;
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
fetchedRangesRef.current = merged;
|
|
388
|
-
|
|
389
|
-
// Initialize passenger count to 1 (only if not already set)
|
|
390
|
-
if (passengerCount === 0) {
|
|
391
|
-
setPassengerCount(1);
|
|
392
|
-
}
|
|
393
|
-
} catch (err) {
|
|
394
|
-
setError(err instanceof Error ? err.message : 'Failed to load availabilities');
|
|
395
|
-
} finally {
|
|
396
|
-
setLoadingAvailabilities(false);
|
|
397
|
-
fetchingRef.current = false;
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
fetchAvailabilities();
|
|
402
|
-
}, [visibleRange, activeOptions, selectedDate, refreshKey, product.productId, appliedPromoCode]);
|
|
403
|
-
|
|
404
|
-
// Refetch availabilities when user returns to this tab (e.g. after reconciling capacity on dashboard)
|
|
405
|
-
useEffect(() => {
|
|
406
|
-
const handleVisibilityChange = () => {
|
|
407
|
-
if (document.visibilityState === 'visible') {
|
|
408
|
-
fetchedRangesRef.current = [];
|
|
409
|
-
setRefreshKey((k) => k + 1);
|
|
410
|
-
}
|
|
411
|
-
};
|
|
412
|
-
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
413
|
-
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
414
|
-
}, []);
|
|
415
|
-
|
|
416
|
-
// Memoized callback for visible range changes
|
|
417
|
-
// Only update if the range actually changed to avoid unnecessary fetches
|
|
418
|
-
const lastVisibleRangeRef = useRef<{ start: Date; end: Date } | null>(null);
|
|
419
|
-
const handleVisibleRangeChange = useCallback((start: Date, end: Date) => {
|
|
420
|
-
const lastRange = lastVisibleRangeRef.current;
|
|
421
|
-
// Update if this is the first range or if it changed significantly (more than a day)
|
|
422
|
-
const rangeChanged = !lastRange ||
|
|
423
|
-
Math.abs(lastRange.start.getTime() - start.getTime()) > 24 * 60 * 60 * 1000 ||
|
|
424
|
-
Math.abs(lastRange.end.getTime() - end.getTime()) > 24 * 60 * 60 * 1000;
|
|
425
|
-
|
|
426
|
-
if (rangeChanged) {
|
|
427
|
-
lastVisibleRangeRef.current = { start, end };
|
|
428
|
-
// Always update state to trigger fetch, even if needsFetch might return false
|
|
429
|
-
// The needsFetch check will prevent unnecessary API calls, but we want the state update
|
|
430
|
-
setVisibleRange({ start, end });
|
|
431
|
-
}
|
|
432
|
-
}, []);
|
|
433
|
-
|
|
434
|
-
// Group availabilities by date (date-first: each date can have multiple options)
|
|
435
|
-
const availabilitiesByDate = useMemo(() => {
|
|
436
|
-
return availabilities.reduce((acc, avail) => {
|
|
437
|
-
const dateStr = avail.dateTime.split('T')[0];
|
|
438
|
-
if (!acc[dateStr]) acc[dateStr] = [];
|
|
439
|
-
acc[dateStr].push(avail);
|
|
440
|
-
return acc;
|
|
441
|
-
}, {} as Record<string, Availability[]>);
|
|
442
|
-
}, [availabilities]);
|
|
443
|
-
|
|
444
|
-
// Options available for the selected date (for option selector - only show options that have availability that day)
|
|
445
|
-
const optionsAvailableForSelectedDate = useMemo(() => {
|
|
446
|
-
if (!selectedDate) return [];
|
|
447
|
-
const dateAvailabilities = availabilitiesByDate[selectedDate] || [];
|
|
448
|
-
const optionIds = new Set(dateAvailabilities.map(a => a.productId || a.productOptionId).filter(Boolean));
|
|
449
|
-
return activeOptions.filter(opt => optionIds.has(opt.optionId));
|
|
450
|
-
}, [selectedDate, availabilitiesByDate, activeOptions]);
|
|
451
|
-
|
|
452
|
-
const dates = useMemo(() => Object.keys(availabilitiesByDate).sort(), [availabilitiesByDate]);
|
|
453
|
-
|
|
454
|
-
// Find the earliest availability date - memoize with a stable reference
|
|
455
|
-
// Only recalculate if we don't have a cached value or if the new earliest is actually earlier
|
|
456
|
-
// IMPORTANT: Never return null once we have a value, to prevent calendar reset during loading
|
|
457
|
-
const earliestAvailabilityDateRef = useRef<Date | null>(null);
|
|
458
|
-
const earliestAvailabilityDate = useMemo(() => {
|
|
459
|
-
if (dates.length === 0) {
|
|
460
|
-
// If we have a cached value, keep using it even during loading
|
|
461
|
-
return earliestAvailabilityDateRef.current || EARLIEST_AVAILABILITY_DATE;
|
|
462
|
-
}
|
|
463
|
-
const firstDate = dates[0];
|
|
464
|
-
const [year, month, day] = firstDate.split('-').map(Number);
|
|
465
|
-
const newEarliest = new Date(year, month - 1, day);
|
|
466
|
-
|
|
467
|
-
// Only update if we don't have a cached value or if the new one is earlier
|
|
468
|
-
if (!earliestAvailabilityDateRef.current || newEarliest < earliestAvailabilityDateRef.current) {
|
|
469
|
-
earliestAvailabilityDateRef.current = newEarliest;
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
return earliestAvailabilityDateRef.current;
|
|
473
|
-
}, [dates]);
|
|
474
|
-
|
|
475
|
-
// Get selected availability's suggested start times
|
|
476
|
-
const suggestedStartTimes = selectedAvailability?.suggestedStartTimes || privateShuttleConfig?.suggestedStartTimes || [];
|
|
477
|
-
|
|
478
|
-
// Resource capacity per shuttle (hardcoded for now - should come from backend/config)
|
|
479
|
-
const RESOURCE_CAPACITY = 13;
|
|
480
|
-
|
|
481
|
-
// Calculate number of resources needed based on passenger count
|
|
482
|
-
const resourceCount = Math.ceil(passengerCount / RESOURCE_CAPACITY);
|
|
483
|
-
|
|
484
|
-
// Resource price: prefer selected availability's rates (includes dynamic pricing) when available;
|
|
485
|
-
// otherwise fall back to resourcePriceByOption, resourcePriceByCurrency, or precomputedPrices
|
|
486
|
-
const resourcePrice = useMemo(() => {
|
|
487
|
-
// When user has selected a date/availability, use the per-slot price from rates (has dynamic pricing applied)
|
|
488
|
-
if (selectedAvailability?.rates) {
|
|
489
|
-
const resourceRate = selectedAvailability.rates.find((r) => r.rateId === 'RESOURCE' || r.category === 'RESOURCE');
|
|
490
|
-
if (resourceRate) {
|
|
491
|
-
const fromRate = resourceRate.priceByCurrency?.[currency] ?? (currency === 'CAD' ? resourceRate.price : undefined);
|
|
492
|
-
if (fromRate != null) return fromRate;
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
if (selectedOption && resourcePriceByOption?.[selectedOption]?.[currency] != null) {
|
|
496
|
-
return resourcePriceByOption[selectedOption][currency];
|
|
497
|
-
}
|
|
498
|
-
const fromApi = resourcePriceByCurrency?.[currency];
|
|
499
|
-
if (fromApi != null) return fromApi;
|
|
500
|
-
const fromPrecomputed = precomputedPrices?.['RESOURCE']?.[currency];
|
|
501
|
-
if (fromPrecomputed != null) return fromPrecomputed;
|
|
502
|
-
const fromProduct = selectedOptionConfig?.pricing?.['RESOURCE'];
|
|
503
|
-
if (fromProduct != null) return fromProduct;
|
|
504
|
-
return 0;
|
|
505
|
-
}, [selectedAvailability, resourcePriceByOption, resourcePriceByCurrency, precomputedPrices, selectedOption, selectedOptionConfig, currency]);
|
|
506
|
-
|
|
507
|
-
// Calculate base price: resourcePrice * resourceCount
|
|
508
|
-
const basePrice = resourcePrice * resourceCount;
|
|
509
|
-
|
|
510
|
-
// Tax: for CAD/USD tax is separate; for EUR/GBP/AUD it's included in price
|
|
511
|
-
const isTaxIncludedInPrice = (pricingConfig?.currenciesWithTaxIncluded ?? []).includes(currency);
|
|
512
|
-
const taxRate = pricingConfig?.taxRate ?? 0.05;
|
|
513
|
-
// Cancellation policy fee (from selected policy)
|
|
514
|
-
const selectedCancellationPolicy = pricingConfig?.cancellationPolicies?.find((p) => p.id === cancellationPolicyId);
|
|
515
|
-
const cancellationPolicyFee = selectedCancellationPolicy ? (selectedCancellationPolicy.feeByCurrency[currency] ?? 0) : 0;
|
|
516
|
-
// Add-on totals (lunch, animals)
|
|
517
|
-
const addOnTotal = useMemo(() => {
|
|
518
|
-
let sum = 0;
|
|
519
|
-
for (const sel of addOnSelections) {
|
|
520
|
-
const addOn = addOns.find((a) => a.addOnId === sel.addOnId);
|
|
521
|
-
if (!addOn) continue;
|
|
522
|
-
const basePrice = addOn.price ?? 0;
|
|
523
|
-
const hasVariant = (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') && sel.variantId;
|
|
524
|
-
const variantAdjustment = hasVariant
|
|
525
|
-
? (addOn.variants?.find((v) => v.id === sel.variantId)?.priceAdjustment ?? 0)
|
|
526
|
-
: 0;
|
|
527
|
-
sum += (basePrice + variantAdjustment) * (sel.quantity ?? 1);
|
|
528
|
-
}
|
|
529
|
-
return sum;
|
|
530
|
-
}, [addOnSelections, addOns]);
|
|
531
|
-
|
|
532
|
-
// Additional hours amount (admin only: $170/hour)
|
|
533
|
-
const additionalHoursAmount = (isAdmin ? additionalHoursCount : 0) * ADDITIONAL_HOUR_PRICE;
|
|
534
|
-
// Subtotal = base + add-ons + additional hours (needed for promo discount effect)
|
|
535
|
-
const subtotal = basePrice + addOnTotal + additionalHoursAmount;
|
|
536
|
-
|
|
537
|
-
// Promo discount from backend (admin only; order-level)
|
|
538
|
-
const hasOngoingDiscount = useMemo(
|
|
539
|
-
() =>
|
|
540
|
-
selectedAvailability?.rates?.some((r) =>
|
|
541
|
-
(r.appliedAdjustments ?? r.applied_adjustments ?? []).some((a) => (a.type ?? '').toLowerCase() === 'deal')
|
|
542
|
-
) ?? false,
|
|
543
|
-
[selectedAvailability]
|
|
544
|
-
);
|
|
545
|
-
|
|
546
|
-
useEffect(() => {
|
|
547
|
-
if (!appliedPromoCode || !selectedOption || !selectedDate || !selectedStartTime || resourceCount === 0) {
|
|
548
|
-
setPromoDiscountAmount(0);
|
|
549
|
-
setIsGiftCard(false);
|
|
550
|
-
setIsVoucher(false);
|
|
551
|
-
return;
|
|
552
|
-
}
|
|
553
|
-
const companyId = product.companyId;
|
|
554
|
-
if (!companyId) {
|
|
555
|
-
setPromoDiscountAmount(0);
|
|
556
|
-
return;
|
|
557
|
-
}
|
|
558
|
-
const items = [{ category: 'RESOURCE', qty: resourceCount }];
|
|
559
|
-
const dateTime = selectedAvailability?.dateTime ?? `${selectedDate}T${selectedStartTime}:00`;
|
|
560
|
-
let cancelled = false;
|
|
561
|
-
getPromoDiscount(
|
|
562
|
-
appliedPromoCode,
|
|
563
|
-
companyId,
|
|
564
|
-
product.productId,
|
|
565
|
-
selectedOption,
|
|
566
|
-
currency,
|
|
567
|
-
items,
|
|
568
|
-
dateTime,
|
|
569
|
-
subtotal
|
|
570
|
-
)
|
|
571
|
-
.then((res) => {
|
|
572
|
-
if (!cancelled) {
|
|
573
|
-
setPromoDiscountAmount(res.discount ?? 0);
|
|
574
|
-
setIsGiftCard(res.isGiftCard ?? false);
|
|
575
|
-
setIsVoucher(res.isVoucher ?? false);
|
|
576
|
-
}
|
|
577
|
-
})
|
|
578
|
-
.catch(() => {
|
|
579
|
-
if (!cancelled) {
|
|
580
|
-
setPromoDiscountAmount(0);
|
|
581
|
-
setIsGiftCard(false);
|
|
582
|
-
setIsVoucher(false);
|
|
583
|
-
}
|
|
584
|
-
});
|
|
585
|
-
return () => { cancelled = true; };
|
|
586
|
-
}, [appliedPromoCode, selectedOption, selectedDate, selectedStartTime, selectedAvailability?.dateTime, resourceCount, subtotal, product.companyId, product.productId, currency]);
|
|
587
|
-
|
|
588
|
-
// Percentage/fixed promos: tax on discounted amount (promo before GST). Vouchers/gift cards: tax on base (original).
|
|
589
|
-
const taxAmount =
|
|
590
|
-
isTaxIncludedInPrice
|
|
591
|
-
? 0
|
|
592
|
-
: Math.round(
|
|
593
|
-
(promoDiscountAmount > 0 && !isGiftCard && !isVoucher
|
|
594
|
-
? (subtotal - promoDiscountAmount) * taxRate
|
|
595
|
-
: basePrice * taxRate
|
|
596
|
-
) * 100
|
|
597
|
-
) / 100;
|
|
598
|
-
|
|
599
|
-
// Total = subtotal + tax (when not included) + cancellation fee - promo discount
|
|
600
|
-
const totalPrice = subtotal + taxAmount + cancellationPolicyFee - promoDiscountAmount;
|
|
601
|
-
|
|
602
|
-
// Calculate duration: base + admin add-on hours (each additional hour = 60 min)
|
|
603
|
-
const calculatedDuration = (privateShuttleConfig?.baseDurationMinutes ?? 0) + (isAdmin ? additionalHoursCount * 60 : 0);
|
|
604
|
-
|
|
605
|
-
// Calculate end time
|
|
606
|
-
const calculatedEndTime = useMemo(() => {
|
|
607
|
-
if (!selectedStartTime || calculatedDuration === 0) return null;
|
|
608
|
-
|
|
609
|
-
try {
|
|
610
|
-
// Parse selected date and start time
|
|
611
|
-
const [hours, minutes] = selectedStartTime.split(':').map(Number);
|
|
612
|
-
const dateTime = new Date(selectedDate);
|
|
613
|
-
dateTime.setHours(hours, minutes, 0, 0);
|
|
614
|
-
|
|
615
|
-
// Add duration
|
|
616
|
-
const endDateTime = new Date(dateTime.getTime() + calculatedDuration * 60 * 1000);
|
|
617
|
-
|
|
618
|
-
return formatInTimeZone(endDateTime, companyTimezone, 'h:mm a');
|
|
619
|
-
} catch {
|
|
620
|
-
return null;
|
|
621
|
-
}
|
|
622
|
-
}, [selectedStartTime, selectedDate, calculatedDuration, companyTimezone]);
|
|
623
|
-
|
|
624
|
-
// Calculate deposit (based on totalPrice which includes tax when applicable)
|
|
625
|
-
const depositInfo = useMemo(() => {
|
|
626
|
-
if (!privateShuttleConfig?.depositConfig || totalPrice === 0) return null;
|
|
627
|
-
|
|
628
|
-
const { depositConfig } = privateShuttleConfig;
|
|
629
|
-
const percentageDeposit = depositConfig.percentage ? totalPrice * depositConfig.percentage : 0;
|
|
630
|
-
const fixedDeposit = depositConfig.fixedAmount || 0;
|
|
631
|
-
|
|
632
|
-
const depositAmount = Math.round(Math.max(percentageDeposit, fixedDeposit) * 100) / 100;
|
|
633
|
-
const balanceAmount = Math.round((totalPrice - depositAmount) * 100) / 100;
|
|
634
|
-
|
|
635
|
-
return {
|
|
636
|
-
depositAmount,
|
|
637
|
-
balanceAmount,
|
|
638
|
-
totalPrice,
|
|
639
|
-
};
|
|
640
|
-
}, [privateShuttleConfig, totalPrice]);
|
|
641
|
-
|
|
642
|
-
// Build itinerary display items for sticky "Build Your Itinerary" section
|
|
643
|
-
const itineraryDisplayItems = useMemo(() => {
|
|
644
|
-
const items: Array<{ time?: string; label?: string; prefix?: string; clickableLabel?: string; timesNote?: string; isProposedStops?: boolean }> = [];
|
|
645
|
-
const pickupTime = selectedStartTime
|
|
646
|
-
? (() => {
|
|
647
|
-
const [hours, minutes] = selectedStartTime.split(':').map(Number);
|
|
648
|
-
const d = new Date();
|
|
649
|
-
d.setHours(hours, minutes, 0, 0);
|
|
650
|
-
return format(d, 'h:mm a');
|
|
651
|
-
})()
|
|
652
|
-
: 'TBD';
|
|
653
|
-
const dropoffTime = calculatedEndTime ?? 'TBD';
|
|
654
|
-
const pickupDropoffLabel = customPickupAddress
|
|
655
|
-
?? selectedPickupLocation?.name
|
|
656
|
-
?? (pickupLocationSkipped ? (t('booking.pickupAtTourStartLocation') || 'tour start location') : null)
|
|
657
|
-
?? (t('booking.yourPickupLocation') || 'your pickup location');
|
|
658
|
-
items.push({
|
|
659
|
-
time: pickupTime,
|
|
660
|
-
prefix: `${t('booking.pickupAt') || 'Pickup at'} `,
|
|
661
|
-
clickableLabel: pickupDropoffLabel,
|
|
662
|
-
});
|
|
663
|
-
if (selectedOption && product.itineraryBuilder && draftItineraryDestinations.length > 0) {
|
|
664
|
-
const destMap = new Map(product.itineraryBuilder.destinations.map((d) => [d.id, d.label]));
|
|
665
|
-
const locationLabels = draftItineraryDestinations.map((id) => destMap.get(id) || id.replace(/_/g, ' '));
|
|
666
|
-
items.push({
|
|
667
|
-
label: locationLabels.join(', '),
|
|
668
|
-
timesNote: t('booking.orderAndTimesToBeConfirmed') || '(order and times to be confirmed by our team)',
|
|
669
|
-
isProposedStops: true,
|
|
670
|
-
});
|
|
671
|
-
}
|
|
672
|
-
items.push({
|
|
673
|
-
time: dropoffTime,
|
|
674
|
-
prefix: t('booking.dropOffAtPrefix') || 'Drop off at ',
|
|
675
|
-
clickableLabel: pickupDropoffLabel,
|
|
676
|
-
});
|
|
677
|
-
return items;
|
|
678
|
-
}, [selectedOption, product.itineraryBuilder, draftItineraryDestinations, selectedStartTime, calculatedEndTime, customPickupAddress, selectedPickupLocation, pickupLocationSkipped, t]);
|
|
679
|
-
|
|
680
|
-
// Scroll to pickup location section when "your pickup location" is clicked in itinerary
|
|
681
|
-
const handlePickupLocationClick = useCallback(() => {
|
|
682
|
-
const pickupSection = document.getElementById('pickup-location-section');
|
|
683
|
-
const itineraryBox = document.querySelector('[class*="sticky top-"]');
|
|
684
|
-
if (pickupSection && itineraryBox) {
|
|
685
|
-
const headerHeight = window.innerWidth >= 640 ? 73 : 136;
|
|
686
|
-
const itineraryHeight = itineraryBox.getBoundingClientRect().height;
|
|
687
|
-
const elementPosition = pickupSection.getBoundingClientRect().top;
|
|
688
|
-
const offsetPosition = elementPosition + window.pageYOffset - headerHeight - itineraryHeight - 16;
|
|
689
|
-
window.scrollTo({
|
|
690
|
-
top: Math.max(0, offsetPosition),
|
|
691
|
-
behavior: 'smooth',
|
|
692
|
-
});
|
|
693
|
-
} else if (pickupSection) {
|
|
694
|
-
pickupSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
695
|
-
}
|
|
696
|
-
}, []);
|
|
697
|
-
|
|
698
|
-
// Get RESOURCE price for an option (for Select Tour Option grid)
|
|
699
|
-
// When selectedDate is set, prefer the per-slot price from availability (includes dynamic pricing)
|
|
700
|
-
const getOptionPrice = useCallback((optionId: string): number => {
|
|
701
|
-
if (selectedDate) {
|
|
702
|
-
const dateAvail = availabilitiesByDate[selectedDate]?.find(
|
|
703
|
-
(a) => (a.productId || a.productOptionId) === optionId
|
|
704
|
-
);
|
|
705
|
-
const resourceRate = dateAvail?.rates?.find((r) => r.rateId === 'RESOURCE' || r.category === 'RESOURCE');
|
|
706
|
-
if (resourceRate) {
|
|
707
|
-
const fromRate = resourceRate.priceByCurrency?.[currency] ?? (currency === 'CAD' ? resourceRate.price : undefined);
|
|
708
|
-
if (fromRate != null) return fromRate;
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
if (resourcePriceByOption?.[optionId]?.[currency] != null) return resourcePriceByOption[optionId][currency];
|
|
712
|
-
const fromApi = resourcePriceByCurrency?.[currency];
|
|
713
|
-
if (fromApi != null) return fromApi;
|
|
714
|
-
const opt = activeOptions.find((o) => o.optionId === optionId);
|
|
715
|
-
return opt?.pricing?.['RESOURCE'] ?? 0;
|
|
716
|
-
}, [selectedDate, availabilitiesByDate, resourcePriceByOption, resourcePriceByCurrency, currency, activeOptions]);
|
|
717
|
-
|
|
718
|
-
const handleDateSelect = (date: string) => {
|
|
719
|
-
setSelectedDate(date);
|
|
720
|
-
setSelectedOption('');
|
|
721
|
-
setSelectedAvailability(null);
|
|
722
|
-
setSelectedStartTime('');
|
|
723
|
-
setIsCustomTimeMode(false);
|
|
724
|
-
setDraftItineraryDestinations([]);
|
|
725
|
-
setDraftItineraryPlanningNotes('');
|
|
726
|
-
setError('');
|
|
727
|
-
};
|
|
728
|
-
|
|
729
|
-
// Auto-select option when date selected: most popular if set and available, otherwise first available (or only one)
|
|
730
|
-
useEffect(() => {
|
|
731
|
-
if (selectedDate && !selectedOption && optionsAvailableForSelectedDate.length > 0) {
|
|
732
|
-
const mostPopularOpt = optionsAvailableForSelectedDate.find(o => o.mostPopular);
|
|
733
|
-
const opt = mostPopularOpt ?? optionsAvailableForSelectedDate[0];
|
|
734
|
-
setSelectedOption(opt.optionId);
|
|
735
|
-
const dateAvailabilities = availabilitiesByDate[selectedDate] || [];
|
|
736
|
-
const avail = dateAvailabilities.find(a => (a.productId || a.productOptionId) === opt.optionId);
|
|
737
|
-
setSelectedAvailability(avail || null);
|
|
738
|
-
}
|
|
739
|
-
}, [selectedDate, selectedOption, optionsAvailableForSelectedDate, availabilitiesByDate]);
|
|
740
|
-
|
|
741
|
-
// Clamp passenger count when availability changes (e.g. fewer vacancies for new date/option)
|
|
742
|
-
useEffect(() => {
|
|
743
|
-
if (selectedAvailability) {
|
|
744
|
-
const maxAvailable = selectedAvailability.vacancies || 0;
|
|
745
|
-
if (maxAvailable > 0 && passengerCount > maxAvailable) {
|
|
746
|
-
setPassengerCount(maxAvailable);
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
}, [selectedAvailability, passengerCount]);
|
|
750
|
-
|
|
751
|
-
// Initialize draft itinerary with option's default destinations when option changes
|
|
752
|
-
useEffect(() => {
|
|
753
|
-
if (selectedOption) {
|
|
754
|
-
const optionConfig = activeOptions.find((o) => o.optionId === selectedOption);
|
|
755
|
-
const blacklist = optionConfig?.privateShuttleConfig?.itineraryBuilderConfig?.optionBlacklist ?? [];
|
|
756
|
-
const defaults = optionConfig?.privateShuttleConfig?.itineraryBuilderConfig?.defaultDestinations ?? [];
|
|
757
|
-
const initialDestinations = defaults.filter((id) => !blacklist.includes(id));
|
|
758
|
-
setDraftItineraryDestinations(initialDestinations);
|
|
759
|
-
setDraftItineraryPlanningNotes('');
|
|
760
|
-
}
|
|
761
|
-
}, [selectedOption, activeOptions]);
|
|
762
|
-
|
|
763
|
-
// Auto-select first pickup time when suggested times appear
|
|
764
|
-
useEffect(() => {
|
|
765
|
-
if (
|
|
766
|
-
suggestedStartTimes.length > 0 &&
|
|
767
|
-
!isCustomTimeMode &&
|
|
768
|
-
!selectedStartTime
|
|
769
|
-
) {
|
|
770
|
-
setSelectedStartTime(suggestedStartTimes[0]);
|
|
771
|
-
}
|
|
772
|
-
}, [suggestedStartTimes, isCustomTimeMode, selectedStartTime]);
|
|
773
|
-
|
|
774
|
-
// Auto-select first cancellation policy when policies load
|
|
775
|
-
useEffect(() => {
|
|
776
|
-
if (pricingConfig?.cancellationPolicies && pricingConfig.cancellationPolicies.length > 0 && !cancellationPolicyId) {
|
|
777
|
-
setCancellationPolicyId(pricingConfig.cancellationPolicies[0].id);
|
|
778
|
-
}
|
|
779
|
-
}, [pricingConfig?.cancellationPolicies, cancellationPolicyId]);
|
|
780
|
-
|
|
781
|
-
const handleOptionSelect = (optionId: string) => {
|
|
782
|
-
setSelectedOption(optionId);
|
|
783
|
-
setError('');
|
|
784
|
-
// Draft itinerary is initialized by useEffect when selectedOption changes
|
|
785
|
-
// Set selectedAvailability from the availability for this date + option
|
|
786
|
-
if (selectedDate) {
|
|
787
|
-
const dateAvailabilities = availabilitiesByDate[selectedDate] || [];
|
|
788
|
-
const avail = dateAvailabilities.find(a => (a.productId || a.productOptionId) === optionId);
|
|
789
|
-
setSelectedAvailability(avail || null);
|
|
790
|
-
} else {
|
|
791
|
-
setSelectedAvailability(null);
|
|
792
|
-
}
|
|
793
|
-
setSelectedStartTime('');
|
|
794
|
-
setIsCustomTimeMode(false);
|
|
795
|
-
};
|
|
796
|
-
|
|
797
|
-
const handleStartTimeSelect = (time: string) => {
|
|
798
|
-
setSelectedStartTime(time);
|
|
799
|
-
setIsCustomTimeMode(false);
|
|
800
|
-
setError('');
|
|
801
|
-
};
|
|
802
|
-
|
|
803
|
-
const handleCustomTimeRequest = () => {
|
|
804
|
-
setIsCustomTimeMode(true);
|
|
805
|
-
setSelectedStartTime('');
|
|
806
|
-
setError('');
|
|
807
|
-
};
|
|
808
|
-
|
|
809
|
-
const handleCustomTimeChange = (value: string) => {
|
|
810
|
-
setSelectedStartTime(value);
|
|
811
|
-
setError('');
|
|
812
|
-
};
|
|
813
|
-
|
|
814
|
-
const handlePassengerCountChange = (count: number) => {
|
|
815
|
-
const maxAvailable = selectedAvailability?.vacancies || 0;
|
|
816
|
-
const newCount = Math.max(1, Math.min(maxAvailable, count));
|
|
817
|
-
setPassengerCount(newCount);
|
|
818
|
-
setError('');
|
|
819
|
-
};
|
|
820
|
-
|
|
821
|
-
const handleApplyPromo = useCallback(async () => {
|
|
822
|
-
const code = promoCodeInput.trim().toUpperCase();
|
|
823
|
-
if (!code) return;
|
|
824
|
-
if (appliedPromoCode === code) return;
|
|
825
|
-
const companyId = product.companyId;
|
|
826
|
-
if (!companyId) return;
|
|
827
|
-
lastValidatedInputRef.current = code;
|
|
828
|
-
setPromoCodeError('');
|
|
829
|
-
setPromoCodeValidating(true);
|
|
830
|
-
try {
|
|
831
|
-
const result = await validatePromoCode(code, companyId, product.productId, hasOngoingDiscount);
|
|
832
|
-
if (result.valid) {
|
|
833
|
-
setAppliedPromoCode(code);
|
|
834
|
-
fetchedRangesRef.current = [];
|
|
835
|
-
if (result.forcedCancellationPolicyId) {
|
|
836
|
-
setCancellationPolicyId(result.forcedCancellationPolicyId);
|
|
837
|
-
setForcedCancellationPolicyFromPromo(
|
|
838
|
-
result.forcedCancellationPolicyLabel
|
|
839
|
-
? { id: result.forcedCancellationPolicyId, label: result.forcedCancellationPolicyLabel }
|
|
840
|
-
: { id: result.forcedCancellationPolicyId, label: result.forcedCancellationPolicyId }
|
|
841
|
-
);
|
|
842
|
-
} else {
|
|
843
|
-
setForcedCancellationPolicyFromPromo(null);
|
|
844
|
-
}
|
|
845
|
-
} else {
|
|
846
|
-
const errorMsg =
|
|
847
|
-
result.error === 'Promo codes cannot be stacked with deals'
|
|
848
|
-
? (t('booking.promoCodesCannotStackWithDiscounts') || result.error)
|
|
849
|
-
: (result.error || t('booking.invalidPromoCode') || 'Invalid or expired promo code');
|
|
850
|
-
setPromoCodeError(errorMsg);
|
|
851
|
-
}
|
|
852
|
-
} catch (err) {
|
|
853
|
-
setPromoCodeError(err instanceof Error ? err.message : 'Failed to validate promo code');
|
|
854
|
-
} finally {
|
|
855
|
-
setPromoCodeValidating(false);
|
|
856
|
-
}
|
|
857
|
-
}, [promoCodeInput, appliedPromoCode, product.companyId, product.productId, hasOngoingDiscount, t]);
|
|
858
|
-
|
|
859
|
-
useEffect(() => {
|
|
860
|
-
if (!appliedPromoCode || !hasOngoingDiscount) return;
|
|
861
|
-
let cancelled = false;
|
|
862
|
-
validatePromoCode(appliedPromoCode, product.companyId ?? '', product.productId, true).then((result) => {
|
|
863
|
-
if (cancelled) return;
|
|
864
|
-
if (!result.valid && result.error === 'Promo codes cannot be stacked with deals') {
|
|
865
|
-
setAppliedPromoCode(null);
|
|
866
|
-
setPromoCodeInput(appliedPromoCode);
|
|
867
|
-
setPromoCodeError(t('booking.promoCodesCannotStackWithDiscounts') || result.error);
|
|
868
|
-
setForcedCancellationPolicyFromPromo(null);
|
|
869
|
-
fetchedRangesRef.current = [];
|
|
870
|
-
}
|
|
871
|
-
});
|
|
872
|
-
return () => { cancelled = true; };
|
|
873
|
-
}, [hasOngoingDiscount, appliedPromoCode, product.companyId, product.productId, t]);
|
|
874
|
-
|
|
875
|
-
const handleApplyPromoRef = useRef(handleApplyPromo);
|
|
876
|
-
handleApplyPromoRef.current = handleApplyPromo;
|
|
877
|
-
|
|
878
|
-
useEffect(() => {
|
|
879
|
-
const trimmed = promoCodeInput.trim().toUpperCase();
|
|
880
|
-
if (!trimmed) return;
|
|
881
|
-
if (appliedPromoCode === trimmed) return;
|
|
882
|
-
if (promoCodeValidating) return;
|
|
883
|
-
if (lastValidatedInputRef.current === trimmed) return;
|
|
884
|
-
const timer = setTimeout(() => {
|
|
885
|
-
handleApplyPromoRef.current();
|
|
886
|
-
}, 600);
|
|
887
|
-
return () => clearTimeout(timer);
|
|
888
|
-
}, [promoCodeInput, appliedPromoCode, promoCodeValidating]);
|
|
889
|
-
|
|
890
|
-
const handleCheckout = async () => {
|
|
891
|
-
if (!selectedDate || !selectedStartTime || passengerCount < 1) {
|
|
892
|
-
setError('Please select a date, start time, and enter passenger count');
|
|
893
|
-
return;
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
// Require pickup location choice (selected location, custom address, OR "I don't know") — same as BookingFlow
|
|
897
|
-
if (product.pickupLocations && product.pickupLocations.length > 0 && !pickupLocationId && !customPickupAddress && !pickupLocationSkipped) {
|
|
898
|
-
setError(t('booking.selectPickupLocation'));
|
|
899
|
-
return;
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
// Validate email (required) - skip in change mode
|
|
903
|
-
if (!isChangeMode) {
|
|
904
|
-
if (!email) {
|
|
905
|
-
setError(t('booking.enterEmail') || 'Please enter your email address');
|
|
906
|
-
return;
|
|
907
|
-
}
|
|
908
|
-
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
909
|
-
setError(t('booking.invalidEmail') || 'Please enter a valid email address');
|
|
910
|
-
return;
|
|
911
|
-
}
|
|
912
|
-
if (!lastName?.trim()) {
|
|
913
|
-
setError(t('booking.enterLastName') || 'Please enter your last name');
|
|
914
|
-
return;
|
|
915
|
-
}
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
setLoading(true);
|
|
919
|
-
setError('');
|
|
920
|
-
|
|
921
|
-
try {
|
|
922
|
-
// For Private Shuttle, bookingItems should be [{ category: "RESOURCE", count: resourceCount }]
|
|
923
|
-
const bookingItems = [{ category: 'RESOURCE', count: resourceCount }];
|
|
924
|
-
|
|
925
|
-
if (!selectedOption) {
|
|
926
|
-
setError('No product option selected');
|
|
927
|
-
setLoading(false);
|
|
928
|
-
return;
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
// Change mode: call onChangeBooking instead of createReservation
|
|
932
|
-
if (isChangeMode && onChangeBooking) {
|
|
933
|
-
const selectedPickupLocation = pickupLocationId
|
|
934
|
-
? product.pickupLocations?.find(loc => loc.id === pickupLocationId)
|
|
935
|
-
: null;
|
|
936
|
-
const [hours, minutes] = selectedStartTime.split(':').map(Number);
|
|
937
|
-
const startTimeISO = `${selectedDate}T${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00-06:00`;
|
|
938
|
-
await onChangeBooking({
|
|
939
|
-
productId: selectedOption,
|
|
940
|
-
dateTime: selectedDate,
|
|
941
|
-
bookingItems,
|
|
942
|
-
returnAvailabilityId: null,
|
|
943
|
-
pickupLocationId: pickupLocationId ?? null,
|
|
944
|
-
travelerHotel: selectedPickupLocation?.name ?? customPickupAddress ?? initialBooking?.travelerHotel ?? null,
|
|
945
|
-
startTime: startTimeISO,
|
|
946
|
-
passengerCount,
|
|
947
|
-
childSafetySeatsCount: childSafetySeatsCount > 0 ? childSafetySeatsCount : null,
|
|
948
|
-
foodRestrictions: foodRestrictions.trim() || null,
|
|
949
|
-
addOnSelections: addOnSelections.length > 0 ? addOnSelections : null,
|
|
950
|
-
cancellationPolicyId: cancellationPolicyId ?? initialBooking?.cancellationPolicyId ?? null,
|
|
951
|
-
promoCode: appliedPromoCode ?? null,
|
|
952
|
-
newTotalAmount: keepOriginalPrice ? undefined : totalPrice,
|
|
953
|
-
keepOriginalPrice: keepOriginalPrice || undefined,
|
|
954
|
-
additionalHoursCount: isAdmin ? additionalHoursCount : null,
|
|
955
|
-
});
|
|
956
|
-
setLoading(false);
|
|
957
|
-
return;
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
// Build start time as ISO 8601 string in company timezone
|
|
961
|
-
// Simple approach: construct ISO string directly with timezone offset
|
|
962
|
-
// America/Edmonton is UTC-6 (MST) or UTC-7 (MDT), but we'll let the backend handle validation
|
|
963
|
-
const [hours, minutes] = selectedStartTime.split(':').map(Number);
|
|
964
|
-
const hoursStr = String(hours).padStart(2, '0');
|
|
965
|
-
const minutesStr = String(minutes).padStart(2, '0');
|
|
966
|
-
|
|
967
|
-
// Construct ISO 8601 string: YYYY-MM-DDTHH:mm:ss with timezone offset
|
|
968
|
-
// We'll use UTC-6 (MST) as the offset for America/Edmonton
|
|
969
|
-
// Format: 2026-06-01T06:00:00-06:00
|
|
970
|
-
const startTimeISO = `${selectedDate}T${hoursStr}:${minutesStr}:00-06:00`;
|
|
971
|
-
|
|
972
|
-
// Get the hotel name if a pickup location was selected
|
|
973
|
-
const selectedPickupLocation = pickupLocationId
|
|
974
|
-
? product.pickupLocations?.find(loc => loc.id === pickupLocationId)
|
|
975
|
-
: null;
|
|
976
|
-
|
|
977
|
-
const reservation = await createReservation({
|
|
978
|
-
productId: selectedOption,
|
|
979
|
-
dateTime: selectedDate, // Date-only for Private Shuttle
|
|
980
|
-
bookingItems,
|
|
981
|
-
currency: currency,
|
|
982
|
-
startTime: startTimeISO,
|
|
983
|
-
passengerCount: passengerCount,
|
|
984
|
-
pickupLocationId: pickupLocationId || undefined,
|
|
985
|
-
cancellationPolicyId: cancellationPolicyId || undefined,
|
|
986
|
-
promoCode: isAdmin && appliedPromoCode ? appliedPromoCode : undefined,
|
|
987
|
-
// Pass hotel name when pickup location is selected (for reference)
|
|
988
|
-
// Don't set travelerHotel when user selects "I don't know" - leave it undefined
|
|
989
|
-
// This allows us to distinguish between "unknown" (null) and "unmapped hotel name" (not null)
|
|
990
|
-
travelerHotel: selectedPickupLocation?.name || customPickupAddress || undefined,
|
|
991
|
-
// Draft itinerary (destinations, planning notes only)
|
|
992
|
-
draftItinerary:
|
|
993
|
-
draftItineraryDestinations.length > 0 || draftItineraryPlanningNotes.trim()
|
|
994
|
-
? {
|
|
995
|
-
destinations: draftItineraryDestinations,
|
|
996
|
-
planningNotes: draftItineraryPlanningNotes.trim() || undefined,
|
|
997
|
-
}
|
|
998
|
-
: undefined,
|
|
999
|
-
childSafetySeatsCount: childSafetySeatsCount > 0 ? childSafetySeatsCount : undefined,
|
|
1000
|
-
foodRestrictions: foodRestrictions.trim() || undefined,
|
|
1001
|
-
addOnSelections: addOnSelections.length > 0 ? addOnSelections : undefined,
|
|
1002
|
-
additionalHoursCount: isAdmin && additionalHoursCount > 0 ? additionalHoursCount : undefined,
|
|
1003
|
-
});
|
|
1004
|
-
|
|
1005
|
-
if (!reservation || !reservation.reservationReference) {
|
|
1006
|
-
throw new Error('Invalid reservation response: missing reservationReference');
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
onSuccess?.({
|
|
1010
|
-
reservationReference: reservation.reservationReference,
|
|
1011
|
-
});
|
|
1012
|
-
|
|
1013
|
-
const amountToPay = depositInfo?.depositAmount ?? totalPrice;
|
|
1014
|
-
// Always send full line items (Shuttle, GST, etc.) so receipt shows full breakdown.
|
|
1015
|
-
// For deposit: we charge amountToPay but receipt stores full total + line items.
|
|
1016
|
-
const lines = [
|
|
1017
|
-
{
|
|
1018
|
-
label: resourceCount > 1 ? 'Shuttles' : 'Shuttle',
|
|
1019
|
-
amount: basePrice,
|
|
1020
|
-
type: 'TICKET' as const,
|
|
1021
|
-
quantity: resourceCount,
|
|
1022
|
-
},
|
|
1023
|
-
...addOnSelections.map((sel) => {
|
|
1024
|
-
const addOn = addOns.find((a) => a.addOnId === sel.addOnId);
|
|
1025
|
-
if (!addOn) return null;
|
|
1026
|
-
const base = addOn.price ?? 0;
|
|
1027
|
-
const hasVariant = (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') && sel.variantId;
|
|
1028
|
-
const adj = hasVariant
|
|
1029
|
-
? (addOn.variants?.find((v) => v.id === sel.variantId)?.priceAdjustment ?? 0)
|
|
1030
|
-
: 0;
|
|
1031
|
-
const amt = (base + adj) * (sel.quantity ?? 1);
|
|
1032
|
-
const variantLabel = hasVariant
|
|
1033
|
-
? addOn.variants?.find((v) => v.id === sel.variantId)?.label
|
|
1034
|
-
: null;
|
|
1035
|
-
const qty = sel.quantity ?? 1;
|
|
1036
|
-
return { label: variantLabel ? `${addOn.name} (${variantLabel})${qty > 1 ? ` × ${qty}` : ''}` : addOn.name, amount: amt, type: 'FEE' as const };
|
|
1037
|
-
}).filter(Boolean) as Array<{ label: string; amount: number; type: 'FEE' }>,
|
|
1038
|
-
...(additionalHoursAmount > 0
|
|
1039
|
-
? [{
|
|
1040
|
-
label: additionalHoursCount === 1 ? 'Additional hour' : `Additional hours (${additionalHoursCount})`,
|
|
1041
|
-
amount: additionalHoursAmount,
|
|
1042
|
-
type: 'ADDITIONAL_HOURS' as const,
|
|
1043
|
-
}]
|
|
1044
|
-
: []),
|
|
1045
|
-
...(cancellationPolicyFee > 0 && selectedCancellationPolicy
|
|
1046
|
-
? [{
|
|
1047
|
-
label: selectedCancellationPolicy.label,
|
|
1048
|
-
amount: cancellationPolicyFee,
|
|
1049
|
-
type: 'CANCELLATION_UPGRADE' as const,
|
|
1050
|
-
}]
|
|
1051
|
-
: []),
|
|
1052
|
-
...(taxAmount > 0
|
|
1053
|
-
? [
|
|
1054
|
-
{
|
|
1055
|
-
label: t('booking.tax') !== 'booking.tax' ? t('booking.tax') : 'Taxes and fees',
|
|
1056
|
-
amount: taxAmount,
|
|
1057
|
-
type: 'TAX' as const,
|
|
1058
|
-
},
|
|
1059
|
-
]
|
|
1060
|
-
: []),
|
|
1061
|
-
];
|
|
1062
|
-
const receiptTotal = depositInfo ? depositInfo.totalPrice : totalPrice;
|
|
1063
|
-
const linesWithPromo = [
|
|
1064
|
-
...lines,
|
|
1065
|
-
...(promoDiscountAmount > 0
|
|
1066
|
-
? [{
|
|
1067
|
-
label: appliedPromoCode ? `Promo: ${appliedPromoCode}` : (t('booking.discount') || 'Discount'),
|
|
1068
|
-
amount: -promoDiscountAmount,
|
|
1069
|
-
type: isGiftCard ? ('GIFT_CARD' as const) : ('PROMO_CODE' as const),
|
|
1070
|
-
}]
|
|
1071
|
-
: []),
|
|
1072
|
-
];
|
|
1073
|
-
const checkoutBreakdown = buildCheckoutBreakdown({
|
|
1074
|
-
lines: linesWithPromo,
|
|
1075
|
-
totalAmount: receiptTotal,
|
|
1076
|
-
currency,
|
|
1077
|
-
roundingLabel: t('booking.rounding') || 'Rounding',
|
|
1078
|
-
});
|
|
1079
|
-
|
|
1080
|
-
// Build itineraryDisplay for storage (same for paid and confirm-without-payment flows)
|
|
1081
|
-
const itineraryDisplayForStorage: ItineraryDisplayStep[] = itineraryDisplayItems.map((item, i) => {
|
|
1082
|
-
if (item.isProposedStops) {
|
|
1083
|
-
return { stepType: ItineraryStepType.draft, time: 'Proposed stops', place: item.label ?? undefined };
|
|
1084
|
-
}
|
|
1085
|
-
const isPickup = i === 0;
|
|
1086
|
-
return {
|
|
1087
|
-
stepType: isPickup ? ItineraryStepType.pickup : ItineraryStepType.drop_off,
|
|
1088
|
-
time: item.time ?? 'TBD',
|
|
1089
|
-
place: item.clickableLabel ?? undefined,
|
|
1090
|
-
};
|
|
1091
|
-
});
|
|
1092
|
-
|
|
1093
|
-
// Admin + deposit: show choice to pay or confirm without payment
|
|
1094
|
-
if (isAdmin && depositInfo) {
|
|
1095
|
-
setAdminChoiceData({
|
|
1096
|
-
reservationReference: reservation.reservationReference,
|
|
1097
|
-
checkoutBreakdown,
|
|
1098
|
-
depositAmount: depositInfo.depositAmount,
|
|
1099
|
-
balanceAmount: depositInfo.balanceAmount,
|
|
1100
|
-
totalAmount: depositInfo.totalPrice,
|
|
1101
|
-
balanceChargeDaysBefore: privateShuttleConfig?.balanceChargeDaysBefore ?? 7,
|
|
1102
|
-
itineraryDisplay: itineraryDisplayForStorage,
|
|
1103
|
-
pickupLocationId: pickupLocationId ?? undefined,
|
|
1104
|
-
});
|
|
1105
|
-
setShowAdminPaymentChoice(true);
|
|
1106
|
-
setLoading(false);
|
|
1107
|
-
return;
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
const paymentIntent = await createPaymentIntent({
|
|
1111
|
-
customerFirstName: firstName.trim() || undefined,
|
|
1112
|
-
customerLastName: lastName.trim() || undefined,
|
|
1113
|
-
productId: product.productId,
|
|
1114
|
-
optionId: selectedOption,
|
|
1115
|
-
date: selectedDate,
|
|
1116
|
-
time: selectedStartTime,
|
|
1117
|
-
quantity: resourceCount,
|
|
1118
|
-
customerEmail: email,
|
|
1119
|
-
currency: currency,
|
|
1120
|
-
reservationReference: reservation.reservationReference,
|
|
1121
|
-
travelerHotel: selectedPickupLocation?.name || customPickupAddress || undefined,
|
|
1122
|
-
pickupLocationId: pickupLocationId ?? undefined,
|
|
1123
|
-
itineraryDisplay: itineraryDisplayForStorage,
|
|
1124
|
-
termsAcceptedAt: termsAcceptedAt ?? undefined,
|
|
1125
|
-
cancellationPolicyId: cancellationPolicyId || undefined,
|
|
1126
|
-
promoCode: isAdmin && appliedPromoCode ? appliedPromoCode : undefined,
|
|
1127
|
-
checkoutBreakdown,
|
|
1128
|
-
...(depositInfo && {
|
|
1129
|
-
paymentPlanType: 'DEPOSIT' as const,
|
|
1130
|
-
depositAmount: depositInfo.depositAmount,
|
|
1131
|
-
balanceAmount: depositInfo.balanceAmount,
|
|
1132
|
-
totalAmount: depositInfo.totalPrice,
|
|
1133
|
-
balanceChargeDaysBefore: privateShuttleConfig?.balanceChargeDaysBefore ?? 7,
|
|
1134
|
-
}),
|
|
1135
|
-
skipConfirmationCommunications: isAdmin && skipConfirmationCommunications ? true : undefined,
|
|
1136
|
-
disableAutoCommunications: isAdmin && disableAutoCommunications ? true : undefined,
|
|
1137
|
-
});
|
|
1138
|
-
|
|
1139
|
-
// Free booking (e.g. voucher covers full total): confirm without payment, then redirect to success
|
|
1140
|
-
if (paymentIntent.freeBooking) {
|
|
1141
|
-
const freeBookingResult = await confirmFreeBooking({
|
|
1142
|
-
reservationReference: reservation.reservationReference,
|
|
1143
|
-
productId: product.productId,
|
|
1144
|
-
optionId: selectedOption,
|
|
1145
|
-
date: selectedDate,
|
|
1146
|
-
time: selectedStartTime,
|
|
1147
|
-
customerEmail: email || undefined,
|
|
1148
|
-
customerFirstName: firstName.trim() || undefined,
|
|
1149
|
-
customerLastName: lastName.trim() || undefined,
|
|
1150
|
-
currency: currency,
|
|
1151
|
-
travelerHotel: selectedPickupLocation?.name || customPickupAddress || undefined,
|
|
1152
|
-
pickupLocationId: pickupLocationId || undefined,
|
|
1153
|
-
itineraryDisplay: itineraryDisplayForStorage,
|
|
1154
|
-
termsAcceptedAt: termsAcceptedAt ?? undefined,
|
|
1155
|
-
skipConfirmationCommunications: isAdmin && skipConfirmationCommunications ? true : undefined,
|
|
1156
|
-
disableAutoCommunications: isAdmin && disableAutoCommunications ? true : undefined,
|
|
1157
|
-
});
|
|
1158
|
-
// Show manage UI: in provider-dashboard use callback (e.g. dialog); otherwise redirect to /manage
|
|
1159
|
-
const ref = formatBookingRefForDisplay(freeBookingResult.bookingReference);
|
|
1160
|
-
const ln = lastName.trim();
|
|
1161
|
-
if (onShowManage) {
|
|
1162
|
-
onShowManage({ ref, lastName: ln });
|
|
1163
|
-
} else {
|
|
1164
|
-
const params = new URLSearchParams({ ref, lastName: ln });
|
|
1165
|
-
window.location.href = `/manage?${params.toString()}`;
|
|
1166
|
-
}
|
|
1167
|
-
setLoading(false);
|
|
1168
|
-
return;
|
|
1169
|
-
}
|
|
1170
|
-
|
|
1171
|
-
const ticketLinesForModal: CheckoutModalLineItem[] = [
|
|
1172
|
-
{
|
|
1173
|
-
line: {
|
|
1174
|
-
category: resourceCount > 1 ? 'Shuttles' : 'Shuttle',
|
|
1175
|
-
qty: resourceCount,
|
|
1176
|
-
pricePerUnit: resourcePrice,
|
|
1177
|
-
itemTotal: basePrice,
|
|
1178
|
-
},
|
|
1179
|
-
breakdown: null,
|
|
1180
|
-
},
|
|
1181
|
-
];
|
|
1182
|
-
const feeLineItemsForModal: { name: string; totalAmount: number }[] = [
|
|
1183
|
-
...addOnSelections.map((sel) => {
|
|
1184
|
-
const addOn = addOns.find((a) => a.addOnId === sel.addOnId);
|
|
1185
|
-
if (!addOn) return null;
|
|
1186
|
-
const base = addOn.price ?? 0;
|
|
1187
|
-
const hasVariant = (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') && sel.variantId;
|
|
1188
|
-
const adj = hasVariant ? (addOn.variants?.find((v) => v.id === sel.variantId)?.priceAdjustment ?? 0) : 0;
|
|
1189
|
-
const amt = (base + adj) * (sel.quantity ?? 1);
|
|
1190
|
-
const variantLabel = hasVariant ? addOn.variants?.find((v) => v.id === sel.variantId)?.label : null;
|
|
1191
|
-
const qty = sel.quantity ?? 1;
|
|
1192
|
-
const label = variantLabel ? `${addOn.name} (${variantLabel})${qty > 1 ? ` × ${qty}` : ''}` : addOn.name;
|
|
1193
|
-
return { name: label, totalAmount: amt };
|
|
1194
|
-
}).filter(Boolean) as { name: string; totalAmount: number }[],
|
|
1195
|
-
...(additionalHoursAmount > 0
|
|
1196
|
-
? [{ name: additionalHoursCount === 1 ? 'Additional hour' : `${additionalHoursCount} additional hours`, totalAmount: additionalHoursAmount }]
|
|
1197
|
-
: []),
|
|
1198
|
-
];
|
|
1199
|
-
|
|
1200
|
-
setCheckoutClientSecret(paymentIntent.clientSecret ?? '');
|
|
1201
|
-
setCheckoutModalData({
|
|
1202
|
-
reservationReference: reservation.reservationReference,
|
|
1203
|
-
customerLastName: lastName.trim(),
|
|
1204
|
-
ticketLines: ticketLinesForModal,
|
|
1205
|
-
feeLineItems: feeLineItemsForModal,
|
|
1206
|
-
returnPriceAdjustment: 0,
|
|
1207
|
-
subtotal: depositInfo ? totalPrice : subtotal,
|
|
1208
|
-
tax: taxAmount,
|
|
1209
|
-
total: amountToPay,
|
|
1210
|
-
totalQuantity: resourceCount,
|
|
1211
|
-
isTaxIncludedInPrice,
|
|
1212
|
-
taxRate,
|
|
1213
|
-
isDepositPayment: !!depositInfo,
|
|
1214
|
-
balanceChargeDaysBefore: depositInfo ? (privateShuttleConfig?.balanceChargeDaysBefore ?? 7) : undefined,
|
|
1215
|
-
cancellationPolicyFee: cancellationPolicyFee > 0 ? cancellationPolicyFee : undefined,
|
|
1216
|
-
cancellationPolicyLabel: selectedCancellationPolicy?.label,
|
|
1217
|
-
promoDiscountAmount: promoDiscountAmount > 0 ? promoDiscountAmount : 0,
|
|
1218
|
-
discountLabel: appliedPromoCode ? `Promo: ${appliedPromoCode}` : undefined,
|
|
1219
|
-
});
|
|
1220
|
-
setShowCheckoutModal(true);
|
|
1221
|
-
setLoading(false);
|
|
1222
|
-
} catch (err) {
|
|
1223
|
-
setError(err instanceof Error ? err.message : 'Something went wrong');
|
|
1224
|
-
setLoading(false);
|
|
1225
|
-
}
|
|
1226
|
-
};
|
|
1227
|
-
|
|
1228
|
-
const handleConfirmWithoutPayment = async () => {
|
|
1229
|
-
if (!adminChoiceData) return;
|
|
1230
|
-
setLoading(true);
|
|
1231
|
-
setError('');
|
|
1232
|
-
try {
|
|
1233
|
-
const result = await confirmBookingWithoutPayment({
|
|
1234
|
-
reservationReference: adminChoiceData.reservationReference,
|
|
1235
|
-
productId: product.productId,
|
|
1236
|
-
optionId: selectedOption,
|
|
1237
|
-
date: selectedDate,
|
|
1238
|
-
time: selectedStartTime,
|
|
1239
|
-
customerEmail: email || undefined,
|
|
1240
|
-
customerFirstName: firstName.trim() || undefined,
|
|
1241
|
-
customerLastName: lastName.trim() || undefined,
|
|
1242
|
-
currency: currency,
|
|
1243
|
-
travelerHotel: selectedPickupLocation?.name || customPickupAddress || undefined,
|
|
1244
|
-
pickupLocationId: adminChoiceData.pickupLocationId ?? pickupLocationId ?? undefined,
|
|
1245
|
-
itineraryDisplay: adminChoiceData.itineraryDisplay,
|
|
1246
|
-
termsAcceptedAt: termsAcceptedAt ?? undefined,
|
|
1247
|
-
skipConfirmationCommunications: skipConfirmationCommunications ? true : undefined,
|
|
1248
|
-
disableAutoCommunications: disableAutoCommunications ? true : undefined,
|
|
1249
|
-
checkoutBreakdown: adminChoiceData.checkoutBreakdown,
|
|
1250
|
-
depositAmount: adminChoiceData.depositAmount,
|
|
1251
|
-
balanceAmount: adminChoiceData.balanceAmount,
|
|
1252
|
-
totalAmount: adminChoiceData.totalAmount,
|
|
1253
|
-
balanceChargeDaysBefore: adminChoiceData.balanceChargeDaysBefore,
|
|
1254
|
-
});
|
|
1255
|
-
const ref = formatBookingRefForDisplay(result.bookingReference);
|
|
1256
|
-
const ln = lastName.trim();
|
|
1257
|
-
setShowAdminPaymentChoice(false);
|
|
1258
|
-
setAdminChoiceData(null);
|
|
1259
|
-
if (onShowManage) {
|
|
1260
|
-
onShowManage({ ref, lastName: ln });
|
|
1261
|
-
} else {
|
|
1262
|
-
const params = new URLSearchParams({ ref, lastName: ln });
|
|
1263
|
-
window.location.href = `/manage?${params.toString()}`;
|
|
1264
|
-
}
|
|
1265
|
-
} catch (err) {
|
|
1266
|
-
setError(err instanceof Error ? err.message : 'Failed to confirm booking');
|
|
1267
|
-
} finally {
|
|
1268
|
-
setLoading(false);
|
|
1269
|
-
}
|
|
1270
|
-
};
|
|
1271
|
-
|
|
1272
|
-
const handlePayDepositNow = async () => {
|
|
1273
|
-
if (!adminChoiceData) return;
|
|
1274
|
-
setLoading(true);
|
|
1275
|
-
setError('');
|
|
1276
|
-
try {
|
|
1277
|
-
const paymentIntent = await createPaymentIntent({
|
|
1278
|
-
customerFirstName: firstName.trim() || undefined,
|
|
1279
|
-
customerLastName: lastName.trim() || undefined,
|
|
1280
|
-
productId: product.productId,
|
|
1281
|
-
optionId: selectedOption,
|
|
1282
|
-
date: selectedDate,
|
|
1283
|
-
time: selectedStartTime,
|
|
1284
|
-
quantity: resourceCount,
|
|
1285
|
-
customerEmail: email,
|
|
1286
|
-
currency: currency,
|
|
1287
|
-
reservationReference: adminChoiceData.reservationReference,
|
|
1288
|
-
travelerHotel: selectedPickupLocation?.name || customPickupAddress || undefined,
|
|
1289
|
-
pickupLocationId: adminChoiceData.pickupLocationId ?? pickupLocationId ?? undefined,
|
|
1290
|
-
itineraryDisplay: adminChoiceData.itineraryDisplay,
|
|
1291
|
-
termsAcceptedAt: termsAcceptedAt ?? undefined,
|
|
1292
|
-
cancellationPolicyId: cancellationPolicyId || undefined,
|
|
1293
|
-
promoCode: isAdmin && appliedPromoCode ? appliedPromoCode : undefined,
|
|
1294
|
-
checkoutBreakdown: adminChoiceData.checkoutBreakdown,
|
|
1295
|
-
paymentPlanType: 'DEPOSIT' as const,
|
|
1296
|
-
depositAmount: adminChoiceData.depositAmount,
|
|
1297
|
-
balanceAmount: adminChoiceData.balanceAmount,
|
|
1298
|
-
totalAmount: adminChoiceData.totalAmount,
|
|
1299
|
-
balanceChargeDaysBefore: adminChoiceData.balanceChargeDaysBefore,
|
|
1300
|
-
skipConfirmationCommunications: skipConfirmationCommunications ? true : undefined,
|
|
1301
|
-
disableAutoCommunications: disableAutoCommunications ? true : undefined,
|
|
1302
|
-
});
|
|
1303
|
-
setShowAdminPaymentChoice(false);
|
|
1304
|
-
setAdminChoiceData(null);
|
|
1305
|
-
const ticketLinesForModal: CheckoutModalLineItem[] = [
|
|
1306
|
-
{
|
|
1307
|
-
line: {
|
|
1308
|
-
category: resourceCount > 1 ? 'Shuttles' : 'Shuttle',
|
|
1309
|
-
qty: resourceCount,
|
|
1310
|
-
pricePerUnit: resourcePrice,
|
|
1311
|
-
itemTotal: basePrice,
|
|
1312
|
-
},
|
|
1313
|
-
breakdown: null,
|
|
1314
|
-
},
|
|
1315
|
-
];
|
|
1316
|
-
const feeLineItemsForPayDeposit: { name: string; totalAmount: number }[] = [
|
|
1317
|
-
...addOnSelections.map((sel) => {
|
|
1318
|
-
const addOn = addOns.find((a) => a.addOnId === sel.addOnId);
|
|
1319
|
-
if (!addOn) return null;
|
|
1320
|
-
const base = addOn.price ?? 0;
|
|
1321
|
-
const hasVariant = (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') && sel.variantId;
|
|
1322
|
-
const adj = hasVariant ? (addOn.variants?.find((v) => v.id === sel.variantId)?.priceAdjustment ?? 0) : 0;
|
|
1323
|
-
const amt = (base + adj) * (sel.quantity ?? 1);
|
|
1324
|
-
const variantLabel = hasVariant ? addOn.variants?.find((v) => v.id === sel.variantId)?.label : null;
|
|
1325
|
-
const qty = sel.quantity ?? 1;
|
|
1326
|
-
const label = variantLabel ? `${addOn.name} (${variantLabel})${qty > 1 ? ` × ${qty}` : ''}` : addOn.name;
|
|
1327
|
-
return { name: label, totalAmount: amt };
|
|
1328
|
-
}).filter(Boolean) as { name: string; totalAmount: number }[],
|
|
1329
|
-
...(additionalHoursAmount > 0
|
|
1330
|
-
? [{ name: additionalHoursCount === 1 ? 'Additional hour' : `${additionalHoursCount} additional hours`, totalAmount: additionalHoursAmount }]
|
|
1331
|
-
: []),
|
|
1332
|
-
];
|
|
1333
|
-
setCheckoutClientSecret(paymentIntent.clientSecret ?? '');
|
|
1334
|
-
setCheckoutModalData({
|
|
1335
|
-
reservationReference: adminChoiceData.reservationReference,
|
|
1336
|
-
customerLastName: lastName.trim(),
|
|
1337
|
-
ticketLines: ticketLinesForModal,
|
|
1338
|
-
feeLineItems: feeLineItemsForPayDeposit,
|
|
1339
|
-
returnPriceAdjustment: 0,
|
|
1340
|
-
subtotal: totalPrice,
|
|
1341
|
-
tax: taxAmount,
|
|
1342
|
-
total: adminChoiceData.depositAmount,
|
|
1343
|
-
totalQuantity: resourceCount,
|
|
1344
|
-
isTaxIncludedInPrice,
|
|
1345
|
-
taxRate,
|
|
1346
|
-
isDepositPayment: true,
|
|
1347
|
-
balanceChargeDaysBefore: adminChoiceData.balanceChargeDaysBefore,
|
|
1348
|
-
promoDiscountAmount: promoDiscountAmount > 0 ? promoDiscountAmount : 0,
|
|
1349
|
-
discountLabel: appliedPromoCode ? `Promo: ${appliedPromoCode}` : undefined,
|
|
1350
|
-
});
|
|
1351
|
-
setShowCheckoutModal(true);
|
|
1352
|
-
} catch (err) {
|
|
1353
|
-
setError(err instanceof Error ? err.message : 'Failed to start payment');
|
|
1354
|
-
} finally {
|
|
1355
|
-
setLoading(false);
|
|
1356
|
-
}
|
|
1357
|
-
};
|
|
1358
|
-
|
|
1359
|
-
if (activeOptions.length === 0) {
|
|
1360
|
-
return (
|
|
1361
|
-
<div className="flex items-center justify-center py-16">
|
|
1362
|
-
<div className="text-red-600">{t('booking.noActiveOption') || 'No active product options available'}</div>
|
|
1363
|
-
</div>
|
|
1364
|
-
);
|
|
1365
|
-
}
|
|
1366
|
-
|
|
1367
|
-
return (
|
|
1368
|
-
<div className="space-y-8">
|
|
1369
|
-
{/* Admin: choose to pay or confirm without payment */}
|
|
1370
|
-
{showAdminPaymentChoice && adminChoiceData && (
|
|
1371
|
-
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
|
1372
|
-
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6">
|
|
1373
|
-
<h3 className="text-lg font-semibold text-stone-900 mb-2">Complete booking</h3>
|
|
1374
|
-
<p className="text-sm text-stone-600 mb-4">
|
|
1375
|
-
Pay the deposit now, or confirm without payment. The customer can pay from the Manage Booking page.
|
|
1376
|
-
</p>
|
|
1377
|
-
{error && (
|
|
1378
|
-
<p className="text-sm text-red-600 mb-4" role="alert">{error}</p>
|
|
1379
|
-
)}
|
|
1380
|
-
<div className="flex flex-col gap-3">
|
|
1381
|
-
<button
|
|
1382
|
-
type="button"
|
|
1383
|
-
onClick={handlePayDepositNow}
|
|
1384
|
-
disabled={loading}
|
|
1385
|
-
className="w-full py-3 px-4 bg-emerald-600 text-white font-semibold rounded-lg hover:bg-emerald-700 disabled:opacity-50"
|
|
1386
|
-
>
|
|
1387
|
-
{loading ? 'Loading...' : `Pay deposit now (${formatCurrencyAmount(adminChoiceData.depositAmount, currency)})`}
|
|
1388
|
-
</button>
|
|
1389
|
-
<button
|
|
1390
|
-
type="button"
|
|
1391
|
-
onClick={handleConfirmWithoutPayment}
|
|
1392
|
-
disabled={loading}
|
|
1393
|
-
className="w-full py-3 px-4 border border-stone-300 text-stone-700 rounded-lg hover:bg-stone-50 disabled:opacity-50"
|
|
1394
|
-
>
|
|
1395
|
-
Confirm without payment
|
|
1396
|
-
</button>
|
|
1397
|
-
<button
|
|
1398
|
-
type="button"
|
|
1399
|
-
onClick={() => { setShowAdminPaymentChoice(false); setAdminChoiceData(null); setError(''); }}
|
|
1400
|
-
className="w-full py-2 text-sm text-stone-500 hover:text-stone-700"
|
|
1401
|
-
>
|
|
1402
|
-
Cancel
|
|
1403
|
-
</button>
|
|
1404
|
-
</div>
|
|
1405
|
-
</div>
|
|
1406
|
-
</div>
|
|
1407
|
-
)}
|
|
1408
|
-
{checkoutModalData && (
|
|
1409
|
-
<CheckoutModal
|
|
1410
|
-
open={showCheckoutModal}
|
|
1411
|
-
onClose={() => {
|
|
1412
|
-
setShowCheckoutModal(false);
|
|
1413
|
-
setCheckoutClientSecret('');
|
|
1414
|
-
setCheckoutModalData(null);
|
|
1415
|
-
}}
|
|
1416
|
-
clientSecret={checkoutClientSecret}
|
|
1417
|
-
reservationReference={checkoutModalData.reservationReference}
|
|
1418
|
-
customerLastName={checkoutModalData.customerLastName}
|
|
1419
|
-
successUrlOverride={getSuccessUrl ? getSuccessUrl({ reservationRef: checkoutModalData.reservationReference, lastName: checkoutModalData.customerLastName ?? '' }) : undefined}
|
|
1420
|
-
ticketLines={checkoutModalData.ticketLines}
|
|
1421
|
-
feeLineItems={checkoutModalData.feeLineItems}
|
|
1422
|
-
returnPriceAdjustment={checkoutModalData.returnPriceAdjustment}
|
|
1423
|
-
cancellationPolicyFee={checkoutModalData.cancellationPolicyFee}
|
|
1424
|
-
cancellationPolicyLabel={checkoutModalData.cancellationPolicyLabel}
|
|
1425
|
-
subtotal={checkoutModalData.subtotal}
|
|
1426
|
-
tax={checkoutModalData.tax}
|
|
1427
|
-
total={checkoutModalData.total}
|
|
1428
|
-
totalQuantity={checkoutModalData.totalQuantity}
|
|
1429
|
-
isTaxIncludedInPrice={checkoutModalData.isTaxIncludedInPrice}
|
|
1430
|
-
taxRate={checkoutModalData.taxRate}
|
|
1431
|
-
currency={currency}
|
|
1432
|
-
locale={locale}
|
|
1433
|
-
t={t}
|
|
1434
|
-
isDepositPayment={checkoutModalData.isDepositPayment}
|
|
1435
|
-
balanceChargeDaysBefore={checkoutModalData.balanceChargeDaysBefore}
|
|
1436
|
-
promoDiscountAmount={checkoutModalData.promoDiscountAmount ?? 0}
|
|
1437
|
-
discountLabel={checkoutModalData.discountLabel}
|
|
1438
|
-
/>
|
|
1439
|
-
)}
|
|
1440
|
-
{/* Back button */}
|
|
1441
|
-
<button
|
|
1442
|
-
onClick={onBack}
|
|
1443
|
-
className="flex items-center gap-2 text-stone-600 hover:text-stone-900 transition-colors"
|
|
1444
|
-
>
|
|
1445
|
-
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1446
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
1447
|
-
</svg>
|
|
1448
|
-
<span>{t('products.backToExperiences')}</span>
|
|
1449
|
-
</button>
|
|
1450
|
-
|
|
1451
|
-
{/* Product header */}
|
|
1452
|
-
<div className="bg-gradient-to-r from-emerald-700 to-emerald-600 text-white p-8 rounded-xl">
|
|
1453
|
-
<h2 className="text-2xl font-bold mb-2">{product.name}</h2>
|
|
1454
|
-
{product.description && (
|
|
1455
|
-
<p className="text-emerald-100">{product.description}</p>
|
|
1456
|
-
)}
|
|
1457
|
-
</div>
|
|
1458
|
-
|
|
1459
|
-
{loadingAvailabilities && availabilities.length === 0 ? (
|
|
1460
|
-
<div className="flex items-center justify-center py-12">
|
|
1461
|
-
<div className="text-center">
|
|
1462
|
-
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-emerald-600 mb-4"></div>
|
|
1463
|
-
<div className="text-stone-600">{t('booking.loadingTimes')}</div>
|
|
1464
|
-
</div>
|
|
1465
|
-
</div>
|
|
1466
|
-
) : availabilities.length === 0 ? (
|
|
1467
|
-
<div className="text-center py-8 text-stone-500">
|
|
1468
|
-
{t('booking.noAvailability')}
|
|
1469
|
-
</div>
|
|
1470
|
-
) : (
|
|
1471
|
-
<>
|
|
1472
|
-
{/* Date Selection - first in date-first flow */}
|
|
1473
|
-
<div>
|
|
1474
|
-
<div className="relative">
|
|
1475
|
-
{loadingAvailabilities && (
|
|
1476
|
-
<div className="absolute inset-0 bg-white/80 backdrop-blur-sm flex items-center justify-center z-10 rounded-lg">
|
|
1477
|
-
<div className="text-stone-600">{t('booking.loadingTimes')}</div>
|
|
1478
|
-
</div>
|
|
1479
|
-
)}
|
|
1480
|
-
<Calendar
|
|
1481
|
-
availabilitiesByDate={availabilitiesByDate}
|
|
1482
|
-
selectedDate={selectedDate}
|
|
1483
|
-
onDateSelect={handleDateSelect}
|
|
1484
|
-
timezone={companyTimezone}
|
|
1485
|
-
earliestDate={earliestAvailabilityDate}
|
|
1486
|
-
onVisibleRangeChange={handleVisibleRangeChange}
|
|
1487
|
-
currency={currency}
|
|
1488
|
-
/>
|
|
1489
|
-
</div>
|
|
1490
|
-
</div>
|
|
1491
|
-
|
|
1492
|
-
{/* Build Your Itinerary - sticky section shown after date selection */}
|
|
1493
|
-
{selectedDate && (
|
|
1494
|
-
<div
|
|
1495
|
-
ref={itineraryRef}
|
|
1496
|
-
className="sticky top-[136px] sm:top-[73px] z-10 mb-4 p-3 bg-white rounded-lg shadow-sm border border-stone-200"
|
|
1497
|
-
>
|
|
1498
|
-
<h3 className="font-bold text-stone-900 flex items-center gap-2 mb-2">
|
|
1499
|
-
<svg className="flex-shrink-0 w-4 h-4 text-stone-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1500
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
|
1501
|
-
</svg>
|
|
1502
|
-
{t('booking.buildYourItinerary')}
|
|
1503
|
-
</h3>
|
|
1504
|
-
{!selectedOption ? (
|
|
1505
|
-
<p className="text-sm text-stone-600">
|
|
1506
|
-
{t('booking.selectTourOptionToSeeItinerary')}
|
|
1507
|
-
</p>
|
|
1508
|
-
) : (
|
|
1509
|
-
<div className="space-y-1">
|
|
1510
|
-
{itineraryDisplayItems.map((item, index) => (
|
|
1511
|
-
<div key={index} className="flex items-center gap-2 text-sm">
|
|
1512
|
-
{item.time != null && (
|
|
1513
|
-
<span className="font-semibold text-stone-700 tabular-nums shrink-0">{item.time}</span>
|
|
1514
|
-
)}
|
|
1515
|
-
<span className="text-stone-600">
|
|
1516
|
-
{item.isProposedStops ? (
|
|
1517
|
-
<>
|
|
1518
|
-
<b>{t('booking.proposedStops') || 'Proposed stops'}:</b> {item.label} <em>{item.timesNote}</em>
|
|
1519
|
-
</>
|
|
1520
|
-
) : item.clickableLabel ? (
|
|
1521
|
-
<>
|
|
1522
|
-
{item.prefix}
|
|
1523
|
-
<button
|
|
1524
|
-
type="button"
|
|
1525
|
-
onClick={handlePickupLocationClick}
|
|
1526
|
-
className="text-stone-400 hover:text-stone-600 underline cursor-pointer"
|
|
1527
|
-
>
|
|
1528
|
-
{item.clickableLabel}
|
|
1529
|
-
</button>
|
|
1530
|
-
</>
|
|
1531
|
-
) : (
|
|
1532
|
-
item.label
|
|
1533
|
-
)}
|
|
1534
|
-
</span>
|
|
1535
|
-
</div>
|
|
1536
|
-
))}
|
|
1537
|
-
</div>
|
|
1538
|
-
)}
|
|
1539
|
-
</div>
|
|
1540
|
-
)}
|
|
1541
|
-
|
|
1542
|
-
{/* Select Tour Option - grid of option buttons (duration + starting price) */}
|
|
1543
|
-
{selectedDate && optionsAvailableForSelectedDate.length > 0 && (
|
|
1544
|
-
<div>
|
|
1545
|
-
<label className="block text-sm font-medium text-stone-700 mb-2">
|
|
1546
|
-
{t('booking.selectTourOption')}
|
|
1547
|
-
</label>
|
|
1548
|
-
<div className="grid grid-cols-2 gap-2">
|
|
1549
|
-
{[...optionsAvailableForSelectedDate]
|
|
1550
|
-
.sort((a, b) => (b.mostPopular ? 1 : 0) - (a.mostPopular ? 1 : 0))
|
|
1551
|
-
.map((option) => {
|
|
1552
|
-
const isSelected = selectedOption === option.optionId;
|
|
1553
|
-
const baseDurationMinutes = option.privateShuttleConfig?.baseDurationMinutes ?? 0;
|
|
1554
|
-
const hours = baseDurationMinutes / 60;
|
|
1555
|
-
const price = getOptionPrice(option.optionId);
|
|
1556
|
-
const formattedPrice = formatCurrencyAmount(price, currency);
|
|
1557
|
-
const hoursRounded = Math.round(hours);
|
|
1558
|
-
const isMostPopular = optionsAvailableForSelectedDate.length > 1 && option.mostPopular;
|
|
1559
|
-
const priceHoursLine = price > 0 && hours >= 1
|
|
1560
|
-
? (t('booking.startingAtForHours') ?? 'Starting at {price} per shuttle for {hours} hours.')
|
|
1561
|
-
.replace('{price}', formattedPrice)
|
|
1562
|
-
.replace('{hours}', String(hoursRounded))
|
|
1563
|
-
: null;
|
|
1564
|
-
return (
|
|
1565
|
-
<button
|
|
1566
|
-
key={option.optionId}
|
|
1567
|
-
onClick={() => handleOptionSelect(option.optionId)}
|
|
1568
|
-
className={`${isMostPopular ? 'pt-5 sm:pt-4' : 'pt-3'} pb-3 px-4 rounded-lg text-sm font-medium transition-all relative text-left ${
|
|
1569
|
-
isSelected
|
|
1570
|
-
? 'bg-emerald-600 text-white'
|
|
1571
|
-
: 'bg-stone-100 text-stone-700 hover:bg-stone-200'
|
|
1572
|
-
}`}
|
|
1573
|
-
>
|
|
1574
|
-
<div className="font-semibold">{option.name}</div>
|
|
1575
|
-
{priceHoursLine && (
|
|
1576
|
-
<div className={`mt-1 text-xs ${isSelected ? 'text-emerald-100' : 'text-stone-500'}`}>
|
|
1577
|
-
{(() => {
|
|
1578
|
-
const template = t('booking.startingAtForHours') ?? 'Starting at {price} per shuttle for {hours}.';
|
|
1579
|
-
const parts = template.split(/\{price\}|\{hours\}/);
|
|
1580
|
-
const placeholders = template.match(/\{price\}|\{hours\}/g) || [];
|
|
1581
|
-
const result: React.ReactNode[] = [];
|
|
1582
|
-
parts.forEach((part, i) => {
|
|
1583
|
-
result.push(part);
|
|
1584
|
-
if (i < placeholders.length) {
|
|
1585
|
-
const value = placeholders[i] === '{price}' ? formattedPrice : `${hoursRounded} ${t('booking.hoursUnit') || 'hours'}`;
|
|
1586
|
-
result.push(<b key={i}>{value}</b>);
|
|
1587
|
-
}
|
|
1588
|
-
});
|
|
1589
|
-
return result;
|
|
1590
|
-
})()}
|
|
1591
|
-
</div>
|
|
1592
|
-
)}
|
|
1593
|
-
{priceHoursLine && (
|
|
1594
|
-
<div className={`mt-0.5 text-[11px] ${isSelected ? 'text-emerald-200/90' : 'text-stone-400'}`}>
|
|
1595
|
-
{t('booking.additionalHoursAvailable')}
|
|
1596
|
-
</div>
|
|
1597
|
-
)}
|
|
1598
|
-
{isMostPopular && (
|
|
1599
|
-
<div className="absolute -top-1 left-1/2 -translate-x-1/2 text-white text-[10px] font-semibold px-2.5 py-0.5 rounded-full whitespace-nowrap" style={{ backgroundColor: '#ff4d00' }}>
|
|
1600
|
-
{t('booking.mostPopular')}
|
|
1601
|
-
</div>
|
|
1602
|
-
)}
|
|
1603
|
-
</button>
|
|
1604
|
-
);
|
|
1605
|
-
})}
|
|
1606
|
-
</div>
|
|
1607
|
-
</div>
|
|
1608
|
-
)}
|
|
1609
|
-
|
|
1610
|
-
{/* Passenger Count - Step 2: shown after option selection. Max is based on available resources (vacancies). */}
|
|
1611
|
-
{selectedOption && selectedAvailability && (
|
|
1612
|
-
<div>
|
|
1613
|
-
<label className="block text-sm font-medium text-stone-700 mb-4">
|
|
1614
|
-
Number of Passengers
|
|
1615
|
-
</label>
|
|
1616
|
-
<div className="p-4 bg-stone-50 rounded-lg">
|
|
1617
|
-
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
1618
|
-
<div>
|
|
1619
|
-
<div className="font-medium text-stone-900">Passengers</div>
|
|
1620
|
-
<div className="text-sm text-stone-600">
|
|
1621
|
-
{resourceCount} {resourceCount === 1 ? 'shuttle' : 'shuttles'} needed
|
|
1622
|
-
</div>
|
|
1623
|
-
</div>
|
|
1624
|
-
<select
|
|
1625
|
-
value={passengerCount}
|
|
1626
|
-
onChange={(e) => handlePassengerCountChange(Number(e.target.value))}
|
|
1627
|
-
className="w-16 px-2 py-1.5 text-center border border-stone-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-stone-400 focus:border-stone-500 bg-white"
|
|
1628
|
-
>
|
|
1629
|
-
{Array.from({ length: Math.max(1, selectedAvailability?.vacancies || 0) }, (_, i) => i + 1).map((n) => (
|
|
1630
|
-
<option key={n} value={n}>
|
|
1631
|
-
{n}
|
|
1632
|
-
</option>
|
|
1633
|
-
))}
|
|
1634
|
-
</select>
|
|
1635
|
-
</div>
|
|
1636
|
-
<div className="text-xs text-stone-500 mt-2">
|
|
1637
|
-
Each shuttle can accommodate up to {RESOURCE_CAPACITY} passengers.
|
|
1638
|
-
{resourceCount > 1 && (
|
|
1639
|
-
<span> You'll need {resourceCount} shuttles for {passengerCount} passengers.</span>
|
|
1640
|
-
)}
|
|
1641
|
-
</div>
|
|
1642
|
-
</div>
|
|
1643
|
-
</div>
|
|
1644
|
-
)}
|
|
1645
|
-
|
|
1646
|
-
{/* Pickup Time Selection - suggested times + option to request custom */}
|
|
1647
|
-
{selectedOption && selectedAvailability && passengerCount > 0 && (
|
|
1648
|
-
<div>
|
|
1649
|
-
<label className="block text-sm font-medium text-stone-700 mb-2">
|
|
1650
|
-
{t('booking.selectPickupTime')}
|
|
1651
|
-
</label>
|
|
1652
|
-
{(suggestedStartTimes.length > 0 || isCustomTimeMode) && (
|
|
1653
|
-
<div className="flex flex-wrap gap-2">
|
|
1654
|
-
{suggestedStartTimes.map((time) => {
|
|
1655
|
-
const isSelected = !isCustomTimeMode && selectedStartTime === time;
|
|
1656
|
-
const [hours, minutes] = time.split(':').map(Number);
|
|
1657
|
-
const timeDate = new Date();
|
|
1658
|
-
timeDate.setHours(hours, minutes, 0, 0);
|
|
1659
|
-
const displayTime = format(timeDate, 'h:mm a');
|
|
1660
|
-
return (
|
|
1661
|
-
<button
|
|
1662
|
-
key={time}
|
|
1663
|
-
type="button"
|
|
1664
|
-
onClick={() => handleStartTimeSelect(time)}
|
|
1665
|
-
className={`py-2 px-4 rounded-full text-sm font-medium transition-all ${
|
|
1666
|
-
isSelected
|
|
1667
|
-
? 'bg-emerald-600 text-white'
|
|
1668
|
-
: 'bg-stone-100 text-stone-700 hover:bg-stone-200'
|
|
1669
|
-
}`}
|
|
1670
|
-
>
|
|
1671
|
-
{displayTime}
|
|
1672
|
-
</button>
|
|
1673
|
-
);
|
|
1674
|
-
})}
|
|
1675
|
-
{suggestedStartTimes.length > 0 && (
|
|
1676
|
-
<button
|
|
1677
|
-
type="button"
|
|
1678
|
-
onClick={handleCustomTimeRequest}
|
|
1679
|
-
className={`py-2 px-4 rounded-full text-sm font-medium transition-all ${
|
|
1680
|
-
isCustomTimeMode
|
|
1681
|
-
? 'bg-emerald-600 text-white'
|
|
1682
|
-
: 'bg-stone-100 text-stone-700 hover:bg-stone-200'
|
|
1683
|
-
}`}
|
|
1684
|
-
>
|
|
1685
|
-
{t('booking.requestDifferentTime')}
|
|
1686
|
-
</button>
|
|
1687
|
-
)}
|
|
1688
|
-
</div>
|
|
1689
|
-
)}
|
|
1690
|
-
{(isCustomTimeMode || suggestedStartTimes.length === 0) && (
|
|
1691
|
-
<div className="mt-3">
|
|
1692
|
-
<label htmlFor="custom-pickup-time" className="block text-xs text-stone-500 mb-1">
|
|
1693
|
-
{suggestedStartTimes.length === 0 ? t('booking.preferredPickupTime') : t('booking.requestDifferentTime')}
|
|
1694
|
-
</label>
|
|
1695
|
-
<input
|
|
1696
|
-
id="custom-pickup-time"
|
|
1697
|
-
type="time"
|
|
1698
|
-
value={selectedStartTime}
|
|
1699
|
-
onChange={(e) => handleCustomTimeChange(e.target.value)}
|
|
1700
|
-
className="px-3 py-2 border border-stone-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-stone-400 focus:border-stone-500"
|
|
1701
|
-
/>
|
|
1702
|
-
</div>
|
|
1703
|
-
)}
|
|
1704
|
-
</div>
|
|
1705
|
-
)}
|
|
1706
|
-
|
|
1707
|
-
{/* Itinerary Builder - Step 3: destinations, lunch stop, planning notes */}
|
|
1708
|
-
{selectedOption && passengerCount > 0 && product.itineraryBuilder && (
|
|
1709
|
-
<div className="border-t border-stone-200 pt-6 space-y-6">
|
|
1710
|
-
<ItineraryBuilder
|
|
1711
|
-
destinations={product.itineraryBuilder.destinations}
|
|
1712
|
-
optionBlacklist={privateShuttleConfig?.itineraryBuilderConfig?.optionBlacklist || []}
|
|
1713
|
-
selectedDestinationIds={draftItineraryDestinations}
|
|
1714
|
-
planningNotes={draftItineraryPlanningNotes}
|
|
1715
|
-
onDestinationsChange={setDraftItineraryDestinations}
|
|
1716
|
-
onPlanningNotesChange={setDraftItineraryPlanningNotes}
|
|
1717
|
-
/>
|
|
1718
|
-
{/* Admin only: add on hours ($170/hour, extends duration) */}
|
|
1719
|
-
{isAdmin && (
|
|
1720
|
-
<div className="p-4 bg-amber-50/50 border border-amber-200 rounded-lg">
|
|
1721
|
-
<label className="block text-sm font-medium text-stone-700 mb-2">
|
|
1722
|
-
Add on hours (admin)
|
|
1723
|
-
</label>
|
|
1724
|
-
<p className="text-sm text-stone-600 mb-3">
|
|
1725
|
-
Each additional hour adds {formatCurrencyAmount(ADDITIONAL_HOUR_PRICE, currency)} and extends the tour duration.
|
|
1726
|
-
</p>
|
|
1727
|
-
<div className="flex items-center gap-3">
|
|
1728
|
-
<button
|
|
1729
|
-
type="button"
|
|
1730
|
-
onClick={() => setAdditionalHoursCount((c) => Math.max(0, c - 1))}
|
|
1731
|
-
disabled={additionalHoursCount <= 0}
|
|
1732
|
-
className="h-9 w-9 rounded-full border border-stone-300 bg-white text-stone-600 hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed font-medium"
|
|
1733
|
-
>
|
|
1734
|
-
−
|
|
1735
|
-
</button>
|
|
1736
|
-
<span className="w-8 text-center font-medium tabular-nums">{additionalHoursCount}</span>
|
|
1737
|
-
<button
|
|
1738
|
-
type="button"
|
|
1739
|
-
onClick={() => setAdditionalHoursCount((c) => c + 1)}
|
|
1740
|
-
className="h-9 w-9 rounded-full border border-stone-300 bg-white text-stone-600 hover:bg-stone-50 font-medium"
|
|
1741
|
-
>
|
|
1742
|
-
+
|
|
1743
|
-
</button>
|
|
1744
|
-
<span className="text-sm text-stone-600">
|
|
1745
|
-
{additionalHoursCount === 0
|
|
1746
|
-
? 'No extra hours'
|
|
1747
|
-
: additionalHoursCount === 1
|
|
1748
|
-
? `+1 hour • ${formatCurrencyAmount(ADDITIONAL_HOUR_PRICE, currency)}`
|
|
1749
|
-
: `+${additionalHoursCount} hours • ${formatCurrencyAmount(additionalHoursAmount, currency)}`}
|
|
1750
|
-
</span>
|
|
1751
|
-
</div>
|
|
1752
|
-
</div>
|
|
1753
|
-
)}
|
|
1754
|
-
</div>
|
|
1755
|
-
)}
|
|
1756
|
-
|
|
1757
|
-
{/* Safety seats - Step 4 */}
|
|
1758
|
-
{selectedOption && passengerCount > 0 && (
|
|
1759
|
-
<div className="border-t border-stone-200 pt-6">
|
|
1760
|
-
<label className="block text-sm font-medium text-stone-700 mb-2">
|
|
1761
|
-
Safety seats for kids
|
|
1762
|
-
</label>
|
|
1763
|
-
<p className="text-sm text-stone-500 mb-2">How many child safety seats do you need?</p>
|
|
1764
|
-
<div className="flex items-center gap-2">
|
|
1765
|
-
<button
|
|
1766
|
-
type="button"
|
|
1767
|
-
onClick={() => setChildSafetySeatsCount((c) => Math.max(0, c - 1))}
|
|
1768
|
-
disabled={childSafetySeatsCount <= 0}
|
|
1769
|
-
className="h-9 w-9 rounded-full border border-stone-300 bg-white text-stone-600 hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
1770
|
-
>
|
|
1771
|
-
−
|
|
1772
|
-
</button>
|
|
1773
|
-
<span className="w-8 text-center font-medium tabular-nums">{childSafetySeatsCount}</span>
|
|
1774
|
-
<button
|
|
1775
|
-
type="button"
|
|
1776
|
-
onClick={() => setChildSafetySeatsCount((c) => Math.min(passengerCount, c + 1))}
|
|
1777
|
-
disabled={childSafetySeatsCount >= passengerCount}
|
|
1778
|
-
className="h-9 w-9 rounded-full border border-stone-300 bg-white text-stone-600 hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
1779
|
-
>
|
|
1780
|
-
+
|
|
1781
|
-
</button>
|
|
1782
|
-
</div>
|
|
1783
|
-
</div>
|
|
1784
|
-
)}
|
|
1785
|
-
|
|
1786
|
-
{/* Food restrictions - Step 5 */}
|
|
1787
|
-
{selectedOption && passengerCount > 0 && (
|
|
1788
|
-
<div className="border-t border-stone-200 pt-6">
|
|
1789
|
-
<label htmlFor="food-restrictions" className="block text-sm font-medium text-stone-700 mb-2">
|
|
1790
|
-
Food restrictions
|
|
1791
|
-
</label>
|
|
1792
|
-
<p className="text-sm text-stone-500 mb-2">Shuttle includes croissants, coffee, tea, hot chocolate, trail snacks.</p>
|
|
1793
|
-
<textarea
|
|
1794
|
-
id="food-restrictions"
|
|
1795
|
-
value={foodRestrictions}
|
|
1796
|
-
onChange={(e) => setFoodRestrictions(e.target.value)}
|
|
1797
|
-
placeholder="Any dietary restrictions or allergies?"
|
|
1798
|
-
rows={2}
|
|
1799
|
-
className="w-full px-3 py-2 border border-stone-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-stone-400 focus:border-stone-500 text-sm"
|
|
1800
|
-
/>
|
|
1801
|
-
</div>
|
|
1802
|
-
)}
|
|
1803
|
-
|
|
1804
|
-
{/* Add-ons - Step 6: EL lunch (only when Emerald Lake selected), Animals */}
|
|
1805
|
-
{selectedOption && passengerCount > 0 && addOns.length > 0 && (
|
|
1806
|
-
<div className="border-t border-stone-200 pt-6 space-y-4">
|
|
1807
|
-
{/* Emerald Lake Lunch Package - only when emerald_lake is in destinations */}
|
|
1808
|
-
{draftItineraryDestinations.includes('emerald_lake') && (() => {
|
|
1809
|
-
const lunchAddOn = addOns.find((a) => a.addOnId === 'addon_el_lunch');
|
|
1810
|
-
if (!lunchAddOn || !canUseMealDrinkSelector(lunchAddOn)) return null;
|
|
1811
|
-
return (
|
|
1812
|
-
<MealDrinkAddOnSelector
|
|
1813
|
-
addOn={lunchAddOn}
|
|
1814
|
-
selections={addOnSelections}
|
|
1815
|
-
onSelectionsChange={setAddOnSelections}
|
|
1816
|
-
currency={currency}
|
|
1817
|
-
locale={locale}
|
|
1818
|
-
/>
|
|
1819
|
-
);
|
|
1820
|
-
})()}
|
|
1821
|
-
{/* Animals - $50 cleaning fee */}
|
|
1822
|
-
{(() => {
|
|
1823
|
-
const animalsAddOn = addOns.find((a) => a.addOnId === 'addon_animals');
|
|
1824
|
-
if (!animalsAddOn) return null;
|
|
1825
|
-
const isAnimalsSelected = addOnSelections.some((s) => s.addOnId === 'addon_animals');
|
|
1826
|
-
return (
|
|
1827
|
-
<div>
|
|
1828
|
-
<label className="block text-sm font-medium text-stone-700 mb-2">
|
|
1829
|
-
Animals?
|
|
1830
|
-
</label>
|
|
1831
|
-
<p className="text-sm text-stone-500 mb-2">{animalsAddOn.description || 'Cleaning fee for traveling with animals'}</p>
|
|
1832
|
-
<button
|
|
1833
|
-
type="button"
|
|
1834
|
-
onClick={() => {
|
|
1835
|
-
setAddOnSelections((prev) => {
|
|
1836
|
-
if (isAnimalsSelected) return prev.filter((s) => s.addOnId !== 'addon_animals');
|
|
1837
|
-
return [...prev, { addOnId: 'addon_animals', quantity: 1 }];
|
|
1838
|
-
});
|
|
1839
|
-
}}
|
|
1840
|
-
className={`flex items-center justify-between w-full p-4 rounded-lg border-2 text-left transition-colors ${
|
|
1841
|
-
isAnimalsSelected ? 'border-emerald-500 bg-emerald-50' : 'border-stone-200 bg-white hover:border-stone-300'
|
|
1842
|
-
}`}
|
|
1843
|
-
>
|
|
1844
|
-
<span className="font-medium text-stone-900">
|
|
1845
|
-
{isAnimalsSelected ? 'Yes, traveling with animals' : 'No animals'}
|
|
1846
|
-
</span>
|
|
1847
|
-
<span className="text-sm font-semibold text-stone-700">
|
|
1848
|
-
+{formatCurrencyAmount(animalsAddOn.price || 0, currency, locale)}
|
|
1849
|
-
</span>
|
|
1850
|
-
</button>
|
|
1851
|
-
</div>
|
|
1852
|
-
);
|
|
1853
|
-
})()}
|
|
1854
|
-
</div>
|
|
1855
|
-
)}
|
|
1856
|
-
|
|
1857
|
-
{/* Pickup Location Selection - Step 6 */}
|
|
1858
|
-
{selectedOption && passengerCount > 0 &&
|
|
1859
|
-
product.pickupLocations &&
|
|
1860
|
-
product.pickupLocations.length > 0 && (
|
|
1861
|
-
<div id="pickup-location-section" className="border-t border-stone-200 pt-6">
|
|
1862
|
-
{pickupLocationId || customPickupAddress || pickupLocationSkipped ? (
|
|
1863
|
-
<div className="space-y-4">
|
|
1864
|
-
<div className="flex items-center justify-between">
|
|
1865
|
-
<div>
|
|
1866
|
-
<label className="block text-sm font-medium text-stone-700 mb-1">
|
|
1867
|
-
{t('pickup.pickupLocation') || 'Pickup Location'}
|
|
1868
|
-
</label>
|
|
1869
|
-
{pickupLocationSkipped ? (
|
|
1870
|
-
<p className="text-sm text-stone-900">
|
|
1871
|
-
{t('booking.pickupLocationUnknown')}
|
|
1872
|
-
</p>
|
|
1873
|
-
) : customPickupAddress ? (
|
|
1874
|
-
<p className="text-sm text-stone-900">
|
|
1875
|
-
{customPickupAddress}
|
|
1876
|
-
</p>
|
|
1877
|
-
) : (
|
|
1878
|
-
<>
|
|
1879
|
-
<p className="text-sm text-stone-900">
|
|
1880
|
-
{selectedPickupLocation?.name}
|
|
1881
|
-
</p>
|
|
1882
|
-
<p className="text-xs text-stone-500">
|
|
1883
|
-
{selectedPickupLocation?.address}
|
|
1884
|
-
</p>
|
|
1885
|
-
{selectedPickupLocation?.notes && (
|
|
1886
|
-
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded p-2 mt-2">
|
|
1887
|
-
{selectedPickupLocation.notes}
|
|
1888
|
-
</p>
|
|
1889
|
-
)}
|
|
1890
|
-
</>
|
|
1891
|
-
)}
|
|
1892
|
-
</div>
|
|
1893
|
-
<button
|
|
1894
|
-
onClick={() => {
|
|
1895
|
-
setPickupLocationId(null);
|
|
1896
|
-
setCustomPickupAddress(null);
|
|
1897
|
-
setPickupLocationSkipped(false);
|
|
1898
|
-
setSelectedStartTime('');
|
|
1899
|
-
setIsCustomTimeMode(false);
|
|
1900
|
-
}}
|
|
1901
|
-
className="text-sm text-emerald-600 hover:text-emerald-700 underline"
|
|
1902
|
-
>
|
|
1903
|
-
{t('common.change') || 'Change'}
|
|
1904
|
-
</button>
|
|
1905
|
-
</div>
|
|
1906
|
-
</div>
|
|
1907
|
-
) : (
|
|
1908
|
-
<PickupLocationSelector
|
|
1909
|
-
pickupLocations={product.pickupLocations}
|
|
1910
|
-
selectedLocationId={pickupLocationId}
|
|
1911
|
-
selectedCustomAddress={customPickupAddress}
|
|
1912
|
-
allowCustomLocation
|
|
1913
|
-
isSkipped={pickupLocationSkipped}
|
|
1914
|
-
destinations={product.destinations}
|
|
1915
|
-
onLocationSelect={(locationId, customLocation) => {
|
|
1916
|
-
setError('');
|
|
1917
|
-
if (customLocation) {
|
|
1918
|
-
setPickupLocationId(null);
|
|
1919
|
-
setCustomPickupAddress(customLocation.address);
|
|
1920
|
-
setPickupLocationSkipped(false);
|
|
1921
|
-
} else {
|
|
1922
|
-
setPickupLocationId(locationId);
|
|
1923
|
-
setCustomPickupAddress(null);
|
|
1924
|
-
if (locationId === null && pickupLocationSkipped) {
|
|
1925
|
-
setPickupLocationSkipped(false);
|
|
1926
|
-
} else if (locationId !== null) {
|
|
1927
|
-
setPickupLocationSkipped(false);
|
|
1928
|
-
}
|
|
1929
|
-
}
|
|
1930
|
-
}}
|
|
1931
|
-
onSkip={() => {
|
|
1932
|
-
setPickupLocationSkipped(true);
|
|
1933
|
-
setPickupLocationId(null);
|
|
1934
|
-
setCustomPickupAddress(null);
|
|
1935
|
-
setError('');
|
|
1936
|
-
}}
|
|
1937
|
-
/>
|
|
1938
|
-
)}
|
|
1939
|
-
</div>
|
|
1940
|
-
)}
|
|
1941
|
-
|
|
1942
|
-
{/* Price Summary, Contact, Terms & Checkout — below pickup location */}
|
|
1943
|
-
{selectedStartTime && passengerCount > 0 && (
|
|
1944
|
-
<div className="border-t border-stone-200 pt-6 mt-6 space-y-4">
|
|
1945
|
-
{/* Cancellation policy - forced by promo or user selection */}
|
|
1946
|
-
{(forcedCancellationPolicyFromPromo || (pricingConfig?.cancellationPolicies && pricingConfig.cancellationPolicies.length > 0)) && (
|
|
1947
|
-
<div>
|
|
1948
|
-
<div className="text-sm font-medium text-stone-700 mb-2">
|
|
1949
|
-
{t('booking.cancellationPolicy')}
|
|
1950
|
-
</div>
|
|
1951
|
-
{forcedCancellationPolicyFromPromo ? (
|
|
1952
|
-
<div className="flex items-center gap-3 p-4 rounded-lg border-2 border-amber-200 bg-amber-50">
|
|
1953
|
-
<Check className="h-5 w-5 shrink-0 text-amber-600" />
|
|
1954
|
-
<div>
|
|
1955
|
-
<span className="font-medium text-stone-900">{forcedCancellationPolicyFromPromo.label}</span>
|
|
1956
|
-
<p className="text-sm text-stone-600 mt-0.5">{t('booking.promoRequiresThisPolicy')}</p>
|
|
1957
|
-
</div>
|
|
1958
|
-
</div>
|
|
1959
|
-
) : (
|
|
1960
|
-
<div className="space-y-2">
|
|
1961
|
-
{pricingConfig!.cancellationPolicies!.map((policy) => {
|
|
1962
|
-
const fee = policy.feeByCurrency[currency] ?? 0;
|
|
1963
|
-
const isSelected = cancellationPolicyId === policy.id;
|
|
1964
|
-
const isFree = fee === 0;
|
|
1965
|
-
return (
|
|
1966
|
-
<button
|
|
1967
|
-
key={policy.id}
|
|
1968
|
-
type="button"
|
|
1969
|
-
onClick={() => setCancellationPolicyId(policy.id)}
|
|
1970
|
-
className={`w-full flex items-center justify-between gap-3 p-4 rounded-lg border-2 text-left transition-colors ${
|
|
1971
|
-
isSelected
|
|
1972
|
-
? 'border-emerald-500 bg-emerald-50'
|
|
1973
|
-
: 'border-stone-200 bg-white hover:border-stone-300'
|
|
1974
|
-
}`}
|
|
1975
|
-
>
|
|
1976
|
-
<div className="flex items-center gap-3">
|
|
1977
|
-
<span className={`flex h-5 w-5 shrink-0 items-center justify-center rounded border ${
|
|
1978
|
-
isSelected ? 'border-emerald-500 bg-emerald-500' : 'border-stone-300'
|
|
1979
|
-
}`}>
|
|
1980
|
-
{isSelected && <Check className="h-3 w-3 text-white" />}
|
|
1981
|
-
</span>
|
|
1982
|
-
<span className="font-medium text-stone-900">
|
|
1983
|
-
{policy.label}
|
|
1984
|
-
</span>
|
|
1985
|
-
</div>
|
|
1986
|
-
<span className={`text-sm whitespace-nowrap ${isFree ? 'text-stone-500' : 'font-semibold text-stone-700'}`}>
|
|
1987
|
-
{isFree ? (t('booking.included') ?? 'Included') : `+${formatCurrencyAmount(fee, currency, locale)}`}
|
|
1988
|
-
</span>
|
|
1989
|
-
</button>
|
|
1990
|
-
);
|
|
1991
|
-
})}
|
|
1992
|
-
</div>
|
|
1993
|
-
)}
|
|
1994
|
-
</div>
|
|
1995
|
-
)}
|
|
1996
|
-
{/* Duration Info */}
|
|
1997
|
-
{calculatedDuration > 0 && (
|
|
1998
|
-
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
|
|
1999
|
-
<div className="text-sm text-stone-600 mb-1">Duration</div>
|
|
2000
|
-
<div className="text-lg font-semibold text-emerald-900">
|
|
2001
|
-
{Math.floor(calculatedDuration / 60)}h {calculatedDuration % 60}m
|
|
2002
|
-
</div>
|
|
2003
|
-
{calculatedEndTime && selectedStartTime && (
|
|
2004
|
-
<div className="text-sm text-stone-600 mt-1">
|
|
2005
|
-
Start: {(() => {
|
|
2006
|
-
const [hours, minutes] = selectedStartTime.split(':').map(Number);
|
|
2007
|
-
const timeDate = new Date();
|
|
2008
|
-
timeDate.setHours(hours, minutes);
|
|
2009
|
-
return format(timeDate, 'h:mm a');
|
|
2010
|
-
})()} • End: {calculatedEndTime}
|
|
2011
|
-
</div>
|
|
2012
|
-
)}
|
|
2013
|
-
</div>
|
|
2014
|
-
)}
|
|
2015
|
-
<PriceSummary
|
|
2016
|
-
lines={[
|
|
2017
|
-
{
|
|
2018
|
-
kind: 'line',
|
|
2019
|
-
label: `Shuttle${resourceCount > 1 ? 's' : ''} (${resourceCount} × ${formatCurrencyAmount(resourcePrice, currency)})`,
|
|
2020
|
-
amount: basePrice,
|
|
2021
|
-
type: 'TICKET',
|
|
2022
|
-
quantity: resourceCount,
|
|
2023
|
-
},
|
|
2024
|
-
...addOnSelections.map((sel) => {
|
|
2025
|
-
const addOn = addOns.find((a) => a.addOnId === sel.addOnId);
|
|
2026
|
-
if (!addOn) return null;
|
|
2027
|
-
const base = addOn.price ?? 0;
|
|
2028
|
-
const hasVariant = (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') && sel.variantId;
|
|
2029
|
-
const adj = hasVariant
|
|
2030
|
-
? (addOn.variants?.find((v) => v.id === sel.variantId)?.priceAdjustment ?? 0)
|
|
2031
|
-
: 0;
|
|
2032
|
-
const amt = (base + adj) * (sel.quantity ?? 1);
|
|
2033
|
-
const variantLabel = hasVariant
|
|
2034
|
-
? addOn.variants?.find((v) => v.id === sel.variantId)?.label
|
|
2035
|
-
: null;
|
|
2036
|
-
const qty = sel.quantity ?? 1;
|
|
2037
|
-
return { kind: 'line' as const, label: variantLabel ? `${addOn.name} (${variantLabel})${qty > 1 ? ` × ${qty}` : ''}` : addOn.name, amount: amt, type: 'FEE' as const };
|
|
2038
|
-
}).filter(Boolean) as Array<{ kind: 'line'; label: string; amount: number; type: string }>,
|
|
2039
|
-
...(additionalHoursAmount > 0
|
|
2040
|
-
? [{ kind: 'line' as const, label: additionalHoursCount === 1 ? 'Additional hour' : `Additional hours (${additionalHoursCount})`, amount: additionalHoursAmount, type: 'FEE' as const }]
|
|
2041
|
-
: []),
|
|
2042
|
-
...(cancellationPolicyFee > 0 && selectedCancellationPolicy
|
|
2043
|
-
? [{ kind: 'line' as const, label: selectedCancellationPolicy.label, amount: cancellationPolicyFee, type: 'cancellation' as const }]
|
|
2044
|
-
: []),
|
|
2045
|
-
...(taxAmount > 0
|
|
2046
|
-
? [{ kind: 'line' as const, label: t('booking.tax') !== 'booking.tax' ? t('booking.tax') : 'Taxes and fees', amount: taxAmount, type: 'TAX' as const }]
|
|
2047
|
-
: []),
|
|
2048
|
-
]}
|
|
2049
|
-
total={totalPrice}
|
|
2050
|
-
currency={currency}
|
|
2051
|
-
locale={locale}
|
|
2052
|
-
size="sm"
|
|
2053
|
-
subtotal={taxAmount > 0 || promoDiscountAmount > 0 ? subtotal : undefined}
|
|
2054
|
-
discountAmount={promoDiscountAmount}
|
|
2055
|
-
discountLabel={appliedPromoCode ? `Promo: ${appliedPromoCode}` : undefined}
|
|
2056
|
-
t={t}
|
|
2057
|
-
extraAfterTotal={isChangeMode && initialBooking?.originalTotalAmount != null && initialBooking.originalCurrency === currency ? (
|
|
2058
|
-
<div className="pt-3 mt-2 border-t border-stone-200 space-y-2">
|
|
2059
|
-
<div className="flex justify-between gap-3 min-w-0 text-sm">
|
|
2060
|
-
<span className="text-stone-600 min-w-0 truncate">Price change</span>
|
|
2061
|
-
<span className={`flex-shrink-0 whitespace-nowrap font-medium ${totalPrice - initialBooking.originalTotalAmount >= 0 ? 'text-stone-700' : 'text-red-600'}`}>
|
|
2062
|
-
{totalPrice - initialBooking.originalTotalAmount >= 0 ? '+' : ''}
|
|
2063
|
-
{formatCurrencyAmount(totalPrice - initialBooking.originalTotalAmount, currency, locale)}
|
|
2064
|
-
</span>
|
|
2065
|
-
</div>
|
|
2066
|
-
<div className="flex justify-between gap-3 min-w-0 text-xs text-stone-500">
|
|
2067
|
-
<span>Original: {formatCurrencyAmount(initialBooking.originalTotalAmount, currency, locale)}</span>
|
|
2068
|
-
<span>→ New: {formatCurrencyAmount(totalPrice, currency, locale)}</span>
|
|
2069
|
-
</div>
|
|
2070
|
-
{Math.abs(totalPrice - initialBooking.originalTotalAmount) > 0.01 && (
|
|
2071
|
-
<label className="flex items-center gap-2 text-sm text-stone-600 cursor-pointer">
|
|
2072
|
-
<input
|
|
2073
|
-
type="checkbox"
|
|
2074
|
-
checked={keepOriginalPrice}
|
|
2075
|
-
onChange={(e) => setKeepOriginalPrice(e.target.checked)}
|
|
2076
|
-
className="rounded border-stone-300 text-stone-700 focus:ring-stone-500"
|
|
2077
|
-
/>
|
|
2078
|
-
<span>Keep original price (no charge or refund)</span>
|
|
2079
|
-
</label>
|
|
2080
|
-
)}
|
|
2081
|
-
</div>
|
|
2082
|
-
) : undefined}
|
|
2083
|
-
extraBetweenTaxAndTotal={
|
|
2084
|
-
isAdmin ? (
|
|
2085
|
-
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 pt-2 border-t border-stone-200">
|
|
2086
|
-
<label htmlFor="private-shuttle-promo-code" className="flex-shrink-0 text-sm font-medium text-stone-500 whitespace-nowrap">
|
|
2087
|
-
{t('booking.optionalPromoCode') || 'Promo / voucher / gift card'}
|
|
2088
|
-
</label>
|
|
2089
|
-
<div className="flex-1 min-w-0 w-full sm:w-auto flex items-center gap-2 flex-wrap">
|
|
2090
|
-
<div className="relative flex-1 w-full sm:max-w-[200px] min-w-0 sm:min-w-[120px]">
|
|
2091
|
-
<input
|
|
2092
|
-
type="text"
|
|
2093
|
-
name="promoCode"
|
|
2094
|
-
id="private-shuttle-promo-code"
|
|
2095
|
-
value={promoCodeInput}
|
|
2096
|
-
onChange={(e) => {
|
|
2097
|
-
setPromoCodeInput(e.target.value.toUpperCase());
|
|
2098
|
-
setPromoCodeError('');
|
|
2099
|
-
}}
|
|
2100
|
-
onKeyDown={(e) => {
|
|
2101
|
-
if (e.key === 'Enter') {
|
|
2102
|
-
e.preventDefault();
|
|
2103
|
-
handleApplyPromo();
|
|
2104
|
-
}
|
|
2105
|
-
}}
|
|
2106
|
-
onPaste={() => {
|
|
2107
|
-
setTimeout(() => handleApplyPromoRef.current(), 50);
|
|
2108
|
-
}}
|
|
2109
|
-
placeholder={t('booking.promoCodePlaceholder')}
|
|
2110
|
-
autoComplete="off"
|
|
2111
|
-
readOnly={!!appliedPromoCode}
|
|
2112
|
-
className="w-full pr-8 pl-3 py-1.5 text-sm rounded border border-stone-300 bg-white focus:outline-none focus:border-stone-500 text-stone-900 read-only:bg-stone-50 read-only:cursor-default"
|
|
2113
|
-
/>
|
|
2114
|
-
{appliedPromoCode ? (
|
|
2115
|
-
<button
|
|
2116
|
-
type="button"
|
|
2117
|
-
onClick={() => {
|
|
2118
|
-
setAppliedPromoCode(null);
|
|
2119
|
-
setPromoCodeInput('');
|
|
2120
|
-
setPromoCodeError('');
|
|
2121
|
-
setForcedCancellationPolicyFromPromo(null);
|
|
2122
|
-
fetchedRangesRef.current = [];
|
|
2123
|
-
}}
|
|
2124
|
-
className="absolute right-1 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-stone-200 text-stone-500 hover:text-stone-700"
|
|
2125
|
-
aria-label={t('booking.removePromo')}
|
|
2126
|
-
>
|
|
2127
|
-
<X className="w-4 h-4" strokeWidth={2.5} />
|
|
2128
|
-
</button>
|
|
2129
|
-
) : promoCodeValidating ? (
|
|
2130
|
-
<span className="absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5 rounded-full bg-stone-100 animate-pulse" aria-hidden />
|
|
2131
|
-
) : promoCodeError ? (
|
|
2132
|
-
<span className="absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5 rounded-full bg-red-500 text-white flex items-center justify-center" aria-label={promoCodeError}>
|
|
2133
|
-
<X className="w-3 h-3" strokeWidth={3} />
|
|
2134
|
-
</span>
|
|
2135
|
-
) : null}
|
|
2136
|
-
</div>
|
|
2137
|
-
{appliedPromoCode && (
|
|
2138
|
-
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-emerald-500 text-white flex items-center justify-center" aria-label={t('booking.promoApplied', { code: appliedPromoCode })}>
|
|
2139
|
-
<Check className="w-3.5 h-3.5" strokeWidth={3} />
|
|
2140
|
-
</span>
|
|
2141
|
-
)}
|
|
2142
|
-
{promoDiscountAmount > 0 && (
|
|
2143
|
-
<span className="text-sm font-medium text-red-600 whitespace-nowrap ml-auto">
|
|
2144
|
-
-{formatCurrencyAmount(promoDiscountAmount, currency, locale)}
|
|
2145
|
-
</span>
|
|
2146
|
-
)}
|
|
2147
|
-
{promoCodeError && (
|
|
2148
|
-
<span className="text-sm text-red-600 whitespace-nowrap ml-auto">{promoCodeError}</span>
|
|
2149
|
-
)}
|
|
2150
|
-
{forcedCancellationPolicyFromPromo && (
|
|
2151
|
-
<p className="w-full text-sm text-stone-600 mt-2 flex items-center gap-1.5">
|
|
2152
|
-
<span className="inline-flex items-center rounded bg-amber-50 px-2 py-0.5 text-amber-800 ring-1 ring-amber-200">
|
|
2153
|
-
{t('booking.promoRequiresCancellationPolicy', { policy: forcedCancellationPolicyFromPromo.label })}
|
|
2154
|
-
</span>
|
|
2155
|
-
</p>
|
|
2156
|
-
)}
|
|
2157
|
-
</div>
|
|
2158
|
-
</div>
|
|
2159
|
-
) : undefined
|
|
2160
|
-
}
|
|
2161
|
-
/>
|
|
2162
|
-
{depositInfo && (
|
|
2163
|
-
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 space-y-2">
|
|
2164
|
-
<div className="text-sm font-medium text-amber-900">Payment Plan</div>
|
|
2165
|
-
<div className="flex justify-between text-sm">
|
|
2166
|
-
<span className="text-amber-700">Deposit (Due Now)</span>
|
|
2167
|
-
<span className="font-semibold text-amber-900">
|
|
2168
|
-
{formatCurrencyAmount(depositInfo.depositAmount, currency)}
|
|
2169
|
-
</span>
|
|
2170
|
-
</div>
|
|
2171
|
-
<div className="flex justify-between text-sm">
|
|
2172
|
-
<span className="text-amber-700">Balance (Due Later)</span>
|
|
2173
|
-
<span className="text-amber-900">
|
|
2174
|
-
{formatCurrencyAmount(depositInfo.balanceAmount, currency)}
|
|
2175
|
-
</span>
|
|
2176
|
-
</div>
|
|
2177
|
-
<div className="text-xs text-amber-600 mt-2">
|
|
2178
|
-
Balance will be charged {privateShuttleConfig?.balanceChargeDaysBefore || 7} days before your booking
|
|
2179
|
-
</div>
|
|
2180
|
-
</div>
|
|
2181
|
-
)}
|
|
2182
|
-
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
2183
|
-
<div>
|
|
2184
|
-
<label className="block text-sm font-medium text-stone-700 mb-2">
|
|
2185
|
-
{t('booking.firstName') || 'First Name'}
|
|
2186
|
-
</label>
|
|
2187
|
-
<input
|
|
2188
|
-
type="text"
|
|
2189
|
-
name="firstName"
|
|
2190
|
-
id="private-shuttle-firstName"
|
|
2191
|
-
value={firstName}
|
|
2192
|
-
onChange={(e) => { setFirstName(e.target.value); setError(''); }}
|
|
2193
|
-
placeholder={t('booking.firstNamePlaceholder') || 'Jane'}
|
|
2194
|
-
autoComplete="given-name"
|
|
2195
|
-
className="w-full px-4 py-3 rounded-lg border border-stone-300 focus:outline-none focus:border-stone-500 text-stone-900"
|
|
2196
|
-
/>
|
|
2197
|
-
</div>
|
|
2198
|
-
<div>
|
|
2199
|
-
<label className="block text-sm font-medium text-stone-700 mb-2">
|
|
2200
|
-
{t('booking.lastName') || 'Last Name'} <span className="text-red-600">*</span>
|
|
2201
|
-
</label>
|
|
2202
|
-
<input
|
|
2203
|
-
type="text"
|
|
2204
|
-
name="lastName"
|
|
2205
|
-
id="private-shuttle-lastName"
|
|
2206
|
-
value={lastName}
|
|
2207
|
-
onChange={(e) => { setLastName(e.target.value); setError(''); }}
|
|
2208
|
-
placeholder={t('booking.lastNamePlaceholder') || 'Smith'}
|
|
2209
|
-
autoComplete="family-name"
|
|
2210
|
-
required
|
|
2211
|
-
className="w-full px-4 py-3 rounded-lg border border-stone-300 focus:outline-none focus:border-stone-500 text-stone-900"
|
|
2212
|
-
/>
|
|
2213
|
-
</div>
|
|
2214
|
-
</div>
|
|
2215
|
-
<div>
|
|
2216
|
-
<label className="block text-sm font-medium text-stone-700 mb-2">
|
|
2217
|
-
{t('booking.emailForConfirmation')} <span className="text-red-600">*</span>
|
|
2218
|
-
</label>
|
|
2219
|
-
<input
|
|
2220
|
-
type="email"
|
|
2221
|
-
name="email"
|
|
2222
|
-
id="private-shuttle-email"
|
|
2223
|
-
value={email}
|
|
2224
|
-
onChange={(e) => { setEmail(e.target.value); setError(''); }}
|
|
2225
|
-
placeholder={t('common.emailPlaceholder')}
|
|
2226
|
-
autoComplete="email"
|
|
2227
|
-
required
|
|
2228
|
-
className="w-full px-4 py-3 rounded-lg border border-stone-300 focus:outline-none focus:border-stone-500 text-stone-900"
|
|
2229
|
-
/>
|
|
2230
|
-
{email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && (
|
|
2231
|
-
<p className="text-xs text-red-600 mt-1.5">{t('booking.invalidEmail') || 'Please enter a valid email address'}</p>
|
|
2232
|
-
)}
|
|
2233
|
-
</div>
|
|
2234
|
-
{isAdmin && (
|
|
2235
|
-
<div className="p-4 bg-amber-50/50 border border-amber-200 rounded-lg space-y-3">
|
|
2236
|
-
<p className="text-sm font-medium text-stone-700">Communications (admin)</p>
|
|
2237
|
-
<label className="flex items-start gap-2 cursor-pointer">
|
|
2238
|
-
<input
|
|
2239
|
-
type="checkbox"
|
|
2240
|
-
checked={skipConfirmationCommunications}
|
|
2241
|
-
onChange={(e) => setSkipConfirmationCommunications(e.target.checked)}
|
|
2242
|
-
className="mt-1 h-4 w-4 rounded border-stone-300"
|
|
2243
|
-
/>
|
|
2244
|
-
<span className="text-sm text-stone-600">Don't send confirmation email/SMS/WhatsApp for this booking</span>
|
|
2245
|
-
</label>
|
|
2246
|
-
<label className="flex items-start gap-2 cursor-pointer">
|
|
2247
|
-
<input
|
|
2248
|
-
type="checkbox"
|
|
2249
|
-
checked={disableAutoCommunications}
|
|
2250
|
-
onChange={(e) => setDisableAutoCommunications(e.target.checked)}
|
|
2251
|
-
className="mt-1 h-4 w-4 rounded border-stone-300"
|
|
2252
|
-
/>
|
|
2253
|
-
<span className="text-sm text-stone-600">Disable all auto communications for this booking</span>
|
|
2254
|
-
</label>
|
|
2255
|
-
</div>
|
|
2256
|
-
)}
|
|
2257
|
-
<div className="p-4 bg-stone-50 rounded-lg border border-stone-200">
|
|
2258
|
-
<TermsAcceptance
|
|
2259
|
-
checked={termsAccepted}
|
|
2260
|
-
onChange={(checked) => {
|
|
2261
|
-
setTermsAccepted(checked);
|
|
2262
|
-
setTermsAcceptedAt(checked ? new Date().toISOString() : null);
|
|
2263
|
-
}}
|
|
2264
|
-
t={t}
|
|
2265
|
-
/>
|
|
2266
|
-
</div>
|
|
2267
|
-
{error && (
|
|
2268
|
-
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
|
|
2269
|
-
{error}
|
|
2270
|
-
</div>
|
|
2271
|
-
)}
|
|
2272
|
-
<button
|
|
2273
|
-
onClick={handleCheckout}
|
|
2274
|
-
disabled={loading || !termsAccepted}
|
|
2275
|
-
className="w-full bg-emerald-600 text-white py-4 px-6 rounded-lg font-semibold hover:bg-emerald-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
2276
|
-
>
|
|
2277
|
-
{loading
|
|
2278
|
-
? 'Processing...'
|
|
2279
|
-
: depositInfo
|
|
2280
|
-
? `Pay Deposit (${formatCurrencyAmount(depositInfo.depositAmount, currency, locale)})`
|
|
2281
|
-
: `Book Now (${formatCurrencyAmount(totalPrice, currency, locale)})`}
|
|
2282
|
-
</button>
|
|
2283
|
-
</div>
|
|
2284
|
-
)}
|
|
2285
|
-
</>
|
|
2286
|
-
)}
|
|
2287
|
-
</div>
|
|
2288
|
-
);
|
|
2289
|
-
}
|
|
2290
|
-
|