@ticketboothapp/booking 0.1.11 → 0.1.12
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 +1 -1
- 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,2952 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
|
4
|
-
import { parseISO, addWeeks, format, 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 ReturnOption,
|
|
18
|
-
type AddOn,
|
|
19
|
-
} from '@/lib/api';
|
|
20
|
-
import {
|
|
21
|
-
EARLIEST_AVAILABILITY_DATE,
|
|
22
|
-
LATEST_AVAILABILITY_DATE,
|
|
23
|
-
INITIAL_FETCH_WEEKS,
|
|
24
|
-
} from '@/lib/constants';
|
|
25
|
-
import { PickupLocationSelector } from './PickupLocationSelector';
|
|
26
|
-
import { Calendar } from './Calendar';
|
|
27
|
-
import { useTranslations, useLocale } from '@/lib/i18n';
|
|
28
|
-
import { type Currency } from './CurrencySwitcher';
|
|
29
|
-
import { formatCurrencyAmount } from '@/lib/currency';
|
|
30
|
-
import { formatBookingRefForDisplay } from '@/lib/booking-ref';
|
|
31
|
-
import { buildCheckoutBreakdown } from '@/lib/checkout-breakdown';
|
|
32
|
-
import type { PricingConfig, PrecomputedPricesByCategory, ItineraryDisplayStep } from '@/lib/api';
|
|
33
|
-
import { ItineraryStepType as StepType } from '@/lib/api';
|
|
34
|
-
import { getStepLabel } from '@/lib/itinerary-labels';
|
|
35
|
-
import { getDisplayPriceFromBaseInDisplayCurrency, computePriceBreakdown, computeOrderSummary, type PriceBreakdown as PriceBreakdownData, type OrderSummary } from '@/lib/pricing';
|
|
36
|
-
import { useCompanyTimezone } from '@/contexts/CompanyContext';
|
|
37
|
-
import { useBookingApp } from '@/contexts/BookingAppContext';
|
|
38
|
-
import { PriceSummary, type PriceSummaryLine } from './PriceSummary';
|
|
39
|
-
import { CheckoutModal, type CheckoutModalLineItem } from './CheckoutModal';
|
|
40
|
-
import { TermsAcceptance } from './TermsAcceptance';
|
|
41
|
-
import { MealDrinkAddOnSelector, canUseMealDrinkSelector } from './MealDrinkAddOnSelector';
|
|
42
|
-
import { Check, X } from 'lucide-react';
|
|
43
|
-
|
|
44
|
-
/** Initial booking data for change mode (provider dashboard) */
|
|
45
|
-
export interface InitialBookingForChange {
|
|
46
|
-
bookingReference: string;
|
|
47
|
-
productId: string;
|
|
48
|
-
availabilityId?: string;
|
|
49
|
-
dateTime: string;
|
|
50
|
-
/** Original total (for price delta display). From booking.receipt.totalAmount. */
|
|
51
|
-
originalTotalAmount?: number;
|
|
52
|
-
originalCurrency?: string;
|
|
53
|
-
bookingItems: Array<{ category: string; count: number }>;
|
|
54
|
-
returnAvailabilityId?: string | null;
|
|
55
|
-
pickupLocationId?: string | null;
|
|
56
|
-
travelerHotel?: string | null;
|
|
57
|
-
startTime?: string | null;
|
|
58
|
-
privateShuttleDetails?: { passengerCount?: number };
|
|
59
|
-
cancellationPolicyId?: string | null;
|
|
60
|
-
/** Promo code from existing booking — auto-applied when opening change flow */
|
|
61
|
-
promoCode?: string | null;
|
|
62
|
-
additionalHoursCount?: number | null;
|
|
63
|
-
/** Existing add-ons — pre-filled in change mode so they are sent on submit */
|
|
64
|
-
addOnSelections?: Array<{ addOnId: string; variantId?: string; quantity?: number }> | null;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
interface BookingFlowProps {
|
|
68
|
-
product: Product;
|
|
69
|
-
onBack: () => void;
|
|
70
|
-
currency: Currency;
|
|
71
|
-
/**
|
|
72
|
-
* Optional callback called when reservation is successfully created (before checkout redirect)
|
|
73
|
-
* If provided, indicates we're in an embedded context (e.g., provider dashboard)
|
|
74
|
-
*/
|
|
75
|
-
onSuccess?: (data: { reservationReference: string }) => void;
|
|
76
|
-
/** Change mode: pre-fill from existing booking and call onChangeBooking on submit instead of createReservation */
|
|
77
|
-
initialBooking?: InitialBookingForChange;
|
|
78
|
-
onChangeBooking?: (data: {
|
|
79
|
-
productId: string;
|
|
80
|
-
dateTime: string;
|
|
81
|
-
bookingItems: Array<{ category: string; count: number }>;
|
|
82
|
-
returnAvailabilityId?: string | null;
|
|
83
|
-
pickupLocationId?: string | null;
|
|
84
|
-
travelerHotel?: string | null;
|
|
85
|
-
startTime?: string | null;
|
|
86
|
-
passengerCount?: number | null;
|
|
87
|
-
childSafetySeatsCount?: number | null;
|
|
88
|
-
foodRestrictions?: string | null;
|
|
89
|
-
addOnSelections?: Array<{ addOnId: string; variantId?: string; quantity?: number }> | null;
|
|
90
|
-
cancellationPolicyId?: string | null;
|
|
91
|
-
promoCode?: string | null;
|
|
92
|
-
newTotalAmount?: number;
|
|
93
|
-
keepOriginalPrice?: boolean;
|
|
94
|
-
}) => Promise<void>;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export function BookingFlow({ product, onBack, currency, onSuccess, initialBooking, onChangeBooking }: BookingFlowProps) {
|
|
98
|
-
const { t } = useTranslations();
|
|
99
|
-
const { locale } = useLocale();
|
|
100
|
-
const companyTimezone = useCompanyTimezone(); // Get timezone from context
|
|
101
|
-
const { permissions, isSimplifiedPricingView, onShowManage, getSuccessUrl } = useBookingApp();
|
|
102
|
-
const isAdmin = permissions.viewerRole === 'admin';
|
|
103
|
-
const [availabilities, setAvailabilities] = useState<Availability[]>([]);
|
|
104
|
-
const [selectedAvailability, setSelectedAvailability] = useState<Availability | null>(null);
|
|
105
|
-
const [selectedReturnOption, setSelectedReturnOption] = useState<ReturnOption | null>(null);
|
|
106
|
-
const isChangeMode = !!(initialBooking && onChangeBooking);
|
|
107
|
-
const [quantities, setQuantities] = useState<Record<string, number>>(() => {
|
|
108
|
-
if (initialBooking?.bookingItems?.length) {
|
|
109
|
-
return initialBooking.bookingItems.reduce((acc, i) => ({ ...acc, [i.category]: i.count }), {});
|
|
110
|
-
}
|
|
111
|
-
return {};
|
|
112
|
-
});
|
|
113
|
-
const [email, setEmail] = useState('');
|
|
114
|
-
const [firstName, setFirstName] = useState('');
|
|
115
|
-
const [lastName, setLastName] = useState('');
|
|
116
|
-
const [promoCodeInput, setPromoCodeInput] = useState(() => initialBooking?.promoCode?.trim() ?? '');
|
|
117
|
-
const [appliedPromoCode, setAppliedPromoCode] = useState<string | null>(() => initialBooking?.promoCode?.trim() || null);
|
|
118
|
-
const [promoCodeError, setPromoCodeError] = useState('');
|
|
119
|
-
const [promoCodeValidating, setPromoCodeValidating] = useState(false);
|
|
120
|
-
const [pickupLocationId, setPickupLocationId] = useState<string | null>(initialBooking?.pickupLocationId ?? null);
|
|
121
|
-
const [pickupLocationSkipped, setPickupLocationSkipped] = useState(false);
|
|
122
|
-
// Cancellation policy - will be set to first policy from config (e.g. "standard") when availabilities load
|
|
123
|
-
const [cancellationPolicyId, setCancellationPolicyId] = useState<string | null>(null);
|
|
124
|
-
/** When promo forces a cancellation policy (id + label from validate response). */
|
|
125
|
-
const [forcedCancellationPolicyFromPromo, setForcedCancellationPolicyFromPromo] = useState<{ id: string; label: string } | null>(null);
|
|
126
|
-
/** Add-on selections (lunch, animals, etc.) - filtered by selected product option */
|
|
127
|
-
const [addOnSelections, setAddOnSelections] = useState<Array<{ addOnId: string; variantId?: string; quantity?: number }>>(() =>
|
|
128
|
-
initialBooking?.addOnSelections?.length
|
|
129
|
-
? initialBooking.addOnSelections.map((s) => ({
|
|
130
|
-
addOnId: s.addOnId,
|
|
131
|
-
variantId: s.variantId,
|
|
132
|
-
quantity: s.quantity ?? 1,
|
|
133
|
-
}))
|
|
134
|
-
: []
|
|
135
|
-
);
|
|
136
|
-
/** Fetched add-ons for the selected product option */
|
|
137
|
-
const [addOns, setAddOns] = useState<AddOn[]>([]);
|
|
138
|
-
const [loading, setLoading] = useState(false);
|
|
139
|
-
const [loadingAvailabilities, setLoadingAvailabilities] = useState(true);
|
|
140
|
-
const [error, setError] = useState('');
|
|
141
|
-
const [pricingConfig, setPricingConfig] = useState<PricingConfig | null>(null);
|
|
142
|
-
/** Precomputed prices from ticketbooth-product-prices per option (optionId -> category -> currency -> price). Used for display; rates[].price is for GYG only. */
|
|
143
|
-
const [precomputedPricesByOption, setPrecomputedPricesByOption] = useState<Record<string, PrecomputedPricesByCategory> | null>(null);
|
|
144
|
-
const pricingConfigSetRef = useRef(false); // Track if pricingConfig has been set (optimize: only set once)
|
|
145
|
-
const fetchingRef = useRef(false); // Prevent concurrent fetches
|
|
146
|
-
const fetchedRangesRef = useRef<Array<{ start: Date; end: Date }>>([]); // Track fetched date ranges
|
|
147
|
-
const [refreshKey, setRefreshKey] = useState(0); // Bump to force refetch (e.g. after tab focus)
|
|
148
|
-
const [visibleRange, setVisibleRange] = useState<{ start: Date; end: Date } | null>(null);
|
|
149
|
-
const [selectedDate, setSelectedDate] = useState<string>('');
|
|
150
|
-
const [isItinerarySticky, setIsItinerarySticky] = useState(false);
|
|
151
|
-
const isItineraryStickyRef = useRef(false);
|
|
152
|
-
const [isMobile, setIsMobile] = useState(false);
|
|
153
|
-
const [showTooltip, setShowTooltip] = useState(false);
|
|
154
|
-
const itineraryRef = useRef<HTMLDivElement>(null);
|
|
155
|
-
const [showCheckoutModal, setShowCheckoutModal] = useState(false);
|
|
156
|
-
const [termsAccepted, setTermsAccepted] = useState(false);
|
|
157
|
-
const [termsAcceptedAt, setTermsAcceptedAt] = useState<string | null>(null);
|
|
158
|
-
const [checkoutClientSecret, setCheckoutClientSecret] = useState('');
|
|
159
|
-
const [checkoutModalData, setCheckoutModalData] = useState<{
|
|
160
|
-
reservationReference: string;
|
|
161
|
-
customerLastName?: string;
|
|
162
|
-
ticketLines: CheckoutModalLineItem[];
|
|
163
|
-
feeLineItems: OrderSummary['feeLineItems'];
|
|
164
|
-
returnPriceAdjustment: number;
|
|
165
|
-
cancellationPolicyFee: number;
|
|
166
|
-
cancellationPolicyLabel?: string;
|
|
167
|
-
subtotal: number;
|
|
168
|
-
tax: number;
|
|
169
|
-
total: number;
|
|
170
|
-
promoDiscountAmount?: number;
|
|
171
|
-
discountLabel?: string | null;
|
|
172
|
-
totalQuantity: number;
|
|
173
|
-
isTaxIncludedInPrice: boolean;
|
|
174
|
-
taxRate: number;
|
|
175
|
-
} | null>(null);
|
|
176
|
-
/** Admin only: skip sending confirmation at creation (provider dashboard). */
|
|
177
|
-
const [skipConfirmationCommunications, setSkipConfirmationCommunications] = useState(false);
|
|
178
|
-
/** Admin only: disable all auto communications for this booking (provider dashboard). */
|
|
179
|
-
const [disableAutoCommunications, setDisableAutoCommunications] = useState(false);
|
|
180
|
-
/** Admin only: show choice to pay now or confirm without payment (full balance owed). */
|
|
181
|
-
const [showAdminPaymentChoice, setShowAdminPaymentChoice] = useState(false);
|
|
182
|
-
/** Change mode: when true, apply change but keep original receipt/price — no charge or refund. */
|
|
183
|
-
const [keepOriginalPrice, setKeepOriginalPrice] = useState(false);
|
|
184
|
-
const [adminChoiceData, setAdminChoiceData] = useState<{
|
|
185
|
-
reservationReference: string;
|
|
186
|
-
checkoutBreakdown: { lineItems: Array<{ label: string; amount: number; type?: string; quantity?: number }>; totalAmount: number; currency: string };
|
|
187
|
-
totalAmount: number;
|
|
188
|
-
datePart: string;
|
|
189
|
-
timePart: string;
|
|
190
|
-
availabilityProductOptionId: string;
|
|
191
|
-
itineraryDisplay?: ItineraryDisplayStep[] | null;
|
|
192
|
-
clientSecret: string;
|
|
193
|
-
ticketLinesForModal: CheckoutModalLineItem[];
|
|
194
|
-
feeLineItems: OrderSummary['feeLineItems'];
|
|
195
|
-
returnPriceAdjustment: number;
|
|
196
|
-
cancellationPolicyFee: number;
|
|
197
|
-
cancellationPolicyLabel?: string;
|
|
198
|
-
subtotal: number;
|
|
199
|
-
tax: number;
|
|
200
|
-
totalQuantity: number;
|
|
201
|
-
isTaxIncludedInPrice: boolean;
|
|
202
|
-
taxRate: number;
|
|
203
|
-
promoDiscountAmount: number;
|
|
204
|
-
discountLabel?: string | null;
|
|
205
|
-
} | null>(null);
|
|
206
|
-
|
|
207
|
-
// Get all active product options (memoized to prevent recreating array on each render)
|
|
208
|
-
const activeOptions = useMemo(() =>
|
|
209
|
-
product.options?.filter(opt => opt.status === 'ACTIVE') || [],
|
|
210
|
-
[product.options]
|
|
211
|
-
);
|
|
212
|
-
|
|
213
|
-
// Detect if this is a Private Shuttle product
|
|
214
|
-
const isPrivateShuttle = product.productType === 'PRIVATE_SHUTTLE';
|
|
215
|
-
|
|
216
|
-
// Create stable string key from option IDs for dependency array
|
|
217
|
-
const activeOptionIdsKey = useMemo(() =>
|
|
218
|
-
activeOptions.map(opt => opt.optionId).sort().join(','),
|
|
219
|
-
[activeOptions]
|
|
220
|
-
);
|
|
221
|
-
|
|
222
|
-
// Create a Map for O(1) option lookups by optionId (performance optimization)
|
|
223
|
-
const optionsMap = useMemo(() => {
|
|
224
|
-
const map = new Map<string, typeof activeOptions[0]>();
|
|
225
|
-
activeOptions.forEach(opt => map.set(opt.optionId, opt));
|
|
226
|
-
return map;
|
|
227
|
-
}, [activeOptions]);
|
|
228
|
-
|
|
229
|
-
// Helper function to check if we need to fetch a date range
|
|
230
|
-
const needsFetch = (start: Date, end: Date): boolean => {
|
|
231
|
-
if (fetchedRangesRef.current.length === 0) return true;
|
|
232
|
-
|
|
233
|
-
// Check if the requested range is fully covered by fetched ranges
|
|
234
|
-
// For simplicity, check if any single fetched range fully covers the requested range
|
|
235
|
-
return !fetchedRangesRef.current.some(range => {
|
|
236
|
-
const rangeStart = range.start.getTime();
|
|
237
|
-
const rangeEnd = range.end.getTime();
|
|
238
|
-
const reqStart = start.getTime();
|
|
239
|
-
const reqEnd = end.getTime();
|
|
240
|
-
|
|
241
|
-
// Check if this fetched range fully covers the requested range
|
|
242
|
-
return rangeStart <= reqStart && rangeEnd >= reqEnd;
|
|
243
|
-
});
|
|
244
|
-
};
|
|
245
|
-
|
|
246
|
-
// Initialize visible range only once on mount (or from initialBooking in change mode)
|
|
247
|
-
useEffect(() => {
|
|
248
|
-
if (!visibleRange) {
|
|
249
|
-
if (initialBooking?.dateTime) {
|
|
250
|
-
const bookingDate = parseISO(initialBooking.dateTime);
|
|
251
|
-
const start = startOfDay(bookingDate);
|
|
252
|
-
const end = endOfDay(addWeeks(bookingDate, 2));
|
|
253
|
-
setVisibleRange({ start, end });
|
|
254
|
-
} else {
|
|
255
|
-
const initialEnd = addWeeks(EARLIEST_AVAILABILITY_DATE, INITIAL_FETCH_WEEKS);
|
|
256
|
-
setVisibleRange({ start: EARLIEST_AVAILABILITY_DATE, end: initialEnd });
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
}, [visibleRange, initialBooking?.dateTime]);
|
|
260
|
-
|
|
261
|
-
// Fetch availabilities for visible range + buffer
|
|
262
|
-
useEffect(() => {
|
|
263
|
-
if (activeOptions.length === 0) {
|
|
264
|
-
setError('No active product options available');
|
|
265
|
-
setLoadingAvailabilities(false);
|
|
266
|
-
return;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
if (!visibleRange) {
|
|
270
|
-
// Wait for initial range to be set
|
|
271
|
-
return;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
async function fetchAvailabilities() {
|
|
275
|
-
// Prevent concurrent fetches
|
|
276
|
-
if (fetchingRef.current) {
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// Clamp to available date range
|
|
281
|
-
// For end date, we need to check if it's after the end of Oct 12 (inclusive means up to end of day Oct 12)
|
|
282
|
-
if (!visibleRange) return;
|
|
283
|
-
|
|
284
|
-
const clampedStart = isBefore(visibleRange.start, EARLIEST_AVAILABILITY_DATE)
|
|
285
|
-
? EARLIEST_AVAILABILITY_DATE
|
|
286
|
-
: visibleRange.start;
|
|
287
|
-
const endOfLatestDay = endOfDay(LATEST_AVAILABILITY_DATE);
|
|
288
|
-
let clampedEnd = isAfter(visibleRange.end, endOfLatestDay)
|
|
289
|
-
? endOfLatestDay
|
|
290
|
-
: visibleRange.end;
|
|
291
|
-
|
|
292
|
-
// Ensure we include the selected date if it's after the visible range end
|
|
293
|
-
// This handles the case where user selects a date that's not yet in the visible range
|
|
294
|
-
if (selectedDate) {
|
|
295
|
-
try {
|
|
296
|
-
const selectedDateObj = parseISO(selectedDate);
|
|
297
|
-
if (isAfter(selectedDateObj, clampedEnd)) {
|
|
298
|
-
clampedEnd = selectedDateObj;
|
|
299
|
-
}
|
|
300
|
-
} catch {
|
|
301
|
-
// Ignore parse errors
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// Check if we need to fetch this range
|
|
306
|
-
// Backend always returns CAD prices without fee/tax, so no need to refetch on currency change
|
|
307
|
-
const shouldFetch = needsFetch(clampedStart, clampedEnd);
|
|
308
|
-
if (!shouldFetch) {
|
|
309
|
-
// Range already fetched - ensure loading state is cleared
|
|
310
|
-
setLoadingAvailabilities(false);
|
|
311
|
-
fetchingRef.current = false;
|
|
312
|
-
return;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
fetchingRef.current = true;
|
|
316
|
-
setLoadingAvailabilities(true);
|
|
317
|
-
|
|
318
|
-
try {
|
|
319
|
-
let startDateStr: string;
|
|
320
|
-
let endDateStr: string;
|
|
321
|
-
|
|
322
|
-
if (isPrivateShuttle) {
|
|
323
|
-
// Private Shuttle: use date-only format (YYYY-MM-DD)
|
|
324
|
-
startDateStr = format(startOfDay(clampedStart), 'yyyy-MM-dd');
|
|
325
|
-
// Use endOfDay to include the full last day
|
|
326
|
-
endDateStr = format(endOfDay(clampedEnd), 'yyyy-MM-dd');
|
|
327
|
-
} else {
|
|
328
|
-
// Standard products: use ISO datetime format
|
|
329
|
-
// Note: Availabilities are stored in UTC, but we format in company timezone
|
|
330
|
-
// endOfDay ensures we get the full last day (23:59:59 in company timezone)
|
|
331
|
-
startDateStr = formatInTimeZone(startOfDay(clampedStart), companyTimezone, "yyyy-MM-dd'T'HH:mm:ssXXX");
|
|
332
|
-
endDateStr = formatInTimeZone(endOfDay(clampedEnd), companyTimezone, "yyyy-MM-dd'T'HH:mm:ssXXX");
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// Fetch availabilities for all product options in parallel (no currency param - we use precomputedPrices for display)
|
|
336
|
-
const availabilityPromises = activeOptions.map(async (option) => {
|
|
337
|
-
const result = await getAvailabilities(option.optionId, startDateStr, endDateStr, {
|
|
338
|
-
promoCode: appliedPromoCode || undefined,
|
|
339
|
-
});
|
|
340
|
-
// Store pricing config from first response only (all responses have same config)
|
|
341
|
-
if (result.pricingConfig && !pricingConfigSetRef.current) {
|
|
342
|
-
setPricingConfig(prev => {
|
|
343
|
-
if (!prev) {
|
|
344
|
-
pricingConfigSetRef.current = true;
|
|
345
|
-
return result.pricingConfig!;
|
|
346
|
-
}
|
|
347
|
-
return prev;
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
|
-
// Tag each availability with its productOptionId and carry precomputedPrices for this option
|
|
351
|
-
return {
|
|
352
|
-
optionId: option.optionId,
|
|
353
|
-
availabilities: result.availabilities.map(avail => ({
|
|
354
|
-
...avail,
|
|
355
|
-
productOptionId: option.optionId
|
|
356
|
-
})),
|
|
357
|
-
precomputedPrices: result.precomputedPrices,
|
|
358
|
-
};
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
const results = await Promise.all(availabilityPromises);
|
|
362
|
-
const allFetchedAvailabilities = results.flatMap(r => r.availabilities);
|
|
363
|
-
setPrecomputedPricesByOption(prev => {
|
|
364
|
-
const next = { ...(prev || {}) };
|
|
365
|
-
results.forEach(r => {
|
|
366
|
-
if (r.precomputedPrices && Object.keys(r.precomputedPrices).length > 0) {
|
|
367
|
-
next[r.optionId] = r.precomputedPrices;
|
|
368
|
-
}
|
|
369
|
-
});
|
|
370
|
-
return Object.keys(next).length > 0 ? next : prev;
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
// Merge with existing availabilities (avoid duplicates by dateTime + productOptionId)
|
|
374
|
-
setAvailabilities(prev => {
|
|
375
|
-
const existingMap = new Map(
|
|
376
|
-
prev.map(avail => [`${avail.dateTime}-${avail.productOptionId}`, avail])
|
|
377
|
-
);
|
|
378
|
-
|
|
379
|
-
// Merge new availabilities - update existing ones or add new ones
|
|
380
|
-
allFetchedAvailabilities.forEach(avail => {
|
|
381
|
-
const key = `${avail.dateTime}-${avail.productOptionId}`;
|
|
382
|
-
// Always update to get latest data (vacancies, prices, etc.)
|
|
383
|
-
existingMap.set(key, avail);
|
|
384
|
-
});
|
|
385
|
-
|
|
386
|
-
return Array.from(existingMap.values());
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
// Mark this range as fetched
|
|
390
|
-
fetchedRangesRef.current.push({ start: new Date(clampedStart), end: new Date(clampedEnd) });
|
|
391
|
-
// Sort and merge overlapping ranges
|
|
392
|
-
fetchedRangesRef.current.sort((a, b) => a.start.getTime() - b.start.getTime());
|
|
393
|
-
const merged: Array<{ start: Date; end: Date }> = [];
|
|
394
|
-
for (const r of fetchedRangesRef.current) {
|
|
395
|
-
if (merged.length === 0 || merged[merged.length - 1].end < r.start) {
|
|
396
|
-
merged.push({ start: r.start, end: r.end });
|
|
397
|
-
} else {
|
|
398
|
-
merged[merged.length - 1].end = r.end > merged[merged.length - 1].end
|
|
399
|
-
? r.end
|
|
400
|
-
: merged[merged.length - 1].end;
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
fetchedRangesRef.current = merged;
|
|
404
|
-
|
|
405
|
-
// Initialize quantities based on first availability's categories (only if not already set)
|
|
406
|
-
// Use functional update to avoid dependency on quantities state
|
|
407
|
-
setQuantities(prev => {
|
|
408
|
-
if (Object.keys(prev).length > 0) return prev; // Already initialized
|
|
409
|
-
|
|
410
|
-
if (allFetchedAvailabilities.length > 0) {
|
|
411
|
-
const firstAvail = allFetchedAvailabilities.find(avail => avail.rates);
|
|
412
|
-
if (firstAvail?.rates) {
|
|
413
|
-
const initialQuantities: Record<string, number> = {};
|
|
414
|
-
firstAvail.rates.forEach(rate => {
|
|
415
|
-
initialQuantities[rate.category] = rate.category === 'ADULT' ? 1 : 0;
|
|
416
|
-
});
|
|
417
|
-
return initialQuantities;
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
return prev;
|
|
421
|
-
});
|
|
422
|
-
} catch (err) {
|
|
423
|
-
setError(err instanceof Error ? err.message : 'Failed to load availabilities');
|
|
424
|
-
console.error('Error fetching availabilities:', err);
|
|
425
|
-
} finally {
|
|
426
|
-
setLoadingAvailabilities(false);
|
|
427
|
-
fetchingRef.current = false;
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
fetchAvailabilities();
|
|
432
|
-
}, [visibleRange, activeOptionIdsKey, isPrivateShuttle, activeOptions, companyTimezone, selectedDate, appliedPromoCode, refreshKey]);
|
|
433
|
-
|
|
434
|
-
// Change mode: pre-fill selectedDate and selectedAvailability when availabilities load
|
|
435
|
-
const initialBookingPrefilledRef = useRef(false);
|
|
436
|
-
useEffect(() => {
|
|
437
|
-
if (!initialBooking?.dateTime || availabilities.length === 0 || initialBookingPrefilledRef.current) return;
|
|
438
|
-
let match: Availability | undefined;
|
|
439
|
-
if (initialBooking.availabilityId) {
|
|
440
|
-
match = availabilities.find((a) => a.availabilityId === initialBooking.availabilityId);
|
|
441
|
-
}
|
|
442
|
-
if (!match) {
|
|
443
|
-
const targetDt = parseISO(initialBooking.dateTime);
|
|
444
|
-
const targetDateStr = formatInTimeZone(targetDt, companyTimezone, 'yyyy-MM-dd');
|
|
445
|
-
const targetTimeStr = formatInTimeZone(targetDt, companyTimezone, 'HH:mm');
|
|
446
|
-
match = availabilities.find((a) => {
|
|
447
|
-
if (!a.dateTime) return false;
|
|
448
|
-
const aDateStr = formatInTimeZone(parseISO(a.dateTime), companyTimezone, 'yyyy-MM-dd');
|
|
449
|
-
if (aDateStr !== targetDateStr) return false;
|
|
450
|
-
const aTimeStr = formatInTimeZone(parseISO(a.dateTime), companyTimezone, 'HH:mm');
|
|
451
|
-
return targetTimeStr === aTimeStr;
|
|
452
|
-
}) ?? availabilities.find((a) => formatInTimeZone(parseISO(a.dateTime), companyTimezone, 'yyyy-MM-dd') === targetDateStr);
|
|
453
|
-
}
|
|
454
|
-
if (match) {
|
|
455
|
-
initialBookingPrefilledRef.current = true;
|
|
456
|
-
const dateStr = formatInTimeZone(parseISO(match.dateTime), companyTimezone, 'yyyy-MM-dd');
|
|
457
|
-
setSelectedDate(dateStr);
|
|
458
|
-
setSelectedAvailability(match);
|
|
459
|
-
if (initialBooking.returnAvailabilityId && match.returnOptions) {
|
|
460
|
-
const returnOpt = match.returnOptions.find((r) => r.returnAvailabilityId === initialBooking.returnAvailabilityId);
|
|
461
|
-
if (returnOpt) setSelectedReturnOption(returnOpt);
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
}, [initialBooking, availabilities, companyTimezone]);
|
|
465
|
-
|
|
466
|
-
// When applied promo code changes, clear fetched ranges so we refetch with new pricing
|
|
467
|
-
useEffect(() => {
|
|
468
|
-
fetchedRangesRef.current = [];
|
|
469
|
-
}, [appliedPromoCode]);
|
|
470
|
-
|
|
471
|
-
// Refetch availabilities when user returns to this tab (e.g. after reconciling capacity on dashboard)
|
|
472
|
-
useEffect(() => {
|
|
473
|
-
const handleVisibilityChange = () => {
|
|
474
|
-
if (document.visibilityState === 'visible') {
|
|
475
|
-
fetchedRangesRef.current = [];
|
|
476
|
-
setRefreshKey((k) => k + 1);
|
|
477
|
-
}
|
|
478
|
-
};
|
|
479
|
-
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
480
|
-
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
481
|
-
}, []);
|
|
482
|
-
|
|
483
|
-
// Memoized callback for visible range changes
|
|
484
|
-
// Only update if the range actually changed to avoid unnecessary fetches
|
|
485
|
-
const lastVisibleRangeRef = useRef<{ start: Date; end: Date } | null>(null);
|
|
486
|
-
const handleVisibleRangeChange = useCallback((start: Date, end: Date) => {
|
|
487
|
-
const lastRange = lastVisibleRangeRef.current;
|
|
488
|
-
// Update if this is the first range or if it changed significantly (more than a day)
|
|
489
|
-
const rangeChanged = !lastRange ||
|
|
490
|
-
Math.abs(lastRange.start.getTime() - start.getTime()) > 24 * 60 * 60 * 1000 ||
|
|
491
|
-
Math.abs(lastRange.end.getTime() - end.getTime()) > 24 * 60 * 60 * 1000;
|
|
492
|
-
|
|
493
|
-
if (rangeChanged) {
|
|
494
|
-
lastVisibleRangeRef.current = { start, end };
|
|
495
|
-
// Always update state to trigger fetch, even if needsFetch might return false
|
|
496
|
-
// The needsFetch check will prevent unnecessary API calls, but we want the state update
|
|
497
|
-
setVisibleRange({ start, end });
|
|
498
|
-
}
|
|
499
|
-
}, []);
|
|
500
|
-
|
|
501
|
-
// Group availabilities by date (in company timezone) and sort by time
|
|
502
|
-
// Memoized to prevent recalculation on every render
|
|
503
|
-
const availabilitiesByDate = useMemo(() => {
|
|
504
|
-
const grouped = availabilities.reduce((acc, avail) => {
|
|
505
|
-
// Parse the dateTime and extract the date in company timezone
|
|
506
|
-
const dateTime = parseISO(avail.dateTime);
|
|
507
|
-
const dateInCompanyTz = formatInTimeZone(dateTime, companyTimezone, 'yyyy-MM-dd');
|
|
508
|
-
if (!acc[dateInCompanyTz]) acc[dateInCompanyTz] = [];
|
|
509
|
-
acc[dateInCompanyTz].push(avail);
|
|
510
|
-
return acc;
|
|
511
|
-
}, {} as Record<string, Availability[]>);
|
|
512
|
-
|
|
513
|
-
// Sort availabilities within each date by time (create new sorted arrays, don't mutate)
|
|
514
|
-
const sorted: Record<string, Availability[]> = {};
|
|
515
|
-
Object.keys(grouped).forEach(date => {
|
|
516
|
-
sorted[date] = [...grouped[date]].sort((a, b) => {
|
|
517
|
-
const timeA = parseISO(a.dateTime).getTime();
|
|
518
|
-
const timeB = parseISO(b.dateTime).getTime();
|
|
519
|
-
return timeA - timeB;
|
|
520
|
-
});
|
|
521
|
-
});
|
|
522
|
-
|
|
523
|
-
return sorted;
|
|
524
|
-
}, [availabilities, companyTimezone]);
|
|
525
|
-
|
|
526
|
-
const dates = useMemo(() => Object.keys(availabilitiesByDate).sort(), [availabilitiesByDate]);
|
|
527
|
-
|
|
528
|
-
// Track mobile state
|
|
529
|
-
useEffect(() => {
|
|
530
|
-
const checkMobile = () => {
|
|
531
|
-
setIsMobile(window.innerWidth < 640); // sm breakpoint
|
|
532
|
-
};
|
|
533
|
-
checkMobile();
|
|
534
|
-
window.addEventListener('resize', checkMobile);
|
|
535
|
-
return () => window.removeEventListener('resize', checkMobile);
|
|
536
|
-
}, []);
|
|
537
|
-
|
|
538
|
-
// Close tooltip when clicking outside
|
|
539
|
-
useEffect(() => {
|
|
540
|
-
if (!showTooltip) return;
|
|
541
|
-
|
|
542
|
-
const handleClickOutside = (e: MouseEvent | TouchEvent) => {
|
|
543
|
-
const target = e.target as HTMLElement;
|
|
544
|
-
if (!target.closest('[data-tooltip-icon]')) {
|
|
545
|
-
setShowTooltip(false);
|
|
546
|
-
}
|
|
547
|
-
};
|
|
548
|
-
|
|
549
|
-
document.addEventListener('mousedown', handleClickOutside);
|
|
550
|
-
document.addEventListener('touchstart', handleClickOutside);
|
|
551
|
-
|
|
552
|
-
return () => {
|
|
553
|
-
document.removeEventListener('mousedown', handleClickOutside);
|
|
554
|
-
document.removeEventListener('touchstart', handleClickOutside);
|
|
555
|
-
};
|
|
556
|
-
}, [showTooltip]);
|
|
557
|
-
|
|
558
|
-
// Detect when itinerary box becomes sticky (throttled to avoid jank)
|
|
559
|
-
useEffect(() => {
|
|
560
|
-
let ticking = false;
|
|
561
|
-
|
|
562
|
-
const updateStickyState = () => {
|
|
563
|
-
if (!itineraryRef.current) return;
|
|
564
|
-
|
|
565
|
-
const rect = itineraryRef.current.getBoundingClientRect();
|
|
566
|
-
const stickyTop = window.innerWidth >= 640 ? 73 : 136; // sm breakpoint
|
|
567
|
-
|
|
568
|
-
const currentTop = rect.top;
|
|
569
|
-
const wasSticky = isItineraryStickyRef.current;
|
|
570
|
-
|
|
571
|
-
// Simple threshold: sticky when the top of the box reaches the sticky offset
|
|
572
|
-
const nextSticky = currentTop <= stickyTop + 1; // +1px tolerance for sticky comparison
|
|
573
|
-
|
|
574
|
-
if (nextSticky !== wasSticky) {
|
|
575
|
-
isItineraryStickyRef.current = nextSticky;
|
|
576
|
-
setIsItinerarySticky(nextSticky);
|
|
577
|
-
}
|
|
578
|
-
};
|
|
579
|
-
|
|
580
|
-
const handleScroll = () => {
|
|
581
|
-
if (!ticking) {
|
|
582
|
-
window.requestAnimationFrame(() => {
|
|
583
|
-
updateStickyState();
|
|
584
|
-
ticking = false;
|
|
585
|
-
});
|
|
586
|
-
ticking = true;
|
|
587
|
-
}
|
|
588
|
-
};
|
|
589
|
-
|
|
590
|
-
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
591
|
-
updateStickyState(); // Check initial state
|
|
592
|
-
return () => window.removeEventListener('scroll', handleScroll);
|
|
593
|
-
}, [selectedDate, selectedAvailability]); // Re-check when itinerary content changes
|
|
594
|
-
|
|
595
|
-
// Find the earliest availability date - memoize with a stable reference
|
|
596
|
-
// Only recalculate if we don't have a cached value or if the new earliest is actually earlier
|
|
597
|
-
// IMPORTANT: Never return null once we have a value, to prevent calendar reset during loading
|
|
598
|
-
const earliestAvailabilityDateRef = useRef<Date | null>(null);
|
|
599
|
-
const earliestAvailabilityDate = useMemo(() => {
|
|
600
|
-
if (dates.length === 0) {
|
|
601
|
-
// If we have a cached value, keep using it even during loading
|
|
602
|
-
return earliestAvailabilityDateRef.current || EARLIEST_AVAILABILITY_DATE;
|
|
603
|
-
}
|
|
604
|
-
const firstDate = dates[0];
|
|
605
|
-
const firstAvail = availabilitiesByDate[firstDate]?.[0];
|
|
606
|
-
let newEarliest: Date;
|
|
607
|
-
if (firstAvail) {
|
|
608
|
-
newEarliest = parseISO(firstAvail.dateTime);
|
|
609
|
-
} else {
|
|
610
|
-
// Fallback: parse the date string
|
|
611
|
-
const [year, month, day] = firstDate.split('-').map(Number);
|
|
612
|
-
newEarliest = new Date(year, month - 1, day);
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
// Only update if we don't have a cached value or if the new one is earlier
|
|
616
|
-
if (!earliestAvailabilityDateRef.current || newEarliest < earliestAvailabilityDateRef.current) {
|
|
617
|
-
earliestAvailabilityDateRef.current = newEarliest;
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
return earliestAvailabilityDateRef.current;
|
|
621
|
-
}, [dates, availabilitiesByDate]);
|
|
622
|
-
|
|
623
|
-
const timesForSelectedDate = useMemo(() => {
|
|
624
|
-
return selectedDate ? availabilitiesByDate[selectedDate] || [] : [];
|
|
625
|
-
}, [selectedDate, availabilitiesByDate]);
|
|
626
|
-
|
|
627
|
-
// Get selected pickup location
|
|
628
|
-
const selectedPickupLocation = useMemo(() =>
|
|
629
|
-
product.pickupLocations?.find(loc => loc.id === pickupLocationId),
|
|
630
|
-
[product.pickupLocations, pickupLocationId]
|
|
631
|
-
);
|
|
632
|
-
|
|
633
|
-
// Calculate maximum time offset from all pickup locations (for range display when location is unknown)
|
|
634
|
-
const maxTimeOffsetMinutes = useMemo(() => {
|
|
635
|
-
if (!product.pickupLocations || product.pickupLocations.length === 0) {
|
|
636
|
-
return 0;
|
|
637
|
-
}
|
|
638
|
-
return Math.max(...product.pickupLocations.map(loc => loc.pickupTimeOffsetMinutes ?? 0));
|
|
639
|
-
}, [product.pickupLocations]);
|
|
640
|
-
|
|
641
|
-
// Calculate pickup times based on availability times + pickup location offset
|
|
642
|
-
interface PickupTimeInfo extends Availability {
|
|
643
|
-
pickupTime: string;
|
|
644
|
-
displayTime: string;
|
|
645
|
-
originalTime: string;
|
|
646
|
-
displayTimeRange?: string; // Time range when pickup location is unknown
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
const pickupTimes = useMemo((): PickupTimeInfo[] => {
|
|
650
|
-
if (!selectedDate || !timesForSelectedDate.length) {
|
|
651
|
-
return [];
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
// Show base availability times (without pickup location offset) when pickup location not selected yet
|
|
655
|
-
// Once pickup location is selected, we can show adjusted times
|
|
656
|
-
const offsetMinutes = pickupLocationSkipped
|
|
657
|
-
? 0
|
|
658
|
-
: (selectedPickupLocation?.pickupTimeOffsetMinutes ?? 0);
|
|
659
|
-
|
|
660
|
-
return timesForSelectedDate.map(avail => {
|
|
661
|
-
// Parse the dateTime (which should already be in company timezone from backend)
|
|
662
|
-
const availabilityTime = parseISO(avail.dateTime);
|
|
663
|
-
|
|
664
|
-
// Only apply offset if it's set and > 0 and location is selected
|
|
665
|
-
const pickupTime = (offsetMinutes > 0 && selectedPickupLocation)
|
|
666
|
-
? new Date(availabilityTime.getTime() + offsetMinutes * 60 * 1000)
|
|
667
|
-
: availabilityTime;
|
|
668
|
-
|
|
669
|
-
// Format in company timezone (not user's local timezone)
|
|
670
|
-
const displayTime = formatInTimeZone(pickupTime, companyTimezone, 'h:mm a');
|
|
671
|
-
const originalTime = formatInTimeZone(availabilityTime, companyTimezone, 'h:mm a');
|
|
672
|
-
|
|
673
|
-
// If pickup location is skipped, calculate and display time range
|
|
674
|
-
let displayTimeRange: string | undefined;
|
|
675
|
-
if (pickupLocationSkipped && maxTimeOffsetMinutes > 0) {
|
|
676
|
-
const latestPickupTime = new Date(availabilityTime.getTime() + maxTimeOffsetMinutes * 60 * 1000);
|
|
677
|
-
const latestTimeStr = formatInTimeZone(latestPickupTime, companyTimezone, 'h:mm a');
|
|
678
|
-
displayTimeRange = `${originalTime} - ${latestTimeStr}`;
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
return {
|
|
682
|
-
...avail,
|
|
683
|
-
pickupTime: pickupTime.toISOString(),
|
|
684
|
-
displayTime,
|
|
685
|
-
originalTime,
|
|
686
|
-
displayTimeRange,
|
|
687
|
-
};
|
|
688
|
-
});
|
|
689
|
-
}, [selectedDate, selectedPickupLocation, timesForSelectedDate, pickupLocationSkipped, maxTimeOffsetMinutes, companyTimezone]);
|
|
690
|
-
|
|
691
|
-
// Check if any pickup time has "most popular" tag (memoized for performance)
|
|
692
|
-
const hasAnyMostPopular = useMemo(() => {
|
|
693
|
-
if (pickupTimes.length <= 1) return false;
|
|
694
|
-
return pickupTimes.some(t => {
|
|
695
|
-
if (!t.productOptionId) return false;
|
|
696
|
-
const opt = optionsMap.get(t.productOptionId);
|
|
697
|
-
return opt?.mostPopular;
|
|
698
|
-
});
|
|
699
|
-
}, [pickupTimes, optionsMap]);
|
|
700
|
-
|
|
701
|
-
// Helper function to get effective itinerary based on selected date and overrides
|
|
702
|
-
const getEffectiveItinerary = useCallback((option: typeof activeOptions[0], date: Date): typeof option.itinerary => {
|
|
703
|
-
if (!option.itinerary) return undefined;
|
|
704
|
-
|
|
705
|
-
const monthDay = formatInTimeZone(date, companyTimezone, 'MM-dd');
|
|
706
|
-
|
|
707
|
-
// Check for date-specific override
|
|
708
|
-
if (option.itineraryOverrides && option.itineraryOverrides.length > 0) {
|
|
709
|
-
for (const override of option.itineraryOverrides) {
|
|
710
|
-
if (monthDay >= override.startDate && monthDay <= override.endDate) {
|
|
711
|
-
return override.itinerary;
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
return option.itinerary;
|
|
717
|
-
}, [companyTimezone]);
|
|
718
|
-
|
|
719
|
-
// Helper function to calculate stay summary for a specific return option
|
|
720
|
-
const calculateStaySummary = useCallback((returnDateTime: Date): string | null => {
|
|
721
|
-
if (!selectedAvailability) return null;
|
|
722
|
-
|
|
723
|
-
const availabilityProductOptionId = selectedAvailability.productOptionId || activeOptions[0]?.optionId;
|
|
724
|
-
const selectedOption = activeOptions.find(opt => opt.optionId === availabilityProductOptionId);
|
|
725
|
-
if (!selectedOption) return null;
|
|
726
|
-
|
|
727
|
-
const tourStartTime = parseISO(selectedAvailability.dateTime);
|
|
728
|
-
const itinerary = getEffectiveItinerary(selectedOption, tourStartTime);
|
|
729
|
-
const hasItinerary = itinerary && itinerary.length > 0 && product.destinations && product.destinations.length > 0;
|
|
730
|
-
|
|
731
|
-
if (!hasItinerary || !product.destinations || !itinerary) return null;
|
|
732
|
-
|
|
733
|
-
const destinationMap = new Map(product.destinations.map(d => [d.name, d]));
|
|
734
|
-
let lastDepartureTime = tourStartTime;
|
|
735
|
-
|
|
736
|
-
const stays: Array<{ destinationName: string; durationHours: number }> = [];
|
|
737
|
-
|
|
738
|
-
itinerary.forEach((itineraryItem, index) => {
|
|
739
|
-
const destination = destinationMap.get(itineraryItem.destinationName);
|
|
740
|
-
if (!destination) return;
|
|
741
|
-
|
|
742
|
-
const arrivalTime = new Date(
|
|
743
|
-
lastDepartureTime.getTime() + (itineraryItem.travelTimeFromPreviousHours * 60 * 60 * 1000)
|
|
744
|
-
);
|
|
745
|
-
|
|
746
|
-
const isLastDestination = index === itinerary.length - 1;
|
|
747
|
-
|
|
748
|
-
if (isLastDestination) {
|
|
749
|
-
// For the last destination, calculate time from arrival to return time
|
|
750
|
-
const timeDiffMs = returnDateTime.getTime() - arrivalTime.getTime();
|
|
751
|
-
const timeDiffHours = timeDiffMs / (1000 * 60 * 60);
|
|
752
|
-
|
|
753
|
-
if (timeDiffHours > 0) {
|
|
754
|
-
stays.push({
|
|
755
|
-
destinationName: destination.name,
|
|
756
|
-
durationHours: timeDiffHours,
|
|
757
|
-
});
|
|
758
|
-
}
|
|
759
|
-
} else {
|
|
760
|
-
// For non-last destinations (including first), use durationHours only if there's more than one stop
|
|
761
|
-
if (itinerary.length > 1 && itineraryItem.durationHours && itineraryItem.durationHours > 0) {
|
|
762
|
-
stays.push({
|
|
763
|
-
destinationName: destination.name,
|
|
764
|
-
durationHours: itineraryItem.durationHours,
|
|
765
|
-
});
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
const departureTime = itineraryItem.durationHours && !isLastDestination
|
|
770
|
-
? new Date(arrivalTime.getTime() + (itineraryItem.durationHours * 60 * 60 * 1000))
|
|
771
|
-
: arrivalTime;
|
|
772
|
-
|
|
773
|
-
lastDepartureTime = departureTime;
|
|
774
|
-
});
|
|
775
|
-
|
|
776
|
-
if (stays.length === 0) return null;
|
|
777
|
-
|
|
778
|
-
// Build summary string e.g. "2 hours at [destination] + 2 hours at [destination]"
|
|
779
|
-
return stays.map(stay => {
|
|
780
|
-
const hours = Math.floor(stay.durationHours);
|
|
781
|
-
const minutes = Math.round((stay.durationHours - hours) * 60);
|
|
782
|
-
let timeStr = '';
|
|
783
|
-
if (hours === 0 && minutes === 0) {
|
|
784
|
-
timeStr = '0 min';
|
|
785
|
-
} else if (hours === 0) {
|
|
786
|
-
timeStr = `${minutes} min`;
|
|
787
|
-
} else if (minutes === 0) {
|
|
788
|
-
timeStr = `${hours} ${hours === 1 ? 'hour' : 'hours'}`;
|
|
789
|
-
} else {
|
|
790
|
-
timeStr = `${hours} ${hours === 1 ? 'hour' : 'hours'} ${minutes} min`;
|
|
791
|
-
}
|
|
792
|
-
return `${timeStr} at ${stay.destinationName}`;
|
|
793
|
-
}).join(' + ');
|
|
794
|
-
}, [selectedAvailability, activeOptions, product.destinations, getEffectiveItinerary]);
|
|
795
|
-
|
|
796
|
-
// Helper function to compute itinerary display for storage (returns same shape as "Your Itinerary" box)
|
|
797
|
-
const computeItineraryDisplay = useCallback((): ItineraryDisplayStep[] | null => {
|
|
798
|
-
if (!selectedAvailability) return null;
|
|
799
|
-
const availabilityProductOptionId = selectedAvailability.productOptionId || activeOptions[0]?.optionId;
|
|
800
|
-
const selectedOption = activeOptions.find(opt => opt.optionId === availabilityProductOptionId);
|
|
801
|
-
if (!selectedOption) return null;
|
|
802
|
-
const tourStartTime = parseISO(selectedAvailability.dateTime);
|
|
803
|
-
const itinerary = getEffectiveItinerary(selectedOption, tourStartTime);
|
|
804
|
-
const hasItinerary = itinerary && itinerary.length > 0 && product.destinations && product.destinations.length > 0;
|
|
805
|
-
if (!hasItinerary || !product.destinations || !itinerary) return null;
|
|
806
|
-
|
|
807
|
-
const itineraryItems: ItineraryDisplayStep[] = [];
|
|
808
|
-
const destinationMap = new Map(product.destinations.map(d => [d.name, d]));
|
|
809
|
-
const pickupOffsetMinutes = pickupLocationSkipped ? 0 : (selectedPickupLocation?.pickupTimeOffsetMinutes ?? 0);
|
|
810
|
-
const actualPickupTime = pickupOffsetMinutes > 0
|
|
811
|
-
? new Date(tourStartTime.getTime() + pickupOffsetMinutes * 60 * 1000)
|
|
812
|
-
: tourStartTime;
|
|
813
|
-
let pickupTimeDisplay: string;
|
|
814
|
-
if (pickupLocationSkipped && maxTimeOffsetMinutes > 0) {
|
|
815
|
-
const latestPickupTime = new Date(tourStartTime.getTime() + maxTimeOffsetMinutes * 60 * 1000);
|
|
816
|
-
pickupTimeDisplay = `${formatInTimeZone(tourStartTime, companyTimezone, 'h:mm a')} - ${formatInTimeZone(latestPickupTime, companyTimezone, 'h:mm a')}`;
|
|
817
|
-
} else {
|
|
818
|
-
pickupTimeDisplay = formatInTimeZone(actualPickupTime, companyTimezone, 'h:mm a');
|
|
819
|
-
}
|
|
820
|
-
const pickupPlace = pickupLocationSkipped || !selectedPickupLocation ? 'your_pickup_location' : selectedPickupLocation.name;
|
|
821
|
-
itineraryItems.push({ stepType: StepType.pickup, time: pickupTimeDisplay, place: pickupPlace });
|
|
822
|
-
let lastDepartureTime = tourStartTime;
|
|
823
|
-
itinerary.forEach((itineraryItem, index) => {
|
|
824
|
-
const destination = destinationMap.get(itineraryItem.destinationName);
|
|
825
|
-
if (!destination) return;
|
|
826
|
-
const arrivalTime = new Date(lastDepartureTime.getTime() + (itineraryItem.travelTimeFromPreviousHours * 60 * 60 * 1000));
|
|
827
|
-
itineraryItems.push({
|
|
828
|
-
stepType: StepType.arrive,
|
|
829
|
-
time: formatInTimeZone(arrivalTime, companyTimezone, 'h:mm a'),
|
|
830
|
-
place: destination.name
|
|
831
|
-
});
|
|
832
|
-
const departureTime = itineraryItem.durationHours
|
|
833
|
-
? new Date(arrivalTime.getTime() + (itineraryItem.durationHours * 60 * 60 * 1000))
|
|
834
|
-
: arrivalTime;
|
|
835
|
-
const hasMoreDestinations = index < itinerary.length - 1;
|
|
836
|
-
if (itineraryItem.durationHours && hasMoreDestinations) {
|
|
837
|
-
itineraryItems.push({
|
|
838
|
-
stepType: StepType.depart,
|
|
839
|
-
time: formatInTimeZone(departureTime, companyTimezone, 'h:mm a'),
|
|
840
|
-
place: destination.name
|
|
841
|
-
});
|
|
842
|
-
}
|
|
843
|
-
lastDepartureTime = departureTime;
|
|
844
|
-
});
|
|
845
|
-
// Return time: from selected return option (products with return selection) OR from itinerary's last departure (e.g. Emerald Lake shuttle with fixed multi-stop itinerary)
|
|
846
|
-
const returnDateTime = selectedReturnOption
|
|
847
|
-
? parseISO(selectedReturnOption.dateTime)
|
|
848
|
-
: lastDepartureTime;
|
|
849
|
-
const lastDestination = itinerary.length > 0 ? destinationMap.get(itinerary[itinerary.length - 1].destinationName) : null;
|
|
850
|
-
const lastDestName = itinerary.length > 0 && product.destinations
|
|
851
|
-
? (destinationMap.get(itinerary[itinerary.length - 1].destinationName)?.name ?? null)
|
|
852
|
-
: null;
|
|
853
|
-
const getDropOffMinutes = (loc: { travelMinutesFromDestination?: Record<string, number> }) => {
|
|
854
|
-
if (lastDestName && loc.travelMinutesFromDestination?.[lastDestName] != null) return loc.travelMinutesFromDestination[lastDestName];
|
|
855
|
-
return 0;
|
|
856
|
-
};
|
|
857
|
-
const hasDropOffEstimate = (loc: { travelMinutesFromDestination?: Record<string, number> }) =>
|
|
858
|
-
Boolean(lastDestName && loc.travelMinutesFromDestination?.[lastDestName] != null);
|
|
859
|
-
const dropOffPlace = pickupLocationSkipped || !selectedPickupLocation ? 'your_pickup_location' : selectedPickupLocation!.name;
|
|
860
|
-
|
|
861
|
-
if (selectedReturnOption) {
|
|
862
|
-
itineraryItems.push({
|
|
863
|
-
stepType: StepType.depart,
|
|
864
|
-
time: formatInTimeZone(returnDateTime, companyTimezone, 'h:mm a'),
|
|
865
|
-
place: lastDestination?.name ?? 'the_destination'
|
|
866
|
-
});
|
|
867
|
-
} else if (lastDestination) {
|
|
868
|
-
// No return options: show depart from last stop (e.g. Vermillion Lakes for Emerald Lake shuttle)
|
|
869
|
-
itineraryItems.push({
|
|
870
|
-
stepType: StepType.depart,
|
|
871
|
-
time: formatInTimeZone(returnDateTime, companyTimezone, 'h:mm a'),
|
|
872
|
-
place: lastDestination.name
|
|
873
|
-
});
|
|
874
|
-
}
|
|
875
|
-
// Add drop-off step (works for both return-option products and itinerary-only products like Emerald Lake)
|
|
876
|
-
if (selectedPickupLocation && hasDropOffEstimate(selectedPickupLocation)) {
|
|
877
|
-
const dropOffOffsetMinutes = getDropOffMinutes(selectedPickupLocation);
|
|
878
|
-
const dropOffTime = new Date(returnDateTime.getTime() + dropOffOffsetMinutes * 60 * 1000);
|
|
879
|
-
itineraryItems.push({
|
|
880
|
-
stepType: StepType.drop_off,
|
|
881
|
-
time: formatInTimeZone(dropOffTime, companyTimezone, 'h:mm a'),
|
|
882
|
-
place: dropOffPlace
|
|
883
|
-
});
|
|
884
|
-
} else if (pickupLocationSkipped || !selectedPickupLocation) {
|
|
885
|
-
itineraryItems.push({ stepType: StepType.drop_off, time: 'TBD', place: dropOffPlace });
|
|
886
|
-
} else if (product.pickupLocations?.length) {
|
|
887
|
-
const dropOffOffsets = product.pickupLocations.map(getDropOffMinutes);
|
|
888
|
-
const [minOffset, maxOffset] = [Math.min(...dropOffOffsets), Math.max(...dropOffOffsets)];
|
|
889
|
-
const earliestDropOff = new Date(returnDateTime.getTime() + minOffset * 60 * 1000);
|
|
890
|
-
const latestDropOff = new Date(returnDateTime.getTime() + maxOffset * 60 * 1000);
|
|
891
|
-
itineraryItems.push({
|
|
892
|
-
stepType: StepType.drop_off,
|
|
893
|
-
time: minOffset === maxOffset
|
|
894
|
-
? formatInTimeZone(earliestDropOff, companyTimezone, 'h:mm a')
|
|
895
|
-
: `${formatInTimeZone(earliestDropOff, companyTimezone, 'h:mm a')} - ${formatInTimeZone(latestDropOff, companyTimezone, 'h:mm a')}`,
|
|
896
|
-
place: dropOffPlace
|
|
897
|
-
});
|
|
898
|
-
} else {
|
|
899
|
-
itineraryItems.push({ stepType: StepType.drop_off, time: 'TBD', place: dropOffPlace });
|
|
900
|
-
}
|
|
901
|
-
return itineraryItems;
|
|
902
|
-
}, [selectedAvailability, selectedReturnOption, selectedPickupLocation, pickupLocationSkipped, activeOptions, product, getEffectiveItinerary, companyTimezone, maxTimeOffsetMinutes]);
|
|
903
|
-
|
|
904
|
-
/**
|
|
905
|
-
* Itinerary for storage only (API/DB). When pickup is unknown, pickup step time is always a range
|
|
906
|
-
* (e.g. "9 AM - 10:00 AM") so /manage and confirmation email show it; drop-off stays TBD.
|
|
907
|
-
* UI during booking is unchanged (uses computeItineraryDisplay).
|
|
908
|
-
*/
|
|
909
|
-
const computeItineraryDisplayForStorage = useCallback((): ItineraryDisplayStep[] | null => {
|
|
910
|
-
const base = computeItineraryDisplay();
|
|
911
|
-
if (!base || base.length === 0) return base;
|
|
912
|
-
const pickupUnknown = pickupLocationSkipped || (!selectedPickupLocation && product.pickupLocations && product.pickupLocations.length > 0);
|
|
913
|
-
if (!pickupUnknown) return base;
|
|
914
|
-
const tourStartTime = selectedAvailability ? parseISO(selectedAvailability.dateTime) : null;
|
|
915
|
-
if (!tourStartTime) return base;
|
|
916
|
-
const rangeMinutes = maxTimeOffsetMinutes > 0 ? maxTimeOffsetMinutes : 60;
|
|
917
|
-
const latestPickupTime = new Date(tourStartTime.getTime() + rangeMinutes * 60 * 1000);
|
|
918
|
-
const startStr = formatInTimeZone(tourStartTime, companyTimezone, 'h:mm a');
|
|
919
|
-
const endStr = formatInTimeZone(latestPickupTime, companyTimezone, 'h:mm a');
|
|
920
|
-
const pickupRangeTime = startStr === endStr ? startStr : `${startStr} - ${endStr}`;
|
|
921
|
-
return base.map((step, i) =>
|
|
922
|
-
i === 0 && step.stepType === StepType.pickup && step.place === 'your_pickup_location'
|
|
923
|
-
? { ...step, time: pickupRangeTime }
|
|
924
|
-
: step
|
|
925
|
-
);
|
|
926
|
-
}, [computeItineraryDisplay, pickupLocationSkipped, selectedPickupLocation, product.pickupLocations, selectedAvailability, companyTimezone, maxTimeOffsetMinutes]);
|
|
927
|
-
|
|
928
|
-
// Product has fees from config (e.g. product-fees.json); API sends these in pricingConfig.fees
|
|
929
|
-
const hasFees = useMemo(() =>
|
|
930
|
-
Boolean(pricingConfig?.fees && Object.keys(pricingConfig.fees).length > 0 && Object.values(pricingConfig.fees).some(v => (v?.feePerPerson ?? 0) > 0)),
|
|
931
|
-
[pricingConfig?.fees]
|
|
932
|
-
);
|
|
933
|
-
|
|
934
|
-
// Ticket prices: use breakdown final price so booking flow total matches the price breakdown. All conversion in mid-layer.
|
|
935
|
-
const pricing = useMemo(() => {
|
|
936
|
-
if (!selectedAvailability || !pricingConfig) return [];
|
|
937
|
-
const optionId = selectedAvailability.productOptionId;
|
|
938
|
-
const selectedOption = activeOptions.find(opt => opt.optionId === optionId);
|
|
939
|
-
const precomputed = optionId ? precomputedPricesByOption?.[optionId] : undefined;
|
|
940
|
-
const rateToDisplayPrice = (backendInDisplayCurrency: number) =>
|
|
941
|
-
getDisplayPriceFromBaseInDisplayCurrency(backendInDisplayCurrency, currency, pricingConfig, hasFees);
|
|
942
|
-
const getBaseInDisplayCurrency = (category: string) => {
|
|
943
|
-
const fromPrecomputed = precomputed?.[category]?.[currency];
|
|
944
|
-
if (fromPrecomputed != null) return fromPrecomputed;
|
|
945
|
-
return 0;
|
|
946
|
-
};
|
|
947
|
-
const buildRate = (
|
|
948
|
-
category: string,
|
|
949
|
-
backendPriceCAD: number,
|
|
950
|
-
backendInDisplayCurrency: number,
|
|
951
|
-
baseInDisplayCurrency: number,
|
|
952
|
-
appliedAdjustments: Array<{ type: string; id: string; name: string; changeByCurrency?: Record<string, number> }>
|
|
953
|
-
) => {
|
|
954
|
-
const basePriceCAD = selectedOption?.pricing?.[category.toUpperCase()] ?? 0;
|
|
955
|
-
const isPublicMode = isSimplifiedPricingView;
|
|
956
|
-
const breakdown = computePriceBreakdown(
|
|
957
|
-
pricingConfig,
|
|
958
|
-
currency,
|
|
959
|
-
backendPriceCAD,
|
|
960
|
-
basePriceCAD,
|
|
961
|
-
hasFees,
|
|
962
|
-
appliedAdjustments,
|
|
963
|
-
undefined,
|
|
964
|
-
baseInDisplayCurrency,
|
|
965
|
-
isPublicMode
|
|
966
|
-
);
|
|
967
|
-
const price = breakdown?.finalPrice ?? rateToDisplayPrice(backendInDisplayCurrency);
|
|
968
|
-
return { category, baseInDisplayCurrency, appliedAdjustments, price, priceCAD: backendPriceCAD };
|
|
969
|
-
};
|
|
970
|
-
return selectedAvailability.rates?.map(rate => {
|
|
971
|
-
const backendPriceCAD = rate.price ?? 0;
|
|
972
|
-
const backendInDisplayCurrency = rate.priceByCurrency?.[currency] ?? (currency === 'CAD' ? backendPriceCAD : 0);
|
|
973
|
-
const baseInDisplayCurrency = getBaseInDisplayCurrency(rate.category);
|
|
974
|
-
const built = buildRate(rate.category, backendPriceCAD, backendInDisplayCurrency, baseInDisplayCurrency, rate.appliedAdjustments ?? rate.applied_adjustments ?? []);
|
|
975
|
-
return {
|
|
976
|
-
category: rate.category,
|
|
977
|
-
rateId: rate.rateId || rate.category,
|
|
978
|
-
available: rate.available,
|
|
979
|
-
price: built.price,
|
|
980
|
-
priceCAD: built.priceCAD,
|
|
981
|
-
baseInDisplayCurrency: built.baseInDisplayCurrency,
|
|
982
|
-
appliedAdjustments: built.appliedAdjustments,
|
|
983
|
-
};
|
|
984
|
-
}) || selectedAvailability.pricesByCategory?.retailPrices?.map(p => {
|
|
985
|
-
const priceCADFromApi = p.price / 100;
|
|
986
|
-
const baseInDisplayCurrency = getBaseInDisplayCurrency(p.category);
|
|
987
|
-
const backendInDisplayCurrency = currency === 'CAD' ? priceCADFromApi : 0;
|
|
988
|
-
const built = buildRate(p.category, priceCADFromApi, backendInDisplayCurrency, baseInDisplayCurrency, []);
|
|
989
|
-
return {
|
|
990
|
-
category: p.category,
|
|
991
|
-
rateId: p.category,
|
|
992
|
-
available: selectedAvailability.vacancies,
|
|
993
|
-
price: built.price,
|
|
994
|
-
priceCAD: built.priceCAD,
|
|
995
|
-
baseInDisplayCurrency: built.baseInDisplayCurrency,
|
|
996
|
-
appliedAdjustments: built.appliedAdjustments,
|
|
997
|
-
};
|
|
998
|
-
}) || [];
|
|
999
|
-
}, [selectedAvailability, currency, hasFees, pricingConfig, precomputedPricesByOption, activeOptions, isSimplifiedPricingView]);
|
|
1000
|
-
|
|
1001
|
-
// Price breakdown: mid-layer returns line items (base + one per rule/deal). UI renders each line; rate in brackets when used.
|
|
1002
|
-
const getPriceBreakdown = useCallback((category: string, priceCAD: number, baseInDisplayCurrency: number | undefined, appliedAdjustments: Array<{ type: string; id: string; name: string; changeByCurrency?: Record<string, number> }> = []): PriceBreakdownData | null => {
|
|
1003
|
-
if (!pricingConfig) return null;
|
|
1004
|
-
const selectedOption = activeOptions.find(opt => opt.optionId === selectedAvailability?.productOptionId);
|
|
1005
|
-
const basePriceCAD = selectedOption?.pricing?.[category.toUpperCase()] ?? 0;
|
|
1006
|
-
const isPublicMode = isSimplifiedPricingView;
|
|
1007
|
-
return computePriceBreakdown(
|
|
1008
|
-
pricingConfig,
|
|
1009
|
-
currency,
|
|
1010
|
-
priceCAD,
|
|
1011
|
-
basePriceCAD,
|
|
1012
|
-
hasFees,
|
|
1013
|
-
appliedAdjustments,
|
|
1014
|
-
undefined,
|
|
1015
|
-
baseInDisplayCurrency,
|
|
1016
|
-
isPublicMode
|
|
1017
|
-
);
|
|
1018
|
-
}, [pricingConfig, currency, hasFees, activeOptions, selectedAvailability, isSimplifiedPricingView]);
|
|
1019
|
-
|
|
1020
|
-
// Order summary from mid-layer; UI only displays these values (no calculations).
|
|
1021
|
-
const orderSummary: OrderSummary = useMemo(
|
|
1022
|
-
() =>
|
|
1023
|
-
computeOrderSummary(
|
|
1024
|
-
quantities,
|
|
1025
|
-
pricing,
|
|
1026
|
-
selectedReturnOption,
|
|
1027
|
-
pricingConfig ?? null,
|
|
1028
|
-
currency,
|
|
1029
|
-
hasFees,
|
|
1030
|
-
cancellationPolicyId
|
|
1031
|
-
),
|
|
1032
|
-
[quantities, pricing, selectedReturnOption, pricingConfig, currency, hasFees, cancellationPolicyId]
|
|
1033
|
-
);
|
|
1034
|
-
const { totalQuantity, subtotal, tax, total: totalFromSummary, feeLineItems, returnPriceAdjustment, cancellationPolicyFee, isTaxIncludedInPrice, ticketLineItems } = orderSummary;
|
|
1035
|
-
const selectedCancellationPolicy = pricingConfig?.cancellationPolicies?.find((p) => p.id === cancellationPolicyId);
|
|
1036
|
-
|
|
1037
|
-
// Add-on totals (lunch, animals, etc.)
|
|
1038
|
-
const addOnTotal = useMemo(() => {
|
|
1039
|
-
let sum = 0;
|
|
1040
|
-
for (const sel of addOnSelections) {
|
|
1041
|
-
const addOn = addOns.find((a) => a.addOnId === sel.addOnId);
|
|
1042
|
-
if (!addOn) continue;
|
|
1043
|
-
const basePrice = addOn.price ?? 0;
|
|
1044
|
-
const hasVariant = (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') && sel.variantId;
|
|
1045
|
-
const variantAdjustment = hasVariant
|
|
1046
|
-
? (addOn.variants?.find((v) => v.id === sel.variantId)?.priceAdjustment ?? 0)
|
|
1047
|
-
: 0;
|
|
1048
|
-
sum += (basePrice + variantAdjustment) * (sel.quantity ?? 1);
|
|
1049
|
-
}
|
|
1050
|
-
return sum;
|
|
1051
|
-
}, [addOnSelections, addOns]);
|
|
1052
|
-
|
|
1053
|
-
// Effective subtotal includes add-ons (for promo discount and total)
|
|
1054
|
-
const effectiveSubtotal = subtotal + addOnTotal;
|
|
1055
|
-
|
|
1056
|
-
// Fee line items including add-ons (for PriceSummary and CheckoutModal)
|
|
1057
|
-
const feeLineItemsWithAddOns = useMemo(() => {
|
|
1058
|
-
const addOnLines = addOnSelections
|
|
1059
|
-
.map((sel) => {
|
|
1060
|
-
const addOn = addOns.find((a) => a.addOnId === sel.addOnId);
|
|
1061
|
-
if (!addOn) return null;
|
|
1062
|
-
const base = addOn.price ?? 0;
|
|
1063
|
-
const hasVariant = (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') && sel.variantId;
|
|
1064
|
-
const adj = hasVariant ? (addOn.variants?.find((v) => v.id === sel.variantId)?.priceAdjustment ?? 0) : 0;
|
|
1065
|
-
const qty = sel.quantity ?? 1;
|
|
1066
|
-
const amt = (base + adj) * qty;
|
|
1067
|
-
const variantLabel = hasVariant ? addOn.variants?.find((v) => v.id === sel.variantId)?.label : null;
|
|
1068
|
-
const name = variantLabel ? `${addOn.name} (${variantLabel})${qty > 1 ? ` × ${qty}` : ''}` : addOn.name;
|
|
1069
|
-
return { name, totalAmount: amt, description: addOn.description ?? undefined };
|
|
1070
|
-
})
|
|
1071
|
-
.filter((x): x is NonNullable<typeof x> => x != null);
|
|
1072
|
-
return [...feeLineItems, ...addOnLines];
|
|
1073
|
-
}, [feeLineItems, addOnSelections, addOns]);
|
|
1074
|
-
|
|
1075
|
-
// Promo discount from backend (order-level only; rates are pre-promo)
|
|
1076
|
-
const [promoDiscountAmount, setPromoDiscountAmount] = useState(0);
|
|
1077
|
-
const [isGiftCard, setIsGiftCard] = useState(false);
|
|
1078
|
-
const [isVoucher, setIsVoucher] = useState(false);
|
|
1079
|
-
useEffect(() => {
|
|
1080
|
-
if (!appliedPromoCode || !selectedAvailability || totalQuantity === 0) {
|
|
1081
|
-
setPromoDiscountAmount(0);
|
|
1082
|
-
setIsGiftCard(false);
|
|
1083
|
-
setIsVoucher(false);
|
|
1084
|
-
return;
|
|
1085
|
-
}
|
|
1086
|
-
const companyId = product.companyId;
|
|
1087
|
-
if (!companyId) {
|
|
1088
|
-
setPromoDiscountAmount(0);
|
|
1089
|
-
return;
|
|
1090
|
-
}
|
|
1091
|
-
const optionId = selectedAvailability.productOptionId;
|
|
1092
|
-
if (!optionId) {
|
|
1093
|
-
setPromoDiscountAmount(0);
|
|
1094
|
-
return;
|
|
1095
|
-
}
|
|
1096
|
-
const items = ticketLineItems.map((l) => ({ category: l.category, qty: l.qty }));
|
|
1097
|
-
if (items.length === 0) {
|
|
1098
|
-
setPromoDiscountAmount(0);
|
|
1099
|
-
return;
|
|
1100
|
-
}
|
|
1101
|
-
let cancelled = false;
|
|
1102
|
-
getPromoDiscount(
|
|
1103
|
-
appliedPromoCode,
|
|
1104
|
-
companyId,
|
|
1105
|
-
product.productId,
|
|
1106
|
-
optionId,
|
|
1107
|
-
currency,
|
|
1108
|
-
items,
|
|
1109
|
-
selectedAvailability.dateTime,
|
|
1110
|
-
effectiveSubtotal
|
|
1111
|
-
)
|
|
1112
|
-
.then((res) => {
|
|
1113
|
-
if (!cancelled) {
|
|
1114
|
-
setPromoDiscountAmount(res.discount ?? 0);
|
|
1115
|
-
setIsGiftCard(res.isGiftCard ?? false);
|
|
1116
|
-
setIsVoucher(res.isVoucher ?? false);
|
|
1117
|
-
}
|
|
1118
|
-
})
|
|
1119
|
-
.catch(() => {
|
|
1120
|
-
if (!cancelled) {
|
|
1121
|
-
setPromoDiscountAmount(0);
|
|
1122
|
-
setIsGiftCard(false);
|
|
1123
|
-
setIsVoucher(false);
|
|
1124
|
-
}
|
|
1125
|
-
});
|
|
1126
|
-
return () => { cancelled = true; };
|
|
1127
|
-
}, [appliedPromoCode, selectedAvailability, ticketLineItems, totalQuantity, product.companyId, product.productId, currency, effectiveSubtotal]);
|
|
1128
|
-
|
|
1129
|
-
// Percentage/fixed promos: tax on discounted amount (promo before GST per CRA guidance).
|
|
1130
|
-
// Vouchers and gift cards: tax on full subtotal (voucher discount includes tax on free portion; gift card is payment).
|
|
1131
|
-
const taxOnSubtotal = isTaxIncludedInPrice ? 0 : effectiveSubtotal * (pricingConfig?.taxRate ?? 0);
|
|
1132
|
-
const effectiveTax =
|
|
1133
|
-
promoDiscountAmount > 0 && !isGiftCard && !isVoucher
|
|
1134
|
-
? (effectiveSubtotal - promoDiscountAmount) * (pricingConfig?.taxRate ?? 0)
|
|
1135
|
-
: taxOnSubtotal;
|
|
1136
|
-
const totalPrice = effectiveSubtotal + effectiveTax - promoDiscountAmount;
|
|
1137
|
-
|
|
1138
|
-
// Auto-select product option when date is selected: most popular if set, otherwise first available
|
|
1139
|
-
useEffect(() => {
|
|
1140
|
-
if (selectedDate && timesForSelectedDate.length > 0 && !selectedAvailability) {
|
|
1141
|
-
const mostPopularOption = activeOptions.find(opt => opt.mostPopular);
|
|
1142
|
-
const candidate = mostPopularOption
|
|
1143
|
-
? timesForSelectedDate.find(avail => avail.productOptionId === mostPopularOption.optionId && avail.vacancies > 0)
|
|
1144
|
-
: null;
|
|
1145
|
-
const fallback = timesForSelectedDate.find(avail => avail.vacancies > 0);
|
|
1146
|
-
const toSelect = candidate ?? fallback;
|
|
1147
|
-
if (toSelect) {
|
|
1148
|
-
setSelectedAvailability(toSelect);
|
|
1149
|
-
setError('');
|
|
1150
|
-
}
|
|
1151
|
-
}
|
|
1152
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1153
|
-
}, [selectedDate, timesForSelectedDate.length, activeOptionIdsKey, selectedAvailability]);
|
|
1154
|
-
|
|
1155
|
-
// Currency change does NOT trigger a refetch. Backend returns per-currency data (priceByCurrency,
|
|
1156
|
-
// changeByCurrency, feesByCurrency, precomputedPrices, etc.) in one response; we just
|
|
1157
|
-
// re-render with the new currency and pick the right values.
|
|
1158
|
-
|
|
1159
|
-
// Sync selectedAvailability when the availabilities list changes (e.g. after refetch for new date range)
|
|
1160
|
-
useEffect(() => {
|
|
1161
|
-
if (selectedAvailability && availabilities.length > 0) {
|
|
1162
|
-
const updatedAvailability = availabilities.find(
|
|
1163
|
-
avail =>
|
|
1164
|
-
avail.dateTime === selectedAvailability.dateTime &&
|
|
1165
|
-
avail.productOptionId === selectedAvailability.productOptionId
|
|
1166
|
-
);
|
|
1167
|
-
if (updatedAvailability) {
|
|
1168
|
-
setSelectedAvailability(updatedAvailability);
|
|
1169
|
-
|
|
1170
|
-
// Also update selectedReturnOption if it exists
|
|
1171
|
-
if (selectedReturnOption && updatedAvailability.returnOptions) {
|
|
1172
|
-
const updatedReturnOption = updatedAvailability.returnOptions.find(
|
|
1173
|
-
opt => opt.returnAvailabilityId === selectedReturnOption.returnAvailabilityId
|
|
1174
|
-
);
|
|
1175
|
-
if (updatedReturnOption) {
|
|
1176
|
-
setSelectedReturnOption(updatedReturnOption);
|
|
1177
|
-
}
|
|
1178
|
-
}
|
|
1179
|
-
}
|
|
1180
|
-
}
|
|
1181
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1182
|
-
}, [availabilities]); // Update when availabilities change (selectedAvailability/selectedReturnOption intentionally excluded to avoid loops)
|
|
1183
|
-
|
|
1184
|
-
// Auto-select first return option (by time) when pickup time is selected
|
|
1185
|
-
useEffect(() => {
|
|
1186
|
-
if (selectedAvailability?.returnOptions && selectedAvailability.returnOptions.length > 0 && !selectedReturnOption) {
|
|
1187
|
-
const sorted = [...selectedAvailability.returnOptions].sort(
|
|
1188
|
-
(a, b) => parseISO(a.dateTime).getTime() - parseISO(b.dateTime).getTime()
|
|
1189
|
-
);
|
|
1190
|
-
const firstAvailable = sorted.find(opt => opt.vacancies > 0);
|
|
1191
|
-
if (firstAvailable) {
|
|
1192
|
-
setSelectedReturnOption(firstAvailable);
|
|
1193
|
-
}
|
|
1194
|
-
}
|
|
1195
|
-
}, [selectedAvailability, selectedReturnOption]);
|
|
1196
|
-
|
|
1197
|
-
// Fetch add-ons when availability (product option) is selected; clear selections when option changes
|
|
1198
|
-
const availabilityProductOptionId = selectedAvailability?.productOptionId ?? null;
|
|
1199
|
-
const prevAvailabilityProductOptionIdRef = useRef<string | null>(null);
|
|
1200
|
-
useEffect(() => {
|
|
1201
|
-
if (!availabilityProductOptionId || !product.companyId) {
|
|
1202
|
-
setAddOns([]);
|
|
1203
|
-
setAddOnSelections([]);
|
|
1204
|
-
return;
|
|
1205
|
-
}
|
|
1206
|
-
const optionChanged = prevAvailabilityProductOptionIdRef.current !== availabilityProductOptionId;
|
|
1207
|
-
if (optionChanged) {
|
|
1208
|
-
setAddOnSelections([]);
|
|
1209
|
-
prevAvailabilityProductOptionIdRef.current = availabilityProductOptionId;
|
|
1210
|
-
}
|
|
1211
|
-
getAddOns(product.companyId, { productOptionId: availabilityProductOptionId, preCheckout: true })
|
|
1212
|
-
.then(setAddOns)
|
|
1213
|
-
.catch(() => setAddOns([]));
|
|
1214
|
-
}, [availabilityProductOptionId, product.companyId]);
|
|
1215
|
-
|
|
1216
|
-
// Auto-select first cancellation policy (e.g. "standard" with fee=0) when pricing config loads
|
|
1217
|
-
useEffect(() => {
|
|
1218
|
-
if (pricingConfig?.cancellationPolicies && pricingConfig.cancellationPolicies.length > 0 && !cancellationPolicyId) {
|
|
1219
|
-
// Select first policy by default (should be "standard" with fee 0)
|
|
1220
|
-
setCancellationPolicyId(pricingConfig.cancellationPolicies[0].id);
|
|
1221
|
-
}
|
|
1222
|
-
}, [pricingConfig?.cancellationPolicies, cancellationPolicyId]);
|
|
1223
|
-
|
|
1224
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
1225
|
-
const handleDateSelect = (_date: string) => {
|
|
1226
|
-
// Clear selected availability when date changes
|
|
1227
|
-
setSelectedAvailability(null);
|
|
1228
|
-
setSelectedReturnOption(null); // Clear return selection when changing date
|
|
1229
|
-
};
|
|
1230
|
-
|
|
1231
|
-
const handleTimeSelect = (availability: Availability) => {
|
|
1232
|
-
setSelectedAvailability(availability);
|
|
1233
|
-
setSelectedReturnOption(null); // Clear return selection when changing start time
|
|
1234
|
-
setError('');
|
|
1235
|
-
};
|
|
1236
|
-
|
|
1237
|
-
const handleQuantityChange = (category: string, delta: number) => {
|
|
1238
|
-
const maxAvailable = selectedAvailability?.vacancies || 0;
|
|
1239
|
-
const currentQty = quantities[category] || 0;
|
|
1240
|
-
const newQty = Math.max(0, currentQty + delta);
|
|
1241
|
-
// Admin can overbook; non-admin cannot exceed vacancies
|
|
1242
|
-
if (delta > 0 && !isAdmin && orderSummary.totalQuantity >= maxAvailable) {
|
|
1243
|
-
return;
|
|
1244
|
-
}
|
|
1245
|
-
setQuantities(prev => ({
|
|
1246
|
-
...prev,
|
|
1247
|
-
[category]: newQty,
|
|
1248
|
-
}));
|
|
1249
|
-
setError('');
|
|
1250
|
-
};
|
|
1251
|
-
|
|
1252
|
-
const lastValidatedInputRef = useRef<string | null>(null);
|
|
1253
|
-
|
|
1254
|
-
// Selected availability has a deal applied (promo codes not allowed with deals; vouchers/gift cards still allowed; dynamic pricing alone is ok)
|
|
1255
|
-
const hasOngoingDiscount = useMemo(
|
|
1256
|
-
() =>
|
|
1257
|
-
selectedAvailability?.rates?.some((r) =>
|
|
1258
|
-
(r.appliedAdjustments ?? r.applied_adjustments ?? []).some((a) => (a.type ?? '').toLowerCase() === 'deal')
|
|
1259
|
-
) ?? false,
|
|
1260
|
-
[selectedAvailability]
|
|
1261
|
-
);
|
|
1262
|
-
|
|
1263
|
-
const handleApplyPromo = useCallback(async () => {
|
|
1264
|
-
const code = promoCodeInput.trim().toUpperCase();
|
|
1265
|
-
if (!code) return;
|
|
1266
|
-
if (appliedPromoCode === code) return; // Already applied, skip API call
|
|
1267
|
-
const companyId = product.companyId;
|
|
1268
|
-
if (!companyId) return;
|
|
1269
|
-
lastValidatedInputRef.current = code;
|
|
1270
|
-
setPromoCodeError('');
|
|
1271
|
-
setPromoCodeValidating(true);
|
|
1272
|
-
try {
|
|
1273
|
-
const result = await validatePromoCode(code, companyId, product.productId, hasOngoingDiscount);
|
|
1274
|
-
if (result.valid) {
|
|
1275
|
-
setAppliedPromoCode(code);
|
|
1276
|
-
fetchedRangesRef.current = [];
|
|
1277
|
-
if (result.forcedCancellationPolicyId) {
|
|
1278
|
-
setCancellationPolicyId(result.forcedCancellationPolicyId);
|
|
1279
|
-
setForcedCancellationPolicyFromPromo(
|
|
1280
|
-
result.forcedCancellationPolicyLabel
|
|
1281
|
-
? { id: result.forcedCancellationPolicyId, label: result.forcedCancellationPolicyLabel }
|
|
1282
|
-
: { id: result.forcedCancellationPolicyId, label: result.forcedCancellationPolicyId }
|
|
1283
|
-
);
|
|
1284
|
-
} else {
|
|
1285
|
-
setForcedCancellationPolicyFromPromo(null);
|
|
1286
|
-
}
|
|
1287
|
-
} else {
|
|
1288
|
-
const errorMsg =
|
|
1289
|
-
result.error === 'Promo codes cannot be stacked with deals'
|
|
1290
|
-
? (t('booking.promoCodesCannotStackWithDiscounts') || result.error)
|
|
1291
|
-
: (result.error || t('booking.invalidPromoCode') || 'Invalid or expired promo code');
|
|
1292
|
-
setPromoCodeError(errorMsg);
|
|
1293
|
-
}
|
|
1294
|
-
} catch (err) {
|
|
1295
|
-
setPromoCodeError(err instanceof Error ? err.message : 'Failed to validate promo code');
|
|
1296
|
-
} finally {
|
|
1297
|
-
setPromoCodeValidating(false);
|
|
1298
|
-
}
|
|
1299
|
-
}, [promoCodeInput, appliedPromoCode, product.companyId, product.productId, hasOngoingDiscount, t]);
|
|
1300
|
-
|
|
1301
|
-
// When user selects a time with ongoing discount and has a promo applied, re-validate and clear if promo can't be stacked
|
|
1302
|
-
useEffect(() => {
|
|
1303
|
-
if (!appliedPromoCode || !hasOngoingDiscount) return;
|
|
1304
|
-
let cancelled = false;
|
|
1305
|
-
validatePromoCode(appliedPromoCode, product.companyId ?? '', product.productId, true).then((result) => {
|
|
1306
|
-
if (cancelled) return;
|
|
1307
|
-
if (!result.valid && result.error === 'Promo codes cannot be stacked with deals') {
|
|
1308
|
-
setAppliedPromoCode(null);
|
|
1309
|
-
setPromoCodeInput(appliedPromoCode);
|
|
1310
|
-
setPromoCodeError(t('booking.promoCodesCannotStackWithDiscounts') || result.error);
|
|
1311
|
-
setForcedCancellationPolicyFromPromo(null);
|
|
1312
|
-
fetchedRangesRef.current = [];
|
|
1313
|
-
}
|
|
1314
|
-
});
|
|
1315
|
-
return () => { cancelled = true; };
|
|
1316
|
-
}, [hasOngoingDiscount, appliedPromoCode, product.companyId, product.productId, t]);
|
|
1317
|
-
|
|
1318
|
-
// Ref to avoid effect re-running when handleApplyPromo identity changes (t changes every render)
|
|
1319
|
-
const handleApplyPromoRef = useRef(handleApplyPromo);
|
|
1320
|
-
handleApplyPromoRef.current = handleApplyPromo;
|
|
1321
|
-
|
|
1322
|
-
// Auto-apply promo when user stops typing (mobile-friendly, no Enter key needed)
|
|
1323
|
-
useEffect(() => {
|
|
1324
|
-
const trimmed = promoCodeInput.trim().toUpperCase();
|
|
1325
|
-
if (!trimmed) return;
|
|
1326
|
-
if (appliedPromoCode === trimmed) return;
|
|
1327
|
-
if (promoCodeValidating) return;
|
|
1328
|
-
// Don't re-validate the same invalid input (prevents infinite loop when user sits on "BL...")
|
|
1329
|
-
if (lastValidatedInputRef.current === trimmed) return;
|
|
1330
|
-
|
|
1331
|
-
const timer = setTimeout(() => {
|
|
1332
|
-
handleApplyPromoRef.current();
|
|
1333
|
-
}, 600);
|
|
1334
|
-
|
|
1335
|
-
return () => clearTimeout(timer);
|
|
1336
|
-
}, [promoCodeInput, appliedPromoCode, promoCodeValidating]);
|
|
1337
|
-
|
|
1338
|
-
const handleCheckout = async () => {
|
|
1339
|
-
if (!selectedAvailability || totalQuantity === 0) {
|
|
1340
|
-
setError(t('booking.selectTimeAndTickets'));
|
|
1341
|
-
return;
|
|
1342
|
-
}
|
|
1343
|
-
|
|
1344
|
-
// Validate email (required) - skip in change mode
|
|
1345
|
-
if (!isChangeMode) {
|
|
1346
|
-
if (!email) {
|
|
1347
|
-
setError(t('booking.enterEmail') || 'Please enter your email address');
|
|
1348
|
-
return;
|
|
1349
|
-
}
|
|
1350
|
-
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
1351
|
-
setError(t('booking.invalidEmail') || 'Please enter a valid email address');
|
|
1352
|
-
return;
|
|
1353
|
-
}
|
|
1354
|
-
if (!lastName?.trim()) {
|
|
1355
|
-
setError(t('booking.enterLastName') || 'Please enter your last name');
|
|
1356
|
-
return;
|
|
1357
|
-
}
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
// Allow checkout if pickup location is selected OR if user chose "I don't know"
|
|
1361
|
-
if (product.pickupLocations && product.pickupLocations.length > 0 && !pickupLocationId && !pickupLocationSkipped) {
|
|
1362
|
-
setError(t('booking.selectPickupLocation'));
|
|
1363
|
-
return;
|
|
1364
|
-
}
|
|
1365
|
-
|
|
1366
|
-
setLoading(true);
|
|
1367
|
-
setError('');
|
|
1368
|
-
|
|
1369
|
-
try {
|
|
1370
|
-
const bookingItems = Object.entries(quantities)
|
|
1371
|
-
.filter(([, count]) => count > 0)
|
|
1372
|
-
.map(([category, count]) => ({ category, count }));
|
|
1373
|
-
|
|
1374
|
-
// Get the productOptionId from the selected availability (we tagged it when fetching)
|
|
1375
|
-
const availabilityProductOptionId = selectedAvailability.productOptionId
|
|
1376
|
-
|| activeOptions[0]?.optionId;
|
|
1377
|
-
|
|
1378
|
-
if (!availabilityProductOptionId) {
|
|
1379
|
-
setError('No product option selected');
|
|
1380
|
-
setLoading(false);
|
|
1381
|
-
return;
|
|
1382
|
-
}
|
|
1383
|
-
|
|
1384
|
-
// Change mode: call onChangeBooking instead of createReservation
|
|
1385
|
-
if (isChangeMode && onChangeBooking) {
|
|
1386
|
-
const selectedPickupLocation = pickupLocationId
|
|
1387
|
-
? product.pickupLocations?.find(loc => loc.id === pickupLocationId)
|
|
1388
|
-
: null;
|
|
1389
|
-
await onChangeBooking({
|
|
1390
|
-
productId: availabilityProductOptionId,
|
|
1391
|
-
dateTime: selectedAvailability.dateTime,
|
|
1392
|
-
bookingItems,
|
|
1393
|
-
returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
|
|
1394
|
-
pickupLocationId: pickupLocationId ?? null,
|
|
1395
|
-
travelerHotel: selectedPickupLocation?.name ?? initialBooking?.travelerHotel ?? null,
|
|
1396
|
-
startTime: selectedAvailability.dateTime ?? null,
|
|
1397
|
-
passengerCount: isPrivateShuttle ? Object.values(quantities).reduce((a, b) => a + b, 0) : null,
|
|
1398
|
-
cancellationPolicyId: cancellationPolicyId ?? initialBooking?.cancellationPolicyId ?? null,
|
|
1399
|
-
addOnSelections: addOnSelections.length > 0 ? addOnSelections : null,
|
|
1400
|
-
promoCode: appliedPromoCode ?? null,
|
|
1401
|
-
newTotalAmount: keepOriginalPrice ? undefined : totalPrice,
|
|
1402
|
-
keepOriginalPrice: keepOriginalPrice || undefined,
|
|
1403
|
-
});
|
|
1404
|
-
setLoading(false);
|
|
1405
|
-
return;
|
|
1406
|
-
}
|
|
1407
|
-
|
|
1408
|
-
// Get the hotel name if a pickup location was selected
|
|
1409
|
-
const selectedPickupLocation = pickupLocationId
|
|
1410
|
-
? product.pickupLocations?.find(loc => loc.id === pickupLocationId)
|
|
1411
|
-
: null;
|
|
1412
|
-
|
|
1413
|
-
const reservation = await createReservation({
|
|
1414
|
-
productId: availabilityProductOptionId, // GetYourGuide passes productOptionId values in productId field
|
|
1415
|
-
dateTime: selectedAvailability.dateTime,
|
|
1416
|
-
bookingItems,
|
|
1417
|
-
pickupLocationId: pickupLocationId || undefined,
|
|
1418
|
-
returnAvailabilityId: selectedReturnOption?.returnAvailabilityId,
|
|
1419
|
-
currency: currency,
|
|
1420
|
-
promoCode: appliedPromoCode || undefined,
|
|
1421
|
-
cancellationPolicyId: cancellationPolicyId || undefined,
|
|
1422
|
-
addOnSelections: addOnSelections.length > 0 ? addOnSelections : undefined,
|
|
1423
|
-
// Pass hotel name when pickup location is selected (for reference)
|
|
1424
|
-
// Don't set travelerHotel when user selects "I don't know" - leave it undefined
|
|
1425
|
-
// This allows us to distinguish between "unknown" (null) and "unmapped hotel name" (not null)
|
|
1426
|
-
travelerHotel: selectedPickupLocation?.name || undefined,
|
|
1427
|
-
// For standard bookings, backend calculates startTime from availability + pickup location offset
|
|
1428
|
-
// When pickup location is skipped, backend will store null for startTime
|
|
1429
|
-
});
|
|
1430
|
-
|
|
1431
|
-
if (!reservation || !reservation.reservationReference) {
|
|
1432
|
-
throw new Error('Invalid reservation response: missing reservationReference');
|
|
1433
|
-
}
|
|
1434
|
-
|
|
1435
|
-
// Call onSuccess callback if provided (before checkout redirect)
|
|
1436
|
-
onSuccess?.({
|
|
1437
|
-
reservationReference: reservation.reservationReference,
|
|
1438
|
-
});
|
|
1439
|
-
|
|
1440
|
-
// Update stored booking data with reservation reference
|
|
1441
|
-
try {
|
|
1442
|
-
const storedBooking = sessionStorage.getItem('pendingBooking');
|
|
1443
|
-
if (storedBooking) {
|
|
1444
|
-
const booking = JSON.parse(storedBooking);
|
|
1445
|
-
booking.reservationReference = reservation.reservationReference;
|
|
1446
|
-
booking.totalPrice = reservation.totalAmount || totalPrice;
|
|
1447
|
-
booking.currency = reservation.currency || currency || selectedAvailability.currency || 'CAD';
|
|
1448
|
-
sessionStorage.setItem('pendingBooking', JSON.stringify(booking));
|
|
1449
|
-
}
|
|
1450
|
-
} catch (e) {
|
|
1451
|
-
console.warn('Failed to update booking data', e);
|
|
1452
|
-
}
|
|
1453
|
-
|
|
1454
|
-
// Create Payment Intent for embedded checkout modal (order summary with strikethrough + green, Payment Element)
|
|
1455
|
-
const datePart = selectedAvailability.dateTime.split('T')[0];
|
|
1456
|
-
const timePart = selectedAvailability.dateTime.split('T')[1]?.substring(0, 5) || '00:00';
|
|
1457
|
-
|
|
1458
|
-
// Itinerary for storage: when pickup unknown, pickup step is always a range (e.g. "9 AM - 10:00 AM") so /manage and email show it; drop-off stays TBD
|
|
1459
|
-
const itineraryDisplay = computeItineraryDisplayForStorage() ?? computeItineraryDisplay();
|
|
1460
|
-
|
|
1461
|
-
// Build checkout breakdown from the exact same values we show in the UI and Stripe modal.
|
|
1462
|
-
// Backend will charge totalAmount and store this as the receipt so /manage matches.
|
|
1463
|
-
const taxForBreakdown = promoDiscountAmount > 0 ? effectiveTax : tax;
|
|
1464
|
-
const lines = [
|
|
1465
|
-
...ticketLineItems.map((line) => ({
|
|
1466
|
-
label: line.category,
|
|
1467
|
-
amount: line.itemTotal,
|
|
1468
|
-
type: 'TICKET' as const,
|
|
1469
|
-
quantity: line.qty,
|
|
1470
|
-
})),
|
|
1471
|
-
...(returnPriceAdjustment !== 0
|
|
1472
|
-
? [
|
|
1473
|
-
{
|
|
1474
|
-
label: `${t('booking.returnOption') || 'Return option'} (${totalQuantity} ${totalQuantity === 1 ? (t('booking.person') || 'person') : (t('booking.people') || 'people')})`,
|
|
1475
|
-
amount: returnPriceAdjustment,
|
|
1476
|
-
type: 'RETURN_OPTION' as const,
|
|
1477
|
-
quantity: totalQuantity,
|
|
1478
|
-
},
|
|
1479
|
-
]
|
|
1480
|
-
: []),
|
|
1481
|
-
...(cancellationPolicyFee > 0
|
|
1482
|
-
? [
|
|
1483
|
-
{
|
|
1484
|
-
label: selectedCancellationPolicy?.label ?? (t('booking.flexibleCancellation') || 'Flexible cancellation'),
|
|
1485
|
-
amount: cancellationPolicyFee,
|
|
1486
|
-
type: 'CANCELLATION_UPGRADE' as const,
|
|
1487
|
-
},
|
|
1488
|
-
]
|
|
1489
|
-
: []),
|
|
1490
|
-
...addOnSelections
|
|
1491
|
-
.map((sel) => {
|
|
1492
|
-
const addOn = addOns.find((a) => a.addOnId === sel.addOnId);
|
|
1493
|
-
if (!addOn) return null;
|
|
1494
|
-
const base = addOn.price ?? 0;
|
|
1495
|
-
const hasVariant = (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') && sel.variantId;
|
|
1496
|
-
const adj = hasVariant ? (addOn.variants?.find((v) => v.id === sel.variantId)?.priceAdjustment ?? 0) : 0;
|
|
1497
|
-
const qty = sel.quantity ?? 1;
|
|
1498
|
-
const amt = (base + adj) * qty;
|
|
1499
|
-
const variantLabel = hasVariant ? addOn.variants?.find((v) => v.id === sel.variantId)?.label : null;
|
|
1500
|
-
return {
|
|
1501
|
-
label: variantLabel ? `${addOn.name} (${variantLabel})${qty > 1 ? ` × ${qty}` : ''}` : addOn.name,
|
|
1502
|
-
amount: amt,
|
|
1503
|
-
type: 'FEE' as const,
|
|
1504
|
-
quantity: qty,
|
|
1505
|
-
};
|
|
1506
|
-
})
|
|
1507
|
-
.filter((x): x is NonNullable<typeof x> => x != null),
|
|
1508
|
-
...feeLineItems.map((fee) => ({
|
|
1509
|
-
label: `${fee.name} (${totalQuantity} ${totalQuantity === 1 ? (t('booking.person') || 'person') : (t('booking.people') || 'people')})`,
|
|
1510
|
-
amount: fee.totalAmount,
|
|
1511
|
-
type: 'FEE' as const,
|
|
1512
|
-
quantity: totalQuantity,
|
|
1513
|
-
})),
|
|
1514
|
-
...(!isTaxIncludedInPrice && taxForBreakdown > 0
|
|
1515
|
-
? [
|
|
1516
|
-
{
|
|
1517
|
-
label: t('booking.tax') !== 'booking.tax' ? t('booking.tax') : 'Taxes and fees',
|
|
1518
|
-
amount: taxForBreakdown,
|
|
1519
|
-
type: 'TAX' as const,
|
|
1520
|
-
},
|
|
1521
|
-
]
|
|
1522
|
-
: []),
|
|
1523
|
-
...(promoDiscountAmount > 0
|
|
1524
|
-
? [
|
|
1525
|
-
{
|
|
1526
|
-
label: appliedPromoCode ? `Promo: ${appliedPromoCode}` : (t('booking.discount') || 'Discount'),
|
|
1527
|
-
amount: -promoDiscountAmount,
|
|
1528
|
-
type: isGiftCard ? 'GIFT_CARD' : 'PROMO_CODE',
|
|
1529
|
-
},
|
|
1530
|
-
]
|
|
1531
|
-
: []),
|
|
1532
|
-
];
|
|
1533
|
-
const checkoutBreakdown = buildCheckoutBreakdown({
|
|
1534
|
-
lines,
|
|
1535
|
-
totalAmount: totalPrice,
|
|
1536
|
-
currency,
|
|
1537
|
-
roundingLabel: t('booking.rounding') || 'Rounding',
|
|
1538
|
-
});
|
|
1539
|
-
|
|
1540
|
-
const paymentIntent = await createPaymentIntent({
|
|
1541
|
-
productId: product.productId,
|
|
1542
|
-
optionId: availabilityProductOptionId,
|
|
1543
|
-
date: datePart,
|
|
1544
|
-
time: timePart,
|
|
1545
|
-
quantity: totalQuantity,
|
|
1546
|
-
customerEmail: email,
|
|
1547
|
-
customerFirstName: firstName.trim() || undefined,
|
|
1548
|
-
customerLastName: lastName.trim() || undefined,
|
|
1549
|
-
currency: currency,
|
|
1550
|
-
reservationReference: reservation.reservationReference,
|
|
1551
|
-
travelerHotel: selectedPickupLocation?.name || undefined,
|
|
1552
|
-
pickupLocationId: pickupLocationId || undefined,
|
|
1553
|
-
itineraryDisplay: itineraryDisplay ?? undefined,
|
|
1554
|
-
returnAvailabilityId: selectedReturnOption?.returnAvailabilityId,
|
|
1555
|
-
promoCode: appliedPromoCode || undefined,
|
|
1556
|
-
cancellationPolicyId: cancellationPolicyId || undefined,
|
|
1557
|
-
termsAcceptedAt: termsAcceptedAt ?? undefined,
|
|
1558
|
-
checkoutBreakdown,
|
|
1559
|
-
skipConfirmationCommunications: isAdmin && skipConfirmationCommunications ? true : undefined,
|
|
1560
|
-
disableAutoCommunications: isAdmin && disableAutoCommunications ? true : undefined,
|
|
1561
|
-
});
|
|
1562
|
-
|
|
1563
|
-
// Free booking (e.g. voucher covers full total): confirm without payment, then redirect to success
|
|
1564
|
-
if (paymentIntent.freeBooking) {
|
|
1565
|
-
|
|
1566
|
-
const freeBookingResult = await confirmFreeBooking({
|
|
1567
|
-
reservationReference: reservation.reservationReference,
|
|
1568
|
-
productId: product.productId,
|
|
1569
|
-
optionId: availabilityProductOptionId,
|
|
1570
|
-
date: datePart,
|
|
1571
|
-
time: timePart,
|
|
1572
|
-
customerEmail: email || undefined,
|
|
1573
|
-
customerFirstName: firstName.trim() || undefined,
|
|
1574
|
-
customerLastName: lastName.trim() || undefined,
|
|
1575
|
-
currency: currency,
|
|
1576
|
-
travelerHotel: selectedPickupLocation?.name || undefined,
|
|
1577
|
-
pickupLocationId: pickupLocationId || undefined,
|
|
1578
|
-
itineraryDisplay: itineraryDisplay ?? undefined,
|
|
1579
|
-
termsAcceptedAt: termsAcceptedAt ?? undefined,
|
|
1580
|
-
skipConfirmationCommunications: isAdmin && skipConfirmationCommunications ? true : undefined,
|
|
1581
|
-
disableAutoCommunications: isAdmin && disableAutoCommunications ? true : undefined,
|
|
1582
|
-
});
|
|
1583
|
-
|
|
1584
|
-
// Show manage UI: in provider-dashboard use callback (e.g. dialog); otherwise redirect to /manage
|
|
1585
|
-
const ref = formatBookingRefForDisplay(freeBookingResult.bookingReference);
|
|
1586
|
-
const ln = lastName.trim();
|
|
1587
|
-
if (onShowManage) {
|
|
1588
|
-
onShowManage({ ref, lastName: ln });
|
|
1589
|
-
} else {
|
|
1590
|
-
const params = new URLSearchParams({ ref, lastName: ln });
|
|
1591
|
-
window.location.href = `/manage?${params.toString()}`;
|
|
1592
|
-
}
|
|
1593
|
-
setLoading(false);
|
|
1594
|
-
return;
|
|
1595
|
-
}
|
|
1596
|
-
|
|
1597
|
-
// Admin: show choice to pay now or confirm without payment (customer owes full balance)
|
|
1598
|
-
if (isAdmin) {
|
|
1599
|
-
setError('');
|
|
1600
|
-
setAdminChoiceData({
|
|
1601
|
-
reservationReference: reservation.reservationReference,
|
|
1602
|
-
checkoutBreakdown,
|
|
1603
|
-
totalAmount: totalPrice,
|
|
1604
|
-
datePart,
|
|
1605
|
-
timePart,
|
|
1606
|
-
availabilityProductOptionId,
|
|
1607
|
-
itineraryDisplay: itineraryDisplay ?? undefined,
|
|
1608
|
-
clientSecret: paymentIntent.clientSecret ?? '',
|
|
1609
|
-
ticketLinesForModal: ticketLineItems.map((line) => {
|
|
1610
|
-
const rate = pricing.find((r) => r.category === line.category);
|
|
1611
|
-
const breakdown = getPriceBreakdown(
|
|
1612
|
-
line.category,
|
|
1613
|
-
rate?.priceCAD ?? 0,
|
|
1614
|
-
rate?.baseInDisplayCurrency,
|
|
1615
|
-
rate?.appliedAdjustments ?? []
|
|
1616
|
-
);
|
|
1617
|
-
return { line, breakdown };
|
|
1618
|
-
}),
|
|
1619
|
-
feeLineItems: feeLineItemsWithAddOns,
|
|
1620
|
-
returnPriceAdjustment,
|
|
1621
|
-
cancellationPolicyFee,
|
|
1622
|
-
cancellationPolicyLabel: selectedCancellationPolicy?.label,
|
|
1623
|
-
subtotal: effectiveSubtotal,
|
|
1624
|
-
tax: promoDiscountAmount > 0 ? effectiveTax : tax,
|
|
1625
|
-
totalQuantity,
|
|
1626
|
-
isTaxIncludedInPrice,
|
|
1627
|
-
taxRate: pricingConfig?.taxRate ?? 0,
|
|
1628
|
-
promoDiscountAmount: promoDiscountAmount > 0 ? promoDiscountAmount : 0,
|
|
1629
|
-
discountLabel: appliedPromoCode ? `Promo: ${appliedPromoCode}` : undefined,
|
|
1630
|
-
});
|
|
1631
|
-
setShowAdminPaymentChoice(true);
|
|
1632
|
-
setLoading(false);
|
|
1633
|
-
return;
|
|
1634
|
-
}
|
|
1635
|
-
|
|
1636
|
-
const ticketLinesForModal: CheckoutModalLineItem[] = ticketLineItems.map((line) => {
|
|
1637
|
-
const rate = pricing.find((r) => r.category === line.category);
|
|
1638
|
-
const breakdown = getPriceBreakdown(
|
|
1639
|
-
line.category,
|
|
1640
|
-
rate?.priceCAD ?? 0,
|
|
1641
|
-
rate?.baseInDisplayCurrency,
|
|
1642
|
-
rate?.appliedAdjustments ?? []
|
|
1643
|
-
);
|
|
1644
|
-
return { line, breakdown };
|
|
1645
|
-
});
|
|
1646
|
-
|
|
1647
|
-
setCheckoutClientSecret(paymentIntent.clientSecret ?? '');
|
|
1648
|
-
setCheckoutModalData({
|
|
1649
|
-
reservationReference: reservation.reservationReference,
|
|
1650
|
-
customerLastName: lastName.trim(),
|
|
1651
|
-
ticketLines: ticketLinesForModal,
|
|
1652
|
-
feeLineItems: feeLineItemsWithAddOns,
|
|
1653
|
-
returnPriceAdjustment,
|
|
1654
|
-
cancellationPolicyFee,
|
|
1655
|
-
cancellationPolicyLabel: selectedCancellationPolicy?.label,
|
|
1656
|
-
subtotal: effectiveSubtotal,
|
|
1657
|
-
tax: promoDiscountAmount > 0 ? effectiveTax : tax,
|
|
1658
|
-
total: totalPrice,
|
|
1659
|
-
totalQuantity,
|
|
1660
|
-
isTaxIncludedInPrice,
|
|
1661
|
-
taxRate: pricingConfig?.taxRate ?? 0,
|
|
1662
|
-
promoDiscountAmount: promoDiscountAmount > 0 ? promoDiscountAmount : 0,
|
|
1663
|
-
discountLabel: appliedPromoCode ? `Promo: ${appliedPromoCode}` : undefined,
|
|
1664
|
-
});
|
|
1665
|
-
setShowCheckoutModal(true);
|
|
1666
|
-
setLoading(false);
|
|
1667
|
-
} catch (err) {
|
|
1668
|
-
setError(err instanceof Error ? err.message : 'Something went wrong');
|
|
1669
|
-
setLoading(false);
|
|
1670
|
-
}
|
|
1671
|
-
};
|
|
1672
|
-
|
|
1673
|
-
const handleConfirmWithoutPayment = async () => {
|
|
1674
|
-
if (!adminChoiceData) return;
|
|
1675
|
-
setLoading(true);
|
|
1676
|
-
setError('');
|
|
1677
|
-
try {
|
|
1678
|
-
const result = await confirmBookingWithoutPayment({
|
|
1679
|
-
reservationReference: adminChoiceData.reservationReference,
|
|
1680
|
-
productId: product.productId,
|
|
1681
|
-
optionId: adminChoiceData.availabilityProductOptionId,
|
|
1682
|
-
date: adminChoiceData.datePart,
|
|
1683
|
-
time: adminChoiceData.timePart,
|
|
1684
|
-
customerEmail: email || undefined,
|
|
1685
|
-
customerFirstName: firstName.trim() || undefined,
|
|
1686
|
-
customerLastName: lastName.trim() || undefined,
|
|
1687
|
-
currency: currency,
|
|
1688
|
-
travelerHotel: product.pickupLocations?.find(loc => loc.id === pickupLocationId)?.name || undefined,
|
|
1689
|
-
pickupLocationId: pickupLocationId || undefined,
|
|
1690
|
-
itineraryDisplay: adminChoiceData.itineraryDisplay ?? undefined,
|
|
1691
|
-
termsAcceptedAt: termsAcceptedAt ?? undefined,
|
|
1692
|
-
skipConfirmationCommunications: skipConfirmationCommunications ? true : undefined,
|
|
1693
|
-
disableAutoCommunications: disableAutoCommunications ? true : undefined,
|
|
1694
|
-
checkoutBreakdown: adminChoiceData.checkoutBreakdown,
|
|
1695
|
-
depositAmount: 0,
|
|
1696
|
-
balanceAmount: adminChoiceData.totalAmount,
|
|
1697
|
-
totalAmount: adminChoiceData.totalAmount,
|
|
1698
|
-
});
|
|
1699
|
-
const ref = formatBookingRefForDisplay(result.bookingReference);
|
|
1700
|
-
const ln = lastName.trim();
|
|
1701
|
-
setShowAdminPaymentChoice(false);
|
|
1702
|
-
setAdminChoiceData(null);
|
|
1703
|
-
if (onShowManage) {
|
|
1704
|
-
onShowManage({ ref, lastName: ln });
|
|
1705
|
-
} else {
|
|
1706
|
-
const params = new URLSearchParams({ ref, lastName: ln });
|
|
1707
|
-
window.location.href = `/manage?${params.toString()}`;
|
|
1708
|
-
}
|
|
1709
|
-
} catch (err) {
|
|
1710
|
-
setError(err instanceof Error ? err.message : 'Failed to confirm booking');
|
|
1711
|
-
} finally {
|
|
1712
|
-
setLoading(false);
|
|
1713
|
-
}
|
|
1714
|
-
};
|
|
1715
|
-
|
|
1716
|
-
const handlePayNow = () => {
|
|
1717
|
-
if (!adminChoiceData) return;
|
|
1718
|
-
setShowAdminPaymentChoice(false);
|
|
1719
|
-
setCheckoutClientSecret(adminChoiceData.clientSecret);
|
|
1720
|
-
setCheckoutModalData({
|
|
1721
|
-
reservationReference: adminChoiceData.reservationReference,
|
|
1722
|
-
customerLastName: lastName.trim(),
|
|
1723
|
-
ticketLines: adminChoiceData.ticketLinesForModal,
|
|
1724
|
-
feeLineItems: adminChoiceData.feeLineItems,
|
|
1725
|
-
returnPriceAdjustment: adminChoiceData.returnPriceAdjustment,
|
|
1726
|
-
cancellationPolicyFee: adminChoiceData.cancellationPolicyFee,
|
|
1727
|
-
cancellationPolicyLabel: adminChoiceData.cancellationPolicyLabel,
|
|
1728
|
-
subtotal: adminChoiceData.subtotal,
|
|
1729
|
-
tax: adminChoiceData.tax,
|
|
1730
|
-
total: adminChoiceData.totalAmount,
|
|
1731
|
-
totalQuantity: adminChoiceData.totalQuantity,
|
|
1732
|
-
isTaxIncludedInPrice: adminChoiceData.isTaxIncludedInPrice,
|
|
1733
|
-
taxRate: adminChoiceData.taxRate,
|
|
1734
|
-
promoDiscountAmount: adminChoiceData.promoDiscountAmount,
|
|
1735
|
-
discountLabel: adminChoiceData.discountLabel,
|
|
1736
|
-
});
|
|
1737
|
-
setShowCheckoutModal(true);
|
|
1738
|
-
setAdminChoiceData(null);
|
|
1739
|
-
};
|
|
1740
|
-
|
|
1741
|
-
if (activeOptions.length === 0) {
|
|
1742
|
-
return (
|
|
1743
|
-
<div className="flex items-center justify-center py-16">
|
|
1744
|
-
<div className="text-red-600">{t('booking.noActiveOption') || 'No active product options available'}</div>
|
|
1745
|
-
</div>
|
|
1746
|
-
);
|
|
1747
|
-
}
|
|
1748
|
-
|
|
1749
|
-
return (
|
|
1750
|
-
<div className="space-y-8">
|
|
1751
|
-
{/* Admin: choose to pay now or confirm without payment (full balance owed) */}
|
|
1752
|
-
{showAdminPaymentChoice && adminChoiceData && (
|
|
1753
|
-
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
|
1754
|
-
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6">
|
|
1755
|
-
<h3 className="text-lg font-semibold text-stone-900 mb-2">Complete booking</h3>
|
|
1756
|
-
<p className="text-sm text-stone-600 mb-4">
|
|
1757
|
-
Pay now, or confirm without payment. The customer can pay the full balance from the Manage Booking page.
|
|
1758
|
-
</p>
|
|
1759
|
-
{error && (
|
|
1760
|
-
<p className="text-sm text-red-600 mb-4" role="alert">{error}</p>
|
|
1761
|
-
)}
|
|
1762
|
-
<div className="flex flex-col gap-3">
|
|
1763
|
-
<button
|
|
1764
|
-
type="button"
|
|
1765
|
-
onClick={handlePayNow}
|
|
1766
|
-
disabled={loading}
|
|
1767
|
-
className="w-full py-3 px-4 bg-emerald-600 text-white font-semibold rounded-lg hover:bg-emerald-700 disabled:opacity-50"
|
|
1768
|
-
>
|
|
1769
|
-
{loading ? 'Loading...' : `Pay now (${formatCurrencyAmount(adminChoiceData.totalAmount, currency)})`}
|
|
1770
|
-
</button>
|
|
1771
|
-
<button
|
|
1772
|
-
type="button"
|
|
1773
|
-
onClick={handleConfirmWithoutPayment}
|
|
1774
|
-
disabled={loading}
|
|
1775
|
-
className="w-full py-3 px-4 border border-stone-300 text-stone-700 rounded-lg hover:bg-stone-50 disabled:opacity-50"
|
|
1776
|
-
>
|
|
1777
|
-
Confirm without payment
|
|
1778
|
-
</button>
|
|
1779
|
-
<button
|
|
1780
|
-
type="button"
|
|
1781
|
-
onClick={() => { setShowAdminPaymentChoice(false); setAdminChoiceData(null); setError(''); }}
|
|
1782
|
-
className="w-full py-2 text-sm text-stone-500 hover:text-stone-700"
|
|
1783
|
-
>
|
|
1784
|
-
Cancel
|
|
1785
|
-
</button>
|
|
1786
|
-
</div>
|
|
1787
|
-
</div>
|
|
1788
|
-
</div>
|
|
1789
|
-
)}
|
|
1790
|
-
{checkoutModalData && (
|
|
1791
|
-
<CheckoutModal
|
|
1792
|
-
open={showCheckoutModal}
|
|
1793
|
-
onClose={() => {
|
|
1794
|
-
setShowCheckoutModal(false);
|
|
1795
|
-
setCheckoutClientSecret('');
|
|
1796
|
-
setCheckoutModalData(null);
|
|
1797
|
-
}}
|
|
1798
|
-
clientSecret={checkoutClientSecret}
|
|
1799
|
-
reservationReference={checkoutModalData.reservationReference}
|
|
1800
|
-
customerLastName={checkoutModalData.customerLastName}
|
|
1801
|
-
successUrlOverride={getSuccessUrl ? getSuccessUrl({ reservationRef: checkoutModalData.reservationReference, lastName: checkoutModalData.customerLastName ?? '' }) : undefined}
|
|
1802
|
-
ticketLines={checkoutModalData.ticketLines}
|
|
1803
|
-
feeLineItems={checkoutModalData.feeLineItems}
|
|
1804
|
-
returnPriceAdjustment={checkoutModalData.returnPriceAdjustment}
|
|
1805
|
-
cancellationPolicyFee={checkoutModalData.cancellationPolicyFee}
|
|
1806
|
-
cancellationPolicyLabel={checkoutModalData.cancellationPolicyLabel}
|
|
1807
|
-
subtotal={checkoutModalData.subtotal}
|
|
1808
|
-
tax={checkoutModalData.tax}
|
|
1809
|
-
total={checkoutModalData.total}
|
|
1810
|
-
promoDiscountAmount={checkoutModalData.promoDiscountAmount ?? 0}
|
|
1811
|
-
discountLabel={checkoutModalData.discountLabel}
|
|
1812
|
-
totalQuantity={checkoutModalData.totalQuantity}
|
|
1813
|
-
isTaxIncludedInPrice={checkoutModalData.isTaxIncludedInPrice}
|
|
1814
|
-
taxRate={checkoutModalData.taxRate}
|
|
1815
|
-
currency={currency}
|
|
1816
|
-
locale={locale}
|
|
1817
|
-
t={t}
|
|
1818
|
-
/>
|
|
1819
|
-
)}
|
|
1820
|
-
{/* Back button */}
|
|
1821
|
-
<button
|
|
1822
|
-
onClick={onBack}
|
|
1823
|
-
className="flex items-center gap-2 text-stone-600 hover:text-stone-900 transition-colors"
|
|
1824
|
-
>
|
|
1825
|
-
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1826
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
1827
|
-
</svg>
|
|
1828
|
-
<span>{t('products.backToExperiences')}</span>
|
|
1829
|
-
</button>
|
|
1830
|
-
|
|
1831
|
-
{/* Product header */}
|
|
1832
|
-
<div className="bg-gradient-to-r from-emerald-700 to-emerald-600 text-white p-8 rounded-xl">
|
|
1833
|
-
<h2 className="text-2xl font-bold mb-2">{product.name}</h2>
|
|
1834
|
-
{product.description && (
|
|
1835
|
-
<p className="text-emerald-100">{product.description}</p>
|
|
1836
|
-
)}
|
|
1837
|
-
</div>
|
|
1838
|
-
|
|
1839
|
-
{loadingAvailabilities && availabilities.length === 0 ? (
|
|
1840
|
-
<div className="flex items-center justify-center py-12">
|
|
1841
|
-
<div className="text-center">
|
|
1842
|
-
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-emerald-600 mb-4"></div>
|
|
1843
|
-
<div className="text-stone-600">{t('booking.loadingTimes')}</div>
|
|
1844
|
-
</div>
|
|
1845
|
-
</div>
|
|
1846
|
-
) : availabilities.length === 0 ? (
|
|
1847
|
-
<div className="text-center py-8 text-stone-500">
|
|
1848
|
-
{t('booking.noAvailability')}
|
|
1849
|
-
</div>
|
|
1850
|
-
) : (
|
|
1851
|
-
<>
|
|
1852
|
-
{/* Date Selection */}
|
|
1853
|
-
<div>
|
|
1854
|
-
<div className="relative">
|
|
1855
|
-
{loadingAvailabilities && (
|
|
1856
|
-
<div className="absolute inset-0 bg-white/80 backdrop-blur-sm flex items-center justify-center z-10 rounded-lg">
|
|
1857
|
-
<div className="text-stone-600">{t('booking.loadingTimes')}</div>
|
|
1858
|
-
</div>
|
|
1859
|
-
)}
|
|
1860
|
-
<Calendar
|
|
1861
|
-
availabilitiesByDate={availabilitiesByDate}
|
|
1862
|
-
selectedDate={selectedDate}
|
|
1863
|
-
onDateSelect={(date) => {
|
|
1864
|
-
setSelectedDate(date);
|
|
1865
|
-
handleDateSelect(date);
|
|
1866
|
-
}}
|
|
1867
|
-
timezone={companyTimezone}
|
|
1868
|
-
earliestDate={earliestAvailabilityDate}
|
|
1869
|
-
onVisibleRangeChange={handleVisibleRangeChange}
|
|
1870
|
-
currency={currency}
|
|
1871
|
-
showCapacity={isAdmin}
|
|
1872
|
-
/>
|
|
1873
|
-
</div>
|
|
1874
|
-
</div>
|
|
1875
|
-
|
|
1876
|
-
{/* Your itinerary box - shown after date selection, before pickup/return/tickets/pickup location */}
|
|
1877
|
-
{selectedDate && (() => {
|
|
1878
|
-
const hasItineraryAny = activeOptions.some(o => o.itinerary?.length) && (product.destinations?.length ?? 0) > 0;
|
|
1879
|
-
if (!hasItineraryAny) return null;
|
|
1880
|
-
if (!selectedAvailability) {
|
|
1881
|
-
return (
|
|
1882
|
-
<div className="sticky top-[136px] sm:top-[73px] z-10 mb-4 p-3 bg-white rounded-lg shadow-sm border border-stone-200">
|
|
1883
|
-
<h3 className="text-sm font-semibold text-stone-700 mb-2">
|
|
1884
|
-
<b>{t('booking.buildYourItinerary')}</b>
|
|
1885
|
-
</h3>
|
|
1886
|
-
<p className="text-sm text-stone-600">
|
|
1887
|
-
{t('booking.selectPickupTimeToSeeItinerary') || 'Select a pickup time below to see your schedule.'}
|
|
1888
|
-
</p>
|
|
1889
|
-
</div>
|
|
1890
|
-
);
|
|
1891
|
-
}
|
|
1892
|
-
|
|
1893
|
-
const itineraryItems = computeItineraryDisplay();
|
|
1894
|
-
if (!itineraryItems || itineraryItems.length === 0) return null;
|
|
1895
|
-
|
|
1896
|
-
// Check if booking is complete (all required selections made)
|
|
1897
|
-
const isBookingComplete = selectedAvailability &&
|
|
1898
|
-
selectedReturnOption &&
|
|
1899
|
-
(pickupLocationId || pickupLocationSkipped || !product.pickupLocations || product.pickupLocations.length === 0) &&
|
|
1900
|
-
Object.values(quantities).some(qty => qty > 0) &&
|
|
1901
|
-
email.trim() !== '' &&
|
|
1902
|
-
lastName.trim() !== '';
|
|
1903
|
-
|
|
1904
|
-
return (
|
|
1905
|
-
<div
|
|
1906
|
-
ref={itineraryRef}
|
|
1907
|
-
className="sticky top-[136px] sm:top-[73px] z-10 mb-4 p-3 bg-white rounded-lg shadow-sm border border-stone-200"
|
|
1908
|
-
>
|
|
1909
|
-
<h3 className={`font-bold text-stone-900 flex items-center gap-2 transition-all duration-300 ${isItinerarySticky ? 'text-base sm:text-lg mb-2 sm:mb-4' : 'text-lg sm:text-xl mb-4'}`}>
|
|
1910
|
-
{!isBookingComplete && (
|
|
1911
|
-
<svg className={`flex-shrink-0 text-stone-400 ${isItinerarySticky ? 'w-3 h-3 sm:w-4 sm:h-4' : 'w-4 h-4'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1912
|
-
<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" />
|
|
1913
|
-
</svg>
|
|
1914
|
-
)}
|
|
1915
|
-
{t('booking.buildYourItinerary')}
|
|
1916
|
-
</h3>
|
|
1917
|
-
{itineraryItems.length > 0 && (
|
|
1918
|
-
<div className={`transition-all duration-700 ease-in-out ${isItinerarySticky ? 'flex items-center gap-0.5 sm:gap-1 flex-wrap' : 'space-y-1'}`}>
|
|
1919
|
-
{itineraryItems.map((item, index) => {
|
|
1920
|
-
const itemLabel = getStepLabel(item, t);
|
|
1921
|
-
const isUncertain =
|
|
1922
|
-
!item.time ||
|
|
1923
|
-
(!selectedPickupLocation &&
|
|
1924
|
-
!pickupLocationSkipped &&
|
|
1925
|
-
(item.stepType === StepType.pickup || item.stepType === StepType.drop_off));
|
|
1926
|
-
const hasTime = !!item.time;
|
|
1927
|
-
const isPlaceholder = item.stepType === StepType.trip_end && !item.time;
|
|
1928
|
-
const isPickupTime = index === 0 && item.stepType === StepType.pickup;
|
|
1929
|
-
const isApproximatePickupTime = isPickupTime && !selectedPickupLocation && !pickupLocationSkipped && product.pickupLocations && product.pickupLocations.length > 0;
|
|
1930
|
-
|
|
1931
|
-
// Format time - remove "at" prefix and format distinctly
|
|
1932
|
-
let formattedTime = '';
|
|
1933
|
-
if (item.time) {
|
|
1934
|
-
formattedTime = item.time.replace(/^at /i, '');
|
|
1935
|
-
formattedTime = formattedTime.replace(/:00(?=\s*(AM|PM))/i, '');
|
|
1936
|
-
}
|
|
1937
|
-
|
|
1938
|
-
// Helper function to abbreviate destinations
|
|
1939
|
-
const abbreviateDestination = (text: string): string => {
|
|
1940
|
-
const abbreviations: Record<string, string> = {
|
|
1941
|
-
'Moraine Lake': 'ML',
|
|
1942
|
-
'Lake Louise': 'LL',
|
|
1943
|
-
'Banff': 'B',
|
|
1944
|
-
'Jasper': 'J',
|
|
1945
|
-
'Canmore': 'C',
|
|
1946
|
-
};
|
|
1947
|
-
for (const [full, abbrev] of Object.entries(abbreviations)) {
|
|
1948
|
-
if (text.includes(full)) {
|
|
1949
|
-
return text.replace(full, abbrev);
|
|
1950
|
-
}
|
|
1951
|
-
}
|
|
1952
|
-
return text;
|
|
1953
|
-
};
|
|
1954
|
-
|
|
1955
|
-
// When uncertain, the full label is already correct for i18n (getStepLabel uses t('booking.dropOffAt', { location })). Make the whole label clickable so we don't assume word order across languages.
|
|
1956
|
-
const isUncertainPickupOrDropOff =
|
|
1957
|
-
isUncertain && (item.stepType === StepType.pickup || item.stepType === StepType.drop_off);
|
|
1958
|
-
|
|
1959
|
-
// Scroll handler for pickup location
|
|
1960
|
-
const handlePickupLocationClick = () => {
|
|
1961
|
-
const pickupSection = document.getElementById('pickup-location-section');
|
|
1962
|
-
const itineraryBox = document.querySelector('[class*="sticky top-"]');
|
|
1963
|
-
if (pickupSection && itineraryBox) {
|
|
1964
|
-
const headerHeight = window.innerWidth >= 640 ? 73 : 136;
|
|
1965
|
-
const itineraryHeight = itineraryBox.getBoundingClientRect().height;
|
|
1966
|
-
const elementPosition = pickupSection.getBoundingClientRect().top;
|
|
1967
|
-
const offsetPosition = elementPosition + window.pageYOffset - headerHeight - itineraryHeight - 16;
|
|
1968
|
-
window.scrollTo({
|
|
1969
|
-
top: Math.max(0, offsetPosition),
|
|
1970
|
-
behavior: 'smooth'
|
|
1971
|
-
});
|
|
1972
|
-
} else if (pickupSection) {
|
|
1973
|
-
pickupSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
1974
|
-
}
|
|
1975
|
-
};
|
|
1976
|
-
|
|
1977
|
-
// For sticky mobile view: abbreviate destinations
|
|
1978
|
-
const getDisplayLabel = (label: string): string => {
|
|
1979
|
-
if (isItinerarySticky && isMobile) {
|
|
1980
|
-
// Mobile sticky: abbreviate destinations
|
|
1981
|
-
return abbreviateDestination(label);
|
|
1982
|
-
}
|
|
1983
|
-
return label;
|
|
1984
|
-
};
|
|
1985
|
-
|
|
1986
|
-
const locationOnlyDisplay =
|
|
1987
|
-
item.place === 'your_pickup_location' ? t('booking.yourPickupLocation') : (item.place ?? '');
|
|
1988
|
-
const prefixKey =
|
|
1989
|
-
item.stepType === StepType.pickup ? 'booking.pickupAtPrefix' : 'booking.dropOffAtPrefix';
|
|
1990
|
-
|
|
1991
|
-
const content = (
|
|
1992
|
-
<>
|
|
1993
|
-
{hasTime ? (
|
|
1994
|
-
<span className="inline-flex items-center gap-1.5 flex-wrap">
|
|
1995
|
-
<span className={`inline-flex items-center gap-1.5 shrink-0 ${(formattedTime === 'TBD' && isUncertain) || isApproximatePickupTime ? 'text-stone-400' : 'font-bold text-stone-900'}`}>
|
|
1996
|
-
{(formattedTime === 'TBD' && isUncertain) && (
|
|
1997
|
-
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden>
|
|
1998
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
1999
|
-
</svg>
|
|
2000
|
-
)}
|
|
2001
|
-
{isApproximatePickupTime && formattedTime !== 'TBD' && (
|
|
2002
|
-
<span className="relative inline-block" data-tooltip-icon>
|
|
2003
|
-
<svg
|
|
2004
|
-
className="w-4 h-4 text-stone-400 cursor-pointer shrink-0"
|
|
2005
|
-
fill="none"
|
|
2006
|
-
stroke="currentColor"
|
|
2007
|
-
viewBox="0 0 24 24"
|
|
2008
|
-
onClick={(e) => {
|
|
2009
|
-
e.stopPropagation();
|
|
2010
|
-
setShowTooltip(!showTooltip);
|
|
2011
|
-
}}
|
|
2012
|
-
onMouseEnter={() => !isMobile && setShowTooltip(true)}
|
|
2013
|
-
onMouseLeave={() => !isMobile && setShowTooltip(false)}
|
|
2014
|
-
>
|
|
2015
|
-
<path
|
|
2016
|
-
strokeLinecap="round"
|
|
2017
|
-
strokeLinejoin="round"
|
|
2018
|
-
strokeWidth={2}
|
|
2019
|
-
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
2020
|
-
/>
|
|
2021
|
-
</svg>
|
|
2022
|
-
{showTooltip && (
|
|
2023
|
-
<span className={`absolute ${isMobile ? 'top-full mt-1 left-0 w-[280px]' : 'left-1/2 -translate-x-1/2 bottom-full mb-2'} whitespace-normal sm:whitespace-nowrap text-xs bg-stone-800 text-white px-3 py-2 rounded shadow-lg z-50 pointer-events-none sm:max-w-none`}>
|
|
2024
|
-
Approximate time - will be finalized when you select a pickup location
|
|
2025
|
-
</span>
|
|
2026
|
-
)}
|
|
2027
|
-
</span>
|
|
2028
|
-
)}
|
|
2029
|
-
<span className="shrink-0 font-bold">{formattedTime}</span>
|
|
2030
|
-
</span>
|
|
2031
|
-
<span className="text-stone-400 shrink-0">-</span>
|
|
2032
|
-
<span className="text-stone-600">
|
|
2033
|
-
{isUncertainPickupOrDropOff ? (
|
|
2034
|
-
<>
|
|
2035
|
-
{t(prefixKey)}
|
|
2036
|
-
<button
|
|
2037
|
-
type="button"
|
|
2038
|
-
onClick={handlePickupLocationClick}
|
|
2039
|
-
className="text-stone-400 hover:text-stone-600 underline cursor-pointer"
|
|
2040
|
-
>
|
|
2041
|
-
{getDisplayLabel(locationOnlyDisplay)}
|
|
2042
|
-
</button>
|
|
2043
|
-
</>
|
|
2044
|
-
) : isUncertain ? (
|
|
2045
|
-
<span className="text-stone-400 underline">{getDisplayLabel(itemLabel)}</span>
|
|
2046
|
-
) : (
|
|
2047
|
-
getDisplayLabel(itemLabel)
|
|
2048
|
-
)}
|
|
2049
|
-
</span>
|
|
2050
|
-
</span>
|
|
2051
|
-
) : isPlaceholder ? (
|
|
2052
|
-
<span className="text-stone-400">{itemLabel}</span>
|
|
2053
|
-
) : (
|
|
2054
|
-
<span className="inline-flex items-center gap-1.5 flex-wrap">
|
|
2055
|
-
<span className={`inline-flex items-center gap-1.5 shrink-0 ${isUncertain ? 'text-stone-400' : 'font-bold text-stone-900'}`}>
|
|
2056
|
-
{isUncertain && (
|
|
2057
|
-
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden>
|
|
2058
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
2059
|
-
</svg>
|
|
2060
|
-
)}
|
|
2061
|
-
<span className="shrink-0 font-bold">TBD</span>
|
|
2062
|
-
</span>
|
|
2063
|
-
<span className="text-stone-400 shrink-0">-</span>
|
|
2064
|
-
<span className="text-stone-600">
|
|
2065
|
-
{isUncertainPickupOrDropOff ? (
|
|
2066
|
-
<>
|
|
2067
|
-
{t(prefixKey)}
|
|
2068
|
-
<button
|
|
2069
|
-
type="button"
|
|
2070
|
-
onClick={handlePickupLocationClick}
|
|
2071
|
-
className="text-stone-400 hover:text-stone-600 underline cursor-pointer"
|
|
2072
|
-
>
|
|
2073
|
-
{getDisplayLabel(locationOnlyDisplay)}
|
|
2074
|
-
</button>
|
|
2075
|
-
</>
|
|
2076
|
-
) : (
|
|
2077
|
-
<span className="text-stone-400 underline">{getDisplayLabel(itemLabel)}</span>
|
|
2078
|
-
)}
|
|
2079
|
-
</span>
|
|
2080
|
-
</span>
|
|
2081
|
-
)}
|
|
2082
|
-
</>
|
|
2083
|
-
);
|
|
2084
|
-
|
|
2085
|
-
if (isItinerarySticky) {
|
|
2086
|
-
return (
|
|
2087
|
-
<span key={index} className={`text-xs sm:text-sm ${isMobile ? '' : 'whitespace-nowrap'} break-words`}>
|
|
2088
|
-
{content}
|
|
2089
|
-
{index < itineraryItems.length - 1 && (
|
|
2090
|
-
<span className="mx-0.5 sm:mx-1 text-stone-400">
|
|
2091
|
-
<svg className="w-3 h-3 sm:w-4 sm:h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
2092
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
|
2093
|
-
</svg>
|
|
2094
|
-
</span>
|
|
2095
|
-
)}
|
|
2096
|
-
</span>
|
|
2097
|
-
);
|
|
2098
|
-
}
|
|
2099
|
-
|
|
2100
|
-
return (
|
|
2101
|
-
<div key={index} className="text-sm leading-tight">
|
|
2102
|
-
{content}
|
|
2103
|
-
</div>
|
|
2104
|
-
);
|
|
2105
|
-
})}
|
|
2106
|
-
</div>
|
|
2107
|
-
)}
|
|
2108
|
-
</div>
|
|
2109
|
-
);
|
|
2110
|
-
})()}
|
|
2111
|
-
|
|
2112
|
-
{/* Select pickup time */}
|
|
2113
|
-
{selectedDate && (
|
|
2114
|
-
<div>
|
|
2115
|
-
<label className="block text-sm font-medium text-stone-700 mb-2">
|
|
2116
|
-
{t('booking.selectPickupTime')}
|
|
2117
|
-
</label>
|
|
2118
|
-
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2">
|
|
2119
|
-
{pickupTimes.map((timeInfo) => {
|
|
2120
|
-
const isSelected = selectedAvailability?.dateTime === timeInfo.dateTime;
|
|
2121
|
-
const isSoldOut = timeInfo.vacancies === 0;
|
|
2122
|
-
const canSelect = !isSoldOut || isAdmin;
|
|
2123
|
-
const option = timeInfo.productOptionId
|
|
2124
|
-
? optionsMap.get(timeInfo.productOptionId)
|
|
2125
|
-
: undefined;
|
|
2126
|
-
const isMostPopular = pickupTimes.length > 1 && option?.mostPopular;
|
|
2127
|
-
// Use selectedAvailability for capacity when this slot is selected so it matches tickets line and overbooking message
|
|
2128
|
-
const capacitySource = isSelected && selectedAvailability ? selectedAvailability : timeInfo;
|
|
2129
|
-
const totalCap = capacitySource.totalCapacity ?? 0;
|
|
2130
|
-
const booked = capacitySource.bookedCapacity ?? (totalCap - (capacitySource.vacancies ?? 0));
|
|
2131
|
-
const showCapacity = isAdmin && totalCap > 0;
|
|
2132
|
-
return (
|
|
2133
|
-
<button
|
|
2134
|
-
key={timeInfo.dateTime}
|
|
2135
|
-
onClick={() => canSelect && handleTimeSelect(timeInfo)}
|
|
2136
|
-
disabled={!canSelect}
|
|
2137
|
-
className={`${hasAnyMostPopular ? 'pt-5 sm:pt-4' : 'pt-3'} pb-3 px-4 rounded-lg text-sm font-medium transition-all relative ${
|
|
2138
|
-
!canSelect
|
|
2139
|
-
? 'bg-stone-100 text-stone-400 cursor-not-allowed'
|
|
2140
|
-
: isSoldOut && isAdmin
|
|
2141
|
-
? 'bg-red-50 text-red-700 border border-red-200 hover:bg-red-100'
|
|
2142
|
-
: isSelected
|
|
2143
|
-
? 'bg-emerald-600 text-white'
|
|
2144
|
-
: 'bg-stone-100 text-stone-700 hover:bg-stone-200'
|
|
2145
|
-
}`}
|
|
2146
|
-
>
|
|
2147
|
-
<div>{pickupLocationSkipped && timeInfo.displayTimeRange ? timeInfo.displayTimeRange : timeInfo.displayTime}</div>
|
|
2148
|
-
{showCapacity && !isSoldOut && (
|
|
2149
|
-
<div className={`text-xs mt-0.5 tabular-nums ${isSelected ? 'text-white' : 'text-stone-500'}`}>
|
|
2150
|
-
{booked} / {totalCap} capacity
|
|
2151
|
-
</div>
|
|
2152
|
-
)}
|
|
2153
|
-
{isMostPopular && (
|
|
2154
|
-
<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' }}>
|
|
2155
|
-
{t('booking.mostPopular')}
|
|
2156
|
-
</div>
|
|
2157
|
-
)}
|
|
2158
|
-
{isSoldOut && (
|
|
2159
|
-
<div className="text-xs text-red-600 font-medium">
|
|
2160
|
-
{isAdmin && showCapacity
|
|
2161
|
-
? `Sold out (${booked}/${totalCap} capacity)`
|
|
2162
|
-
: 'Sold out'}
|
|
2163
|
-
</div>
|
|
2164
|
-
)}
|
|
2165
|
-
</button>
|
|
2166
|
-
);
|
|
2167
|
-
})}
|
|
2168
|
-
</div>
|
|
2169
|
-
</div>
|
|
2170
|
-
)}
|
|
2171
|
-
|
|
2172
|
-
{/* Select return time */}
|
|
2173
|
-
{selectedAvailability && selectedAvailability.returnOptions && selectedAvailability.returnOptions.length > 0 && (
|
|
2174
|
-
<div>
|
|
2175
|
-
<label className="block text-sm font-medium text-stone-700 mb-4">
|
|
2176
|
-
{t('booking.selectReturnTime') || 'Select Return Time'}
|
|
2177
|
-
</label>
|
|
2178
|
-
|
|
2179
|
-
<div className="space-y-2">
|
|
2180
|
-
{(() => {
|
|
2181
|
-
const sortedReturnOptions = [...(selectedAvailability.returnOptions || [])].sort((a, b) => {
|
|
2182
|
-
const timeA = parseISO(a.dateTime).getTime();
|
|
2183
|
-
const timeB = parseISO(b.dateTime).getTime();
|
|
2184
|
-
return timeA - timeB;
|
|
2185
|
-
});
|
|
2186
|
-
return sortedReturnOptions.map((returnOption, index) => {
|
|
2187
|
-
const returnDateTime = parseISO(returnOption.dateTime);
|
|
2188
|
-
const displayTime = formatInTimeZone(returnDateTime, companyTimezone, 'h:mm a');
|
|
2189
|
-
const isSelected = selectedReturnOption?.returnAvailabilityId === returnOption.returnAvailabilityId;
|
|
2190
|
-
const isSoldOut = returnOption.vacancies === 0;
|
|
2191
|
-
const canSelectReturn = !isSoldOut || isAdmin;
|
|
2192
|
-
const priceAdjustmentPerPerson = returnOption.priceAdjustmentByCurrency?.[currency] ?? 0;
|
|
2193
|
-
const returnOptionsCount = selectedAvailability.returnOptions?.length ?? 0;
|
|
2194
|
-
// First return in list (by time) is treated as most popular — purely frontend
|
|
2195
|
-
const isMostPopular = returnOptionsCount > 1 && index === 0;
|
|
2196
|
-
|
|
2197
|
-
// Calculate stay summary for this specific return option
|
|
2198
|
-
const staySummary = calculateStaySummary(returnDateTime);
|
|
2199
|
-
|
|
2200
|
-
return (
|
|
2201
|
-
<button
|
|
2202
|
-
key={returnOption.returnAvailabilityId}
|
|
2203
|
-
onClick={() => canSelectReturn && setSelectedReturnOption(returnOption)}
|
|
2204
|
-
disabled={!canSelectReturn}
|
|
2205
|
-
className={`w-full p-4 rounded-lg border-2 transition-all text-left relative ${
|
|
2206
|
-
isSelected
|
|
2207
|
-
? 'border-emerald-600 bg-emerald-50'
|
|
2208
|
-
: !canSelectReturn
|
|
2209
|
-
? 'border-stone-200 bg-stone-100 text-stone-400 cursor-not-allowed'
|
|
2210
|
-
: isSoldOut && isAdmin
|
|
2211
|
-
? 'border-red-200 bg-red-50 hover:border-red-300 hover:bg-red-100'
|
|
2212
|
-
: 'border-stone-200 bg-white hover:border-emerald-300 hover:bg-emerald-50'
|
|
2213
|
-
}`}
|
|
2214
|
-
>
|
|
2215
|
-
{isMostPopular && (
|
|
2216
|
-
<div className="absolute -top-1 -right-1 text-white text-[10px] font-semibold px-1.5 py-0.5 rounded-full" style={{ backgroundColor: '#ff4d00' }}>
|
|
2217
|
-
{t('booking.mostPopular')}
|
|
2218
|
-
</div>
|
|
2219
|
-
)}
|
|
2220
|
-
<div className="flex items-center justify-between">
|
|
2221
|
-
<div className="flex-1">
|
|
2222
|
-
<div className="font-medium text-stone-900">{displayTime}</div>
|
|
2223
|
-
<div className="text-xs text-stone-600 mt-0.5">from {returnOption.returnLocation}</div>
|
|
2224
|
-
{isAdmin && returnOption.totalCapacity != null && returnOption.totalCapacity > 0 && (
|
|
2225
|
-
<div className="text-xs text-stone-500 mt-0.5 tabular-nums">
|
|
2226
|
-
{(returnOption.bookedCapacity ?? returnOption.totalCapacity - returnOption.vacancies)} / {returnOption.totalCapacity} capacity
|
|
2227
|
-
</div>
|
|
2228
|
-
)}
|
|
2229
|
-
{staySummary && (
|
|
2230
|
-
<div className="text-xs text-stone-500 mt-1 font-semibold">
|
|
2231
|
-
{staySummary}
|
|
2232
|
-
</div>
|
|
2233
|
-
)}
|
|
2234
|
-
{isSoldOut && (
|
|
2235
|
-
<div className="text-xs font-medium mt-1 text-red-600">
|
|
2236
|
-
{t('booking.soldOut')}
|
|
2237
|
-
{isAdmin && ' (admin can overbook)'}
|
|
2238
|
-
</div>
|
|
2239
|
-
)}
|
|
2240
|
-
</div>
|
|
2241
|
-
<div className={`text-sm font-semibold ml-4 ${
|
|
2242
|
-
priceAdjustmentPerPerson === 0
|
|
2243
|
-
? 'text-stone-600'
|
|
2244
|
-
: priceAdjustmentPerPerson > 0
|
|
2245
|
-
? 'text-emerald-600'
|
|
2246
|
-
: 'text-red-600'
|
|
2247
|
-
}`}>
|
|
2248
|
-
{priceAdjustmentPerPerson === 0
|
|
2249
|
-
? 'Included'
|
|
2250
|
-
: priceAdjustmentPerPerson > 0
|
|
2251
|
-
? `+${formatCurrencyAmount(priceAdjustmentPerPerson, currency, locale)} per person`
|
|
2252
|
-
: `${formatCurrencyAmount(priceAdjustmentPerPerson, currency, locale)} per person`}
|
|
2253
|
-
</div>
|
|
2254
|
-
</div>
|
|
2255
|
-
</button>
|
|
2256
|
-
);
|
|
2257
|
-
});
|
|
2258
|
-
})()}
|
|
2259
|
-
</div>
|
|
2260
|
-
</div>
|
|
2261
|
-
)}
|
|
2262
|
-
|
|
2263
|
-
{/* Cancellation policy - forced by promo or user selection */}
|
|
2264
|
-
{selectedAvailability && (forcedCancellationPolicyFromPromo || (pricingConfig?.cancellationPolicies && pricingConfig.cancellationPolicies.length > 0)) && (
|
|
2265
|
-
<div className="mt-6">
|
|
2266
|
-
<div className="text-sm font-medium text-stone-700 mb-2">
|
|
2267
|
-
{t('booking.cancellationPolicy')}
|
|
2268
|
-
</div>
|
|
2269
|
-
{forcedCancellationPolicyFromPromo ? (
|
|
2270
|
-
<div className="flex items-center gap-3 p-4 rounded-lg border-2 border-amber-200 bg-amber-50">
|
|
2271
|
-
<Check className="h-5 w-5 shrink-0 text-amber-600" />
|
|
2272
|
-
<div>
|
|
2273
|
-
<span className="font-medium text-stone-900">{forcedCancellationPolicyFromPromo.label}</span>
|
|
2274
|
-
<p className="text-sm text-stone-600 mt-0.5">{t('booking.promoRequiresThisPolicy')}</p>
|
|
2275
|
-
</div>
|
|
2276
|
-
</div>
|
|
2277
|
-
) : (
|
|
2278
|
-
<div className="space-y-2">
|
|
2279
|
-
{pricingConfig!.cancellationPolicies!.map((policy) => {
|
|
2280
|
-
const fee = policy.feeByCurrency[currency] ?? 0;
|
|
2281
|
-
const isSelected = cancellationPolicyId === policy.id;
|
|
2282
|
-
const isFree = fee === 0;
|
|
2283
|
-
return (
|
|
2284
|
-
<button
|
|
2285
|
-
key={policy.id}
|
|
2286
|
-
type="button"
|
|
2287
|
-
onClick={() => setCancellationPolicyId(policy.id)}
|
|
2288
|
-
className={`w-full flex items-center justify-between gap-3 p-4 rounded-lg border-2 text-left transition-colors ${
|
|
2289
|
-
isSelected
|
|
2290
|
-
? 'border-emerald-500 bg-emerald-50'
|
|
2291
|
-
: 'border-stone-200 bg-white hover:border-stone-300'
|
|
2292
|
-
}`}
|
|
2293
|
-
>
|
|
2294
|
-
<div className="flex items-center gap-3">
|
|
2295
|
-
<span className={`flex h-5 w-5 shrink-0 items-center justify-center rounded border ${
|
|
2296
|
-
isSelected ? 'border-emerald-500 bg-emerald-500' : 'border-stone-300'
|
|
2297
|
-
}`}>
|
|
2298
|
-
{isSelected && <Check className="h-3 w-3 text-white" />}
|
|
2299
|
-
</span>
|
|
2300
|
-
<span className="font-medium text-stone-900">
|
|
2301
|
-
{policy.label}
|
|
2302
|
-
</span>
|
|
2303
|
-
</div>
|
|
2304
|
-
<span className={`text-sm whitespace-nowrap ${isFree ? 'text-stone-500' : 'font-semibold text-stone-700'}`}>
|
|
2305
|
-
{isFree ? (t('booking.included') ?? 'Included') : `+${formatCurrencyAmount(fee, currency, locale)}`}
|
|
2306
|
-
</span>
|
|
2307
|
-
</button>
|
|
2308
|
-
);
|
|
2309
|
-
})}
|
|
2310
|
-
</div>
|
|
2311
|
-
)}
|
|
2312
|
-
</div>
|
|
2313
|
-
)}
|
|
2314
|
-
|
|
2315
|
-
{/* Ticket Selection */}
|
|
2316
|
-
{selectedAvailability && (
|
|
2317
|
-
<div>
|
|
2318
|
-
<div className="flex items-center justify-between gap-2 mb-4">
|
|
2319
|
-
<label className="block text-sm font-medium text-stone-700">
|
|
2320
|
-
{t('booking.tickets')}
|
|
2321
|
-
</label>
|
|
2322
|
-
{isAdmin && (() => {
|
|
2323
|
-
const pickupTotal = selectedAvailability.totalCapacity ?? 0;
|
|
2324
|
-
const returnTotal = selectedReturnOption?.totalCapacity ?? null;
|
|
2325
|
-
const effectiveCapacity = returnTotal != null ? returnTotal : pickupTotal;
|
|
2326
|
-
if (effectiveCapacity <= 0) return null;
|
|
2327
|
-
const currentBooked = returnTotal != null && selectedReturnOption
|
|
2328
|
-
? (selectedReturnOption.bookedCapacity ?? selectedReturnOption.totalCapacity - (selectedReturnOption.vacancies ?? 0))
|
|
2329
|
-
: (selectedAvailability.bookedCapacity ?? (pickupTotal - (selectedAvailability.vacancies ?? 0)));
|
|
2330
|
-
return (
|
|
2331
|
-
<span className="text-xs text-stone-500 tabular-nums">
|
|
2332
|
-
{currentBooked} / {effectiveCapacity} capacity
|
|
2333
|
-
</span>
|
|
2334
|
-
);
|
|
2335
|
-
})()}
|
|
2336
|
-
</div>
|
|
2337
|
-
<div className="space-y-4">
|
|
2338
|
-
{pricing.map(rate => {
|
|
2339
|
-
const isPublicMode = isSimplifiedPricingView;
|
|
2340
|
-
// Use selected return's capacity when a return is selected (bottleneck); otherwise pickup availability capacity
|
|
2341
|
-
const pickupTotal = selectedAvailability.totalCapacity ?? 0;
|
|
2342
|
-
const returnTotal = selectedReturnOption?.totalCapacity ?? null;
|
|
2343
|
-
const effectiveCap = returnTotal != null ? returnTotal : pickupTotal;
|
|
2344
|
-
const ticketsCurrentBooked = returnTotal != null && selectedReturnOption
|
|
2345
|
-
? (selectedReturnOption.bookedCapacity ?? selectedReturnOption.totalCapacity - (selectedReturnOption.vacancies ?? 0))
|
|
2346
|
-
: (selectedAvailability.bookedCapacity ?? (pickupTotal - (selectedAvailability.vacancies ?? 0)));
|
|
2347
|
-
const isOverbookedTickets = effectiveCap > 0 && (ticketsCurrentBooked + totalQuantity) > effectiveCap;
|
|
2348
|
-
|
|
2349
|
-
// Calculate the base price with dynamic increases rolled in for public mode
|
|
2350
|
-
let displayBasePrice = rate.baseInDisplayCurrency ?? 0;
|
|
2351
|
-
if (isPublicMode && rate.appliedAdjustments) {
|
|
2352
|
-
// Add any dynamic pricing increases to the base price
|
|
2353
|
-
const dynamicIncreases = rate.appliedAdjustments
|
|
2354
|
-
.filter(adj => adj.type === 'dynamic' && (adj.changeByCurrency?.[currency] ?? 0) > 0)
|
|
2355
|
-
.reduce((sum, adj) => sum + (adj.changeByCurrency?.[currency] ?? 0), 0);
|
|
2356
|
-
displayBasePrice += dynamicIncreases;
|
|
2357
|
-
}
|
|
2358
|
-
|
|
2359
|
-
// Check if there's a net discount (final price < adjusted base price)
|
|
2360
|
-
// For public mode: compare against rolled-up base; for private: original base
|
|
2361
|
-
const hasDiscount = displayBasePrice > 0 && (rate.price ?? 0) < displayBasePrice;
|
|
2362
|
-
|
|
2363
|
-
return (
|
|
2364
|
-
<div
|
|
2365
|
-
key={rate.category}
|
|
2366
|
-
className="flex items-center justify-between p-4 bg-stone-50 rounded-lg"
|
|
2367
|
-
>
|
|
2368
|
-
<div>
|
|
2369
|
-
<p className="font-medium text-stone-900">
|
|
2370
|
-
{rate.category}
|
|
2371
|
-
{rate.category === 'CHILD' && (
|
|
2372
|
-
<span className="text-xs font-normal text-stone-500 ml-1">(2-10)</span>
|
|
2373
|
-
)}
|
|
2374
|
-
{rate.category === 'INFANT' && (
|
|
2375
|
-
<span className="text-xs font-normal text-stone-500 ml-1">(Under 2)</span>
|
|
2376
|
-
)}
|
|
2377
|
-
</p>
|
|
2378
|
-
<p className="text-sm text-stone-500">
|
|
2379
|
-
{hasDiscount ? (
|
|
2380
|
-
<>
|
|
2381
|
-
<span className="line-through text-stone-400">
|
|
2382
|
-
{formatCurrencyAmount(displayBasePrice, currency, locale)}
|
|
2383
|
-
</span>
|
|
2384
|
-
{' '}
|
|
2385
|
-
<span className="text-emerald-600 font-medium">
|
|
2386
|
-
{formatCurrencyAmount(rate.price || 0, currency, locale)}
|
|
2387
|
-
</span>
|
|
2388
|
-
</>
|
|
2389
|
-
) : (
|
|
2390
|
-
formatCurrencyAmount(rate.price || 0, currency, locale)
|
|
2391
|
-
)}
|
|
2392
|
-
</p>
|
|
2393
|
-
</div>
|
|
2394
|
-
<div className="flex items-center gap-3">
|
|
2395
|
-
<button
|
|
2396
|
-
onClick={() => handleQuantityChange(rate.category, -1)}
|
|
2397
|
-
disabled={(quantities[rate.category] || 0) <= 0}
|
|
2398
|
-
className="w-10 h-10 rounded-full bg-white border border-stone-300 text-stone-600 hover:bg-stone-100 flex items-center justify-center text-xl disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-stone-100 disabled:text-stone-400 disabled:border-stone-200"
|
|
2399
|
-
>
|
|
2400
|
-
−
|
|
2401
|
-
</button>
|
|
2402
|
-
<span className="w-8 text-center font-medium text-stone-900">
|
|
2403
|
-
{quantities[rate.category] || 0}
|
|
2404
|
-
</span>
|
|
2405
|
-
<button
|
|
2406
|
-
onClick={() => handleQuantityChange(rate.category, 1)}
|
|
2407
|
-
disabled={!isAdmin && totalQuantity >= (selectedAvailability?.vacancies ?? 0)}
|
|
2408
|
-
className={`w-10 h-10 rounded-full flex items-center justify-center text-xl disabled:opacity-50 disabled:cursor-not-allowed ${
|
|
2409
|
-
isAdmin && isOverbookedTickets
|
|
2410
|
-
? 'bg-red-100 border-2 border-red-500 text-red-600 hover:bg-red-200'
|
|
2411
|
-
: 'bg-white border border-stone-300 text-stone-600 hover:bg-stone-100 disabled:bg-stone-100 disabled:text-stone-400 disabled:border-stone-200'
|
|
2412
|
-
}`}
|
|
2413
|
-
>
|
|
2414
|
-
+
|
|
2415
|
-
</button>
|
|
2416
|
-
</div>
|
|
2417
|
-
</div>
|
|
2418
|
-
);
|
|
2419
|
-
})}
|
|
2420
|
-
</div>
|
|
2421
|
-
{isAdmin && selectedAvailability && (() => {
|
|
2422
|
-
// Use selected return's capacity when a return is selected (bottleneck); otherwise pickup availability capacity
|
|
2423
|
-
const pickupTotal = selectedAvailability.totalCapacity ?? 0;
|
|
2424
|
-
const returnTotal = selectedReturnOption?.totalCapacity ?? null;
|
|
2425
|
-
const effectiveCapacity = returnTotal != null ? returnTotal : pickupTotal;
|
|
2426
|
-
if (effectiveCapacity <= 0) return null;
|
|
2427
|
-
const currentBooked = returnTotal != null && selectedReturnOption
|
|
2428
|
-
? (selectedReturnOption.bookedCapacity ?? selectedReturnOption.totalCapacity - (selectedReturnOption.vacancies ?? 0))
|
|
2429
|
-
: (selectedAvailability.bookedCapacity ?? (pickupTotal - (selectedAvailability.vacancies ?? 0)));
|
|
2430
|
-
const newTotalBooked = currentBooked + totalQuantity;
|
|
2431
|
-
const isOverbooked = newTotalBooked > effectiveCapacity;
|
|
2432
|
-
if (!isOverbooked) return null;
|
|
2433
|
-
const resourceCount = returnTotal != null ? null : (selectedAvailability.resourceCount ?? null);
|
|
2434
|
-
return (
|
|
2435
|
-
<p className="text-base font-semibold text-red-600 mt-4 tabular-nums text-center" role="status">
|
|
2436
|
-
Overbooking {newTotalBooked} / {effectiveCapacity} capacity
|
|
2437
|
-
{resourceCount != null ? ` for ${resourceCount} ${resourceCount === 1 ? 'resource' : 'resources'}.` : '.'}
|
|
2438
|
-
</p>
|
|
2439
|
-
);
|
|
2440
|
-
})()}
|
|
2441
|
-
</div>
|
|
2442
|
-
)}
|
|
2443
|
-
|
|
2444
|
-
{/* Add-ons — optional extras for the selected product option */}
|
|
2445
|
-
{selectedAvailability && totalQuantity > 0 && addOns.length > 0 && (
|
|
2446
|
-
<div className="border-t border-stone-200 pt-6 space-y-4">
|
|
2447
|
-
<label className="block text-sm font-medium text-stone-700">
|
|
2448
|
-
Add-ons
|
|
2449
|
-
</label>
|
|
2450
|
-
{addOns.map((addOn) => {
|
|
2451
|
-
if (addOn.variantType === 'none') {
|
|
2452
|
-
const isSelected = addOnSelections.some((s) => s.addOnId === addOn.addOnId);
|
|
2453
|
-
return (
|
|
2454
|
-
<div key={addOn.addOnId}>
|
|
2455
|
-
<label className="block text-sm font-medium text-stone-700 mb-2">{addOn.name}</label>
|
|
2456
|
-
{addOn.description && (
|
|
2457
|
-
<p className="text-sm text-stone-500 mb-2">{addOn.description}</p>
|
|
2458
|
-
)}
|
|
2459
|
-
<button
|
|
2460
|
-
type="button"
|
|
2461
|
-
onClick={() => {
|
|
2462
|
-
setAddOnSelections((prev) => {
|
|
2463
|
-
if (isSelected) return prev.filter((s) => s.addOnId !== addOn.addOnId);
|
|
2464
|
-
return [...prev, { addOnId: addOn.addOnId, quantity: 1 }];
|
|
2465
|
-
});
|
|
2466
|
-
}}
|
|
2467
|
-
className={`flex items-center justify-between w-full p-4 rounded-lg border-2 text-left transition-colors ${
|
|
2468
|
-
isSelected ? 'border-emerald-500 bg-emerald-50' : 'border-stone-200 bg-white hover:border-stone-300'
|
|
2469
|
-
}`}
|
|
2470
|
-
>
|
|
2471
|
-
<span className="font-medium text-stone-900">
|
|
2472
|
-
{isSelected ? `Yes, add ${addOn.name}` : `No ${addOn.name}`}
|
|
2473
|
-
</span>
|
|
2474
|
-
<span className="text-sm font-semibold text-stone-700">
|
|
2475
|
-
+{formatCurrencyAmount(addOn.price ?? 0, currency, locale)}
|
|
2476
|
-
</span>
|
|
2477
|
-
</button>
|
|
2478
|
-
</div>
|
|
2479
|
-
);
|
|
2480
|
-
}
|
|
2481
|
-
if (addOn.variantType === 'multi_quantity' && addOn.variants?.length) {
|
|
2482
|
-
if (canUseMealDrinkSelector(addOn)) {
|
|
2483
|
-
return (
|
|
2484
|
-
<MealDrinkAddOnSelector
|
|
2485
|
-
key={addOn.addOnId}
|
|
2486
|
-
addOn={addOn}
|
|
2487
|
-
selections={addOnSelections}
|
|
2488
|
-
onSelectionsChange={setAddOnSelections}
|
|
2489
|
-
currency={currency}
|
|
2490
|
-
locale={locale}
|
|
2491
|
-
/>
|
|
2492
|
-
);
|
|
2493
|
-
}
|
|
2494
|
-
return (
|
|
2495
|
-
<div key={addOn.addOnId}>
|
|
2496
|
-
<label className="block text-sm font-medium text-stone-700 mb-2">{addOn.name}</label>
|
|
2497
|
-
{addOn.description && (
|
|
2498
|
-
<p className="text-sm text-stone-500 mb-2">{addOn.description}</p>
|
|
2499
|
-
)}
|
|
2500
|
-
<div className="space-y-2">
|
|
2501
|
-
{addOn.variants.map((variant) => {
|
|
2502
|
-
const sel = addOnSelections.find((s) => s.addOnId === addOn.addOnId && s.variantId === variant.id);
|
|
2503
|
-
const qty = sel?.quantity ?? 0;
|
|
2504
|
-
const price = (addOn.price ?? 0) + (variant.priceAdjustment ?? 0);
|
|
2505
|
-
return (
|
|
2506
|
-
<div key={variant.id} className="flex items-center justify-between p-3 bg-stone-50 rounded-lg">
|
|
2507
|
-
<span className="text-sm text-stone-800">{variant.label}</span>
|
|
2508
|
-
<div className="flex items-center gap-2">
|
|
2509
|
-
<button
|
|
2510
|
-
type="button"
|
|
2511
|
-
onClick={() => {
|
|
2512
|
-
setAddOnSelections((prev) => {
|
|
2513
|
-
const next = Math.max(0, qty - 1);
|
|
2514
|
-
const rest = prev.filter((s) => !(s.addOnId === addOn.addOnId && s.variantId === variant.id));
|
|
2515
|
-
if (next > 0) return [...rest, { addOnId: addOn.addOnId, variantId: variant.id, quantity: next }];
|
|
2516
|
-
return rest;
|
|
2517
|
-
});
|
|
2518
|
-
}}
|
|
2519
|
-
disabled={qty <= 0}
|
|
2520
|
-
className="h-8 w-8 rounded-full border border-stone-300 bg-white text-stone-600 hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
|
2521
|
-
>
|
|
2522
|
-
−
|
|
2523
|
-
</button>
|
|
2524
|
-
<span className="w-6 text-center font-medium tabular-nums text-sm">{qty}</span>
|
|
2525
|
-
<button
|
|
2526
|
-
type="button"
|
|
2527
|
-
onClick={() => {
|
|
2528
|
-
setAddOnSelections((prev) => {
|
|
2529
|
-
const rest = prev.filter((s) => !(s.addOnId === addOn.addOnId && s.variantId === variant.id));
|
|
2530
|
-
return [...rest, { addOnId: addOn.addOnId, variantId: variant.id, quantity: qty + 1 }];
|
|
2531
|
-
});
|
|
2532
|
-
}}
|
|
2533
|
-
className="h-8 w-8 rounded-full border border-stone-300 bg-white text-stone-600 hover:bg-stone-50 text-sm"
|
|
2534
|
-
>
|
|
2535
|
-
+
|
|
2536
|
-
</button>
|
|
2537
|
-
<span className="text-sm font-medium text-stone-700 w-16 text-right">
|
|
2538
|
-
{formatCurrencyAmount(price, currency, locale)} ea
|
|
2539
|
-
</span>
|
|
2540
|
-
</div>
|
|
2541
|
-
</div>
|
|
2542
|
-
);
|
|
2543
|
-
})}
|
|
2544
|
-
</div>
|
|
2545
|
-
</div>
|
|
2546
|
-
);
|
|
2547
|
-
}
|
|
2548
|
-
if (addOn.variantType === 'single_choice' && addOn.variants?.length) {
|
|
2549
|
-
const sel = addOnSelections.find((s) => s.addOnId === addOn.addOnId);
|
|
2550
|
-
return (
|
|
2551
|
-
<div key={addOn.addOnId}>
|
|
2552
|
-
<label className="block text-sm font-medium text-stone-700 mb-2">{addOn.name}</label>
|
|
2553
|
-
{addOn.description && (
|
|
2554
|
-
<p className="text-sm text-stone-500 mb-2">{addOn.description}</p>
|
|
2555
|
-
)}
|
|
2556
|
-
<div className="space-y-2">
|
|
2557
|
-
{addOn.variants.map((variant) => {
|
|
2558
|
-
const isSelected = sel?.variantId === variant.id;
|
|
2559
|
-
const price = (addOn.price ?? 0) + (variant.priceAdjustment ?? 0);
|
|
2560
|
-
return (
|
|
2561
|
-
<button
|
|
2562
|
-
key={variant.id}
|
|
2563
|
-
type="button"
|
|
2564
|
-
onClick={() => {
|
|
2565
|
-
setAddOnSelections((prev) => {
|
|
2566
|
-
const rest = prev.filter((s) => s.addOnId !== addOn.addOnId);
|
|
2567
|
-
if (isSelected) return rest;
|
|
2568
|
-
return [...rest, { addOnId: addOn.addOnId, variantId: variant.id, quantity: 1 }];
|
|
2569
|
-
});
|
|
2570
|
-
}}
|
|
2571
|
-
className={`flex items-center justify-between w-full p-3 rounded-lg border-2 text-left transition-colors ${
|
|
2572
|
-
isSelected ? 'border-emerald-500 bg-emerald-50' : 'border-stone-200 bg-white hover:border-stone-300'
|
|
2573
|
-
}`}
|
|
2574
|
-
>
|
|
2575
|
-
<span className="text-sm font-medium text-stone-800">{variant.label}</span>
|
|
2576
|
-
<span className="text-sm font-semibold text-stone-700">
|
|
2577
|
-
+{formatCurrencyAmount(price, currency, locale)}
|
|
2578
|
-
</span>
|
|
2579
|
-
</button>
|
|
2580
|
-
);
|
|
2581
|
-
})}
|
|
2582
|
-
</div>
|
|
2583
|
-
</div>
|
|
2584
|
-
);
|
|
2585
|
-
}
|
|
2586
|
-
return null;
|
|
2587
|
-
})}
|
|
2588
|
-
</div>
|
|
2589
|
-
)}
|
|
2590
|
-
|
|
2591
|
-
{/* Total and Checkout — shared PriceSummary component */}
|
|
2592
|
-
{selectedAvailability && (
|
|
2593
|
-
<div className="border-t border-stone-200 pt-6">
|
|
2594
|
-
<div className="mb-4">
|
|
2595
|
-
<PriceSummary
|
|
2596
|
-
lines={[
|
|
2597
|
-
...ticketLineItems.map((line): PriceSummaryLine => {
|
|
2598
|
-
const rate = pricing.find((r) => r.category === line.category);
|
|
2599
|
-
const breakdown = getPriceBreakdown(line.category, rate?.priceCAD ?? 0, rate?.baseInDisplayCurrency, rate?.appliedAdjustments ?? []);
|
|
2600
|
-
return {
|
|
2601
|
-
kind: 'ticket',
|
|
2602
|
-
category: line.category,
|
|
2603
|
-
qty: line.qty,
|
|
2604
|
-
itemTotal: line.itemTotal,
|
|
2605
|
-
breakdown,
|
|
2606
|
-
};
|
|
2607
|
-
}),
|
|
2608
|
-
...(selectedReturnOption && returnPriceAdjustment !== 0
|
|
2609
|
-
? [
|
|
2610
|
-
{
|
|
2611
|
-
kind: 'line' as const,
|
|
2612
|
-
label: `${t('booking.returnOption')} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`,
|
|
2613
|
-
amount: returnPriceAdjustment,
|
|
2614
|
-
type: 'return',
|
|
2615
|
-
},
|
|
2616
|
-
]
|
|
2617
|
-
: []),
|
|
2618
|
-
...(cancellationPolicyFee > 0 && selectedCancellationPolicy
|
|
2619
|
-
? [
|
|
2620
|
-
{
|
|
2621
|
-
kind: 'line' as const,
|
|
2622
|
-
label: selectedCancellationPolicy.label,
|
|
2623
|
-
amount: cancellationPolicyFee,
|
|
2624
|
-
type: 'cancellation',
|
|
2625
|
-
},
|
|
2626
|
-
]
|
|
2627
|
-
: []),
|
|
2628
|
-
...feeLineItemsWithAddOns.map((fee) => ({
|
|
2629
|
-
kind: 'line' as const,
|
|
2630
|
-
label: feeLineItems.some((f) => f.name === fee.name)
|
|
2631
|
-
? `${fee.name} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`
|
|
2632
|
-
: fee.name,
|
|
2633
|
-
amount: fee.totalAmount,
|
|
2634
|
-
type: 'fee',
|
|
2635
|
-
})),
|
|
2636
|
-
]}
|
|
2637
|
-
total={totalPrice}
|
|
2638
|
-
currency={currency}
|
|
2639
|
-
locale={locale}
|
|
2640
|
-
subtotal={subtotal !== totalFromSummary || promoDiscountAmount > 0 || addOnTotal > 0 ? effectiveSubtotal : undefined}
|
|
2641
|
-
taxAmount={!isTaxIncludedInPrice && (promoDiscountAmount > 0 ? effectiveTax : tax) > 0 ? (promoDiscountAmount > 0 ? effectiveTax : tax) : 0}
|
|
2642
|
-
taxRate={pricingConfig?.taxRate}
|
|
2643
|
-
size="sm"
|
|
2644
|
-
t={t}
|
|
2645
|
-
extraAfterTotal={isChangeMode && initialBooking?.originalTotalAmount != null && initialBooking.originalCurrency === currency ? (
|
|
2646
|
-
<div className="pt-3 mt-2 border-t border-stone-200 space-y-2">
|
|
2647
|
-
<div className="flex justify-between gap-3 min-w-0 text-sm">
|
|
2648
|
-
<span className="text-stone-600 min-w-0 truncate">Price change</span>
|
|
2649
|
-
<span className={`flex-shrink-0 whitespace-nowrap font-medium ${totalPrice - initialBooking.originalTotalAmount >= 0 ? 'text-stone-700' : 'text-red-600'}`}>
|
|
2650
|
-
{totalPrice - initialBooking.originalTotalAmount >= 0 ? '+' : ''}
|
|
2651
|
-
{formatCurrencyAmount(totalPrice - initialBooking.originalTotalAmount, currency, locale)}
|
|
2652
|
-
</span>
|
|
2653
|
-
</div>
|
|
2654
|
-
<div className="flex justify-between gap-3 min-w-0 text-xs text-stone-500">
|
|
2655
|
-
<span>Original: {formatCurrencyAmount(initialBooking.originalTotalAmount, currency, locale)}</span>
|
|
2656
|
-
<span>→ New: {formatCurrencyAmount(totalPrice, currency, locale)}</span>
|
|
2657
|
-
</div>
|
|
2658
|
-
{Math.abs(totalPrice - initialBooking.originalTotalAmount) > 0.01 && (
|
|
2659
|
-
<label className="flex items-center gap-2 text-sm text-stone-600 cursor-pointer">
|
|
2660
|
-
<input
|
|
2661
|
-
type="checkbox"
|
|
2662
|
-
checked={keepOriginalPrice}
|
|
2663
|
-
onChange={(e) => setKeepOriginalPrice(e.target.checked)}
|
|
2664
|
-
className="rounded border-stone-300 text-stone-700 focus:ring-stone-500"
|
|
2665
|
-
/>
|
|
2666
|
-
<span>Keep original price (no charge or refund)</span>
|
|
2667
|
-
</label>
|
|
2668
|
-
)}
|
|
2669
|
-
</div>
|
|
2670
|
-
) : undefined}
|
|
2671
|
-
extraBetweenTaxAndTotal={
|
|
2672
|
-
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 pt-2 border-t border-stone-200">
|
|
2673
|
-
<label htmlFor="booking-promo-code" className="flex-shrink-0 text-sm font-medium text-stone-500 whitespace-nowrap">
|
|
2674
|
-
{t('booking.optionalPromoCode') || 'Promo / voucher / gift card'}
|
|
2675
|
-
</label>
|
|
2676
|
-
<div className="flex-1 min-w-0 w-full sm:w-auto flex items-center gap-2 flex-wrap">
|
|
2677
|
-
<div className="relative flex-1 w-full sm:max-w-[200px] min-w-0 sm:min-w-[120px]">
|
|
2678
|
-
<input
|
|
2679
|
-
type="text"
|
|
2680
|
-
name="promoCode"
|
|
2681
|
-
id="booking-promo-code"
|
|
2682
|
-
value={promoCodeInput}
|
|
2683
|
-
onChange={(e) => {
|
|
2684
|
-
setPromoCodeInput(e.target.value.toUpperCase());
|
|
2685
|
-
setPromoCodeError('');
|
|
2686
|
-
}}
|
|
2687
|
-
onKeyDown={(e) => {
|
|
2688
|
-
if (e.key === 'Enter') {
|
|
2689
|
-
e.preventDefault();
|
|
2690
|
-
handleApplyPromo();
|
|
2691
|
-
}
|
|
2692
|
-
}}
|
|
2693
|
-
onPaste={() => {
|
|
2694
|
-
setTimeout(() => handleApplyPromoRef.current(), 50);
|
|
2695
|
-
}}
|
|
2696
|
-
placeholder={t('booking.promoCodePlaceholder')}
|
|
2697
|
-
autoComplete="off"
|
|
2698
|
-
readOnly={!!appliedPromoCode}
|
|
2699
|
-
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"
|
|
2700
|
-
/>
|
|
2701
|
-
{appliedPromoCode ? (
|
|
2702
|
-
<button
|
|
2703
|
-
type="button"
|
|
2704
|
-
onClick={() => {
|
|
2705
|
-
setAppliedPromoCode(null);
|
|
2706
|
-
setPromoCodeInput('');
|
|
2707
|
-
setPromoCodeError('');
|
|
2708
|
-
setForcedCancellationPolicyFromPromo(null);
|
|
2709
|
-
fetchedRangesRef.current = [];
|
|
2710
|
-
}}
|
|
2711
|
-
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"
|
|
2712
|
-
aria-label={t('booking.removePromo')}
|
|
2713
|
-
>
|
|
2714
|
-
<X className="w-4 h-4" strokeWidth={2.5} />
|
|
2715
|
-
</button>
|
|
2716
|
-
) : promoCodeValidating ? (
|
|
2717
|
-
<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 />
|
|
2718
|
-
) : promoCodeError ? (
|
|
2719
|
-
<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}>
|
|
2720
|
-
<X className="w-3 h-3" strokeWidth={3} />
|
|
2721
|
-
</span>
|
|
2722
|
-
) : null}
|
|
2723
|
-
</div>
|
|
2724
|
-
{appliedPromoCode && (
|
|
2725
|
-
<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 })}>
|
|
2726
|
-
<Check className="w-3.5 h-3.5" strokeWidth={3} />
|
|
2727
|
-
</span>
|
|
2728
|
-
)}
|
|
2729
|
-
{promoDiscountAmount > 0 && (
|
|
2730
|
-
<span className="text-sm font-medium text-red-600 whitespace-nowrap ml-auto">
|
|
2731
|
-
-{formatCurrencyAmount(promoDiscountAmount, currency, locale)}
|
|
2732
|
-
</span>
|
|
2733
|
-
)}
|
|
2734
|
-
{promoCodeError && (
|
|
2735
|
-
<span className="text-sm text-red-600 whitespace-nowrap ml-auto">{promoCodeError}</span>
|
|
2736
|
-
)}
|
|
2737
|
-
</div>
|
|
2738
|
-
{forcedCancellationPolicyFromPromo && (
|
|
2739
|
-
<p className="text-sm text-stone-600 mt-2 flex items-center gap-1.5">
|
|
2740
|
-
<span className="inline-flex items-center rounded bg-amber-50 px-2 py-0.5 text-amber-800 ring-1 ring-amber-200">
|
|
2741
|
-
{t('booking.promoRequiresCancellationPolicy', { policy: forcedCancellationPolicyFromPromo.label })}
|
|
2742
|
-
</span>
|
|
2743
|
-
</p>
|
|
2744
|
-
)}
|
|
2745
|
-
</div>
|
|
2746
|
-
}
|
|
2747
|
-
/>
|
|
2748
|
-
</div>
|
|
2749
|
-
|
|
2750
|
-
{/* Contact info (required for confirmation and manage booking lookup) - hidden in change mode */}
|
|
2751
|
-
{!isChangeMode && (
|
|
2752
|
-
<div className="mt-6 space-y-4">
|
|
2753
|
-
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
2754
|
-
<div>
|
|
2755
|
-
<label className="block text-sm font-medium text-stone-700 mb-2">
|
|
2756
|
-
{t('booking.firstName') || 'First Name'}
|
|
2757
|
-
</label>
|
|
2758
|
-
<input
|
|
2759
|
-
type="text"
|
|
2760
|
-
name="firstName"
|
|
2761
|
-
id="booking-firstName"
|
|
2762
|
-
value={firstName}
|
|
2763
|
-
onChange={(e) => {
|
|
2764
|
-
setFirstName(e.target.value);
|
|
2765
|
-
setError('');
|
|
2766
|
-
}}
|
|
2767
|
-
placeholder={t('booking.firstNamePlaceholder') || 'Jane'}
|
|
2768
|
-
autoComplete="given-name"
|
|
2769
|
-
className="w-full px-4 py-3 rounded-lg border border-stone-300 focus:outline-none focus:border-stone-500 text-stone-900"
|
|
2770
|
-
/>
|
|
2771
|
-
</div>
|
|
2772
|
-
<div>
|
|
2773
|
-
<label className="block text-sm font-medium text-stone-700 mb-2">
|
|
2774
|
-
{t('booking.lastName') || 'Last Name'} <span className="text-red-600">*</span>
|
|
2775
|
-
</label>
|
|
2776
|
-
<input
|
|
2777
|
-
type="text"
|
|
2778
|
-
name="lastName"
|
|
2779
|
-
id="booking-lastName"
|
|
2780
|
-
value={lastName}
|
|
2781
|
-
onChange={(e) => {
|
|
2782
|
-
setLastName(e.target.value);
|
|
2783
|
-
setError('');
|
|
2784
|
-
}}
|
|
2785
|
-
placeholder={t('booking.lastNamePlaceholder') || 'Smith'}
|
|
2786
|
-
autoComplete="family-name"
|
|
2787
|
-
required
|
|
2788
|
-
className="w-full px-4 py-3 rounded-lg border border-stone-300 focus:outline-none focus:border-stone-500 text-stone-900"
|
|
2789
|
-
/>
|
|
2790
|
-
</div>
|
|
2791
|
-
</div>
|
|
2792
|
-
<div>
|
|
2793
|
-
<label className="block text-sm font-medium text-stone-700 mb-2">
|
|
2794
|
-
{t('booking.emailForConfirmation')} <span className="text-red-600">*</span>
|
|
2795
|
-
</label>
|
|
2796
|
-
<input
|
|
2797
|
-
type="email"
|
|
2798
|
-
name="email"
|
|
2799
|
-
id="booking-email"
|
|
2800
|
-
value={email}
|
|
2801
|
-
onChange={(e) => {
|
|
2802
|
-
setEmail(e.target.value);
|
|
2803
|
-
setError('');
|
|
2804
|
-
}}
|
|
2805
|
-
placeholder={t('common.emailPlaceholder')}
|
|
2806
|
-
autoComplete="email"
|
|
2807
|
-
required
|
|
2808
|
-
className="w-full px-4 py-3 rounded-lg border border-stone-300 focus:outline-none focus:border-stone-500 text-stone-900"
|
|
2809
|
-
/>
|
|
2810
|
-
{email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && (
|
|
2811
|
-
<p className="text-xs text-red-600 mt-1.5">{t('booking.invalidEmail') || 'Please enter a valid email address'}</p>
|
|
2812
|
-
)}
|
|
2813
|
-
</div>
|
|
2814
|
-
</div>
|
|
2815
|
-
)}
|
|
2816
|
-
|
|
2817
|
-
{/* Pickup location - selecting it updates pickup time from range to exact time */}
|
|
2818
|
-
{selectedDate &&
|
|
2819
|
-
selectedAvailability &&
|
|
2820
|
-
product.pickupLocations &&
|
|
2821
|
-
product.pickupLocations.length > 0 && (
|
|
2822
|
-
<div id="pickup-location-section" className="border-t border-stone-200 pt-6 mt-6">
|
|
2823
|
-
{pickupLocationId || pickupLocationSkipped ? (
|
|
2824
|
-
<div className="space-y-4">
|
|
2825
|
-
<div className="flex items-center justify-between">
|
|
2826
|
-
<div>
|
|
2827
|
-
<label className="block text-sm font-medium text-stone-700 mb-1">
|
|
2828
|
-
{t('pickup.pickupLocation')}
|
|
2829
|
-
</label>
|
|
2830
|
-
{pickupLocationSkipped ? (
|
|
2831
|
-
<p className="text-sm text-stone-900">
|
|
2832
|
-
{t('booking.pickupLocationUnknown')}
|
|
2833
|
-
</p>
|
|
2834
|
-
) : (
|
|
2835
|
-
<>
|
|
2836
|
-
<p className="text-sm text-stone-900">
|
|
2837
|
-
{selectedPickupLocation?.name}
|
|
2838
|
-
</p>
|
|
2839
|
-
<p className="text-xs text-stone-500">
|
|
2840
|
-
{selectedPickupLocation?.address}
|
|
2841
|
-
</p>
|
|
2842
|
-
{selectedPickupLocation?.notes && (
|
|
2843
|
-
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded p-2 mt-2">
|
|
2844
|
-
{selectedPickupLocation.notes}
|
|
2845
|
-
</p>
|
|
2846
|
-
)}
|
|
2847
|
-
</>
|
|
2848
|
-
)}
|
|
2849
|
-
</div>
|
|
2850
|
-
<button
|
|
2851
|
-
onClick={() => {
|
|
2852
|
-
setPickupLocationId(null);
|
|
2853
|
-
setPickupLocationSkipped(false);
|
|
2854
|
-
// Keep selectedAvailability — pickup time reverts to range until they pick a location again
|
|
2855
|
-
}}
|
|
2856
|
-
className="text-sm text-emerald-600 hover:text-emerald-700 underline"
|
|
2857
|
-
>
|
|
2858
|
-
{t('common.change')}
|
|
2859
|
-
</button>
|
|
2860
|
-
</div>
|
|
2861
|
-
</div>
|
|
2862
|
-
) : (
|
|
2863
|
-
<PickupLocationSelector
|
|
2864
|
-
pickupLocations={product.pickupLocations}
|
|
2865
|
-
selectedLocationId={pickupLocationId}
|
|
2866
|
-
isSkipped={pickupLocationSkipped}
|
|
2867
|
-
destinations={product.destinations}
|
|
2868
|
-
onLocationSelect={(locationId) => {
|
|
2869
|
-
setPickupLocationId(locationId);
|
|
2870
|
-
setError('');
|
|
2871
|
-
if (locationId === null && pickupLocationSkipped) {
|
|
2872
|
-
setPickupLocationSkipped(false);
|
|
2873
|
-
} else if (locationId !== null) {
|
|
2874
|
-
setPickupLocationSkipped(false);
|
|
2875
|
-
}
|
|
2876
|
-
// Keep selectedAvailability — selecting a location only updates pickup time from range to exact
|
|
2877
|
-
}}
|
|
2878
|
-
onSkip={() => {
|
|
2879
|
-
setPickupLocationSkipped(true);
|
|
2880
|
-
setPickupLocationId(null);
|
|
2881
|
-
setError('');
|
|
2882
|
-
// Keep selectedAvailability — skipping only keeps time as range
|
|
2883
|
-
}}
|
|
2884
|
-
/>
|
|
2885
|
-
)}
|
|
2886
|
-
</div>
|
|
2887
|
-
)}
|
|
2888
|
-
|
|
2889
|
-
{/* Error Message */}
|
|
2890
|
-
{error && (
|
|
2891
|
-
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 mt-4">
|
|
2892
|
-
{error}
|
|
2893
|
-
</div>
|
|
2894
|
-
)}
|
|
2895
|
-
|
|
2896
|
-
{/* Admin only: communication options (provider dashboard) */}
|
|
2897
|
-
{isAdmin && (
|
|
2898
|
-
<div className="mt-4 p-4 bg-amber-50/50 border border-amber-200 rounded-lg space-y-3">
|
|
2899
|
-
<p className="text-sm font-medium text-stone-700">Communications (admin)</p>
|
|
2900
|
-
<label className="flex items-start gap-2 cursor-pointer">
|
|
2901
|
-
<input
|
|
2902
|
-
type="checkbox"
|
|
2903
|
-
checked={skipConfirmationCommunications}
|
|
2904
|
-
onChange={(e) => setSkipConfirmationCommunications(e.target.checked)}
|
|
2905
|
-
className="mt-1 h-4 w-4 rounded border-stone-300"
|
|
2906
|
-
/>
|
|
2907
|
-
<span className="text-sm text-stone-600">Don't send confirmation email/SMS/WhatsApp for this booking (you can send manually later). Cancellation and reminder emails still send.</span>
|
|
2908
|
-
</label>
|
|
2909
|
-
<label className="flex items-start gap-2 cursor-pointer">
|
|
2910
|
-
<input
|
|
2911
|
-
type="checkbox"
|
|
2912
|
-
checked={disableAutoCommunications}
|
|
2913
|
-
onChange={(e) => setDisableAutoCommunications(e.target.checked)}
|
|
2914
|
-
className="mt-1 h-4 w-4 rounded border-stone-300"
|
|
2915
|
-
/>
|
|
2916
|
-
<span className="text-sm text-stone-600">Disable all auto communications for this booking (no confirmation, cancellation, or reminders). Can be changed later on the booking.</span>
|
|
2917
|
-
</label>
|
|
2918
|
-
</div>
|
|
2919
|
-
)}
|
|
2920
|
-
|
|
2921
|
-
{/* Terms & Conditions acceptance — required before Continue to Payment (skip in change mode) */}
|
|
2922
|
-
{!isChangeMode && (
|
|
2923
|
-
<div className="mt-4 p-4 bg-stone-50 rounded-lg border border-stone-200">
|
|
2924
|
-
<TermsAcceptance
|
|
2925
|
-
checked={termsAccepted}
|
|
2926
|
-
onChange={(checked) => {
|
|
2927
|
-
setTermsAccepted(checked);
|
|
2928
|
-
setTermsAcceptedAt(checked ? new Date().toISOString() : null);
|
|
2929
|
-
}}
|
|
2930
|
-
t={t}
|
|
2931
|
-
/>
|
|
2932
|
-
</div>
|
|
2933
|
-
)}
|
|
2934
|
-
|
|
2935
|
-
<button
|
|
2936
|
-
onClick={handleCheckout}
|
|
2937
|
-
disabled={loading || totalQuantity === 0 || (!isChangeMode && !termsAccepted)}
|
|
2938
|
-
className="w-full py-4 bg-emerald-600 text-white font-semibold rounded-lg hover:bg-emerald-700 disabled:bg-stone-300 disabled:cursor-not-allowed transition-colors mt-4"
|
|
2939
|
-
>
|
|
2940
|
-
{loading ? (isChangeMode ? 'Changing…' : t('booking.creatingReservation')) : isChangeMode ? 'Change booking' : t('booking.continueToPayment')}
|
|
2941
|
-
</button>
|
|
2942
|
-
<p className="text-center text-sm text-stone-500 mt-4">
|
|
2943
|
-
{t('booking.securePayment')}
|
|
2944
|
-
</p>
|
|
2945
|
-
</div>
|
|
2946
|
-
)}
|
|
2947
|
-
</>
|
|
2948
|
-
)}
|
|
2949
|
-
</div>
|
|
2950
|
-
);
|
|
2951
|
-
}
|
|
2952
|
-
|