@ticketboothapp/booking 0.1.11 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/app/photo-sessions/photo-packages.ts +75 -0
- package/src/assets/icons/partner-logos/getyourguide.svg +8 -0
- package/src/assets/icons/plus.svg +3 -0
- package/src/colours.css +23 -0
- package/src/components/BookingDetails.module.css +1591 -0
- package/src/components/BookingDetails.tsx +2072 -354
- package/src/components/BookingWidget.tsx +28 -248
- package/src/components/JobApplicationDialog.module.css +440 -0
- package/src/components/JobApplicationDialog.tsx +620 -0
- package/src/components/ManageBookingView.tsx +28 -36
- package/src/components/PhoneInputWithCountry.module.css +131 -0
- package/src/components/PhoneInputWithCountry.tsx +44 -0
- package/src/components/PickupLocationDialog.module.css +360 -0
- package/src/components/PickupLocationDialog.tsx +357 -0
- package/src/components/PickupLocationMap.tsx +110 -0
- package/src/components/PostBookingDependentAddOnUpsell.module.css +174 -0
- package/src/components/PostBookingDependentAddOnUpsell.tsx +407 -0
- package/src/components/accordion.css +27 -0
- package/src/components/accordion.tsx +29 -0
- package/src/components/analytics/AnalyticsConsentRestore.tsx +19 -0
- package/src/components/analytics/AnalyticsScripts.tsx +106 -0
- package/src/components/analytics/CookieConsentBanner.css +86 -0
- package/src/components/analytics/CookieConsentBanner.tsx +102 -0
- package/src/components/booking/AddOnsSection.module.css +10 -0
- package/src/components/booking/AddOnsSection.tsx +184 -0
- package/src/components/booking/AdminPaymentChoiceModal.tsx +98 -0
- package/src/components/booking/BookingDialog.module.css +643 -0
- package/src/components/booking/BookingDialog.tsx +356 -0
- package/src/components/booking/BookingFlow.tsx +4385 -0
- package/src/components/booking/BookingFlowCollage.module.css +148 -0
- package/src/components/booking/BookingFlowCollage.tsx +184 -0
- package/src/components/booking/BookingFlowPlaceholder.module.css +27 -0
- package/src/components/booking/BookingFlowPlaceholder.tsx +25 -0
- package/src/components/booking/BookingFlowPreview.tsx +51 -0
- package/src/components/booking/BookingProductGrid.module.css +359 -0
- package/src/components/booking/BookingProductGrid.tsx +497 -0
- package/src/components/booking/Calendar.module.css +616 -0
- package/src/components/{Calendar.tsx → booking/Calendar.tsx} +464 -247
- package/src/components/booking/CancellationPolicySelector.module.css +124 -0
- package/src/components/booking/CancellationPolicySelector.tsx +142 -0
- package/src/components/booking/ChangeBookingDialog.tsx +562 -0
- package/src/components/booking/CheckoutForm.module.css +244 -0
- package/src/components/booking/CheckoutForm.tsx +364 -0
- package/src/components/{CheckoutModal.tsx → booking/CheckoutModal.tsx} +176 -19
- package/src/components/booking/DapFlowCollage.tsx +88 -0
- package/src/components/booking/DapTourDescription.tsx +35 -0
- package/src/components/booking/DependentAddOnBookingDialog.tsx +1350 -0
- package/src/components/booking/DependentAddOnPaymentForm.tsx +124 -0
- package/src/components/booking/InfoTooltip.tsx +108 -0
- package/src/components/booking/ItineraryBox.module.css +258 -0
- package/src/components/booking/ItineraryBox.tsx +550 -0
- package/src/components/{ItineraryBuilder.tsx → booking/ItineraryBuilder.tsx} +1 -2
- package/src/components/booking/ItineraryPlaceholder.module.css +45 -0
- package/src/components/booking/ItineraryPlaceholder.tsx +26 -0
- package/src/components/{MealDrinkAddOnSelector.tsx → booking/MealDrinkAddOnSelector.tsx} +21 -13
- package/src/components/booking/PickupLocationSelector.module.css +124 -0
- package/src/components/{PickupLocationSelector.tsx → booking/PickupLocationSelector.tsx} +315 -290
- package/src/components/booking/PickupTimeSelector.module.css +134 -0
- package/src/components/booking/PickupTimeSelector.tsx +112 -0
- package/src/components/{PriceBreakdown.tsx → booking/PriceBreakdown.tsx} +3 -3
- package/src/components/{PriceSummary.tsx → booking/PriceSummary.tsx} +51 -28
- package/src/components/booking/PrivateShuttleBookingFlow.module.css +357 -0
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +2662 -0
- package/src/components/booking/PromoCodeInput.module.css +166 -0
- package/src/components/booking/PromoCodeInput.tsx +99 -0
- package/src/components/booking/ReturnTimeSelector.module.css +173 -0
- package/src/components/booking/ReturnTimeSelector.tsx +145 -0
- package/src/components/{TermsAcceptance.tsx → booking/TermsAcceptance.tsx} +9 -8
- package/src/components/booking/TicketSelector.module.css +164 -0
- package/src/components/booking/TicketSelector.tsx +199 -0
- package/src/components/booking/TourDescription.module.css +304 -0
- package/src/components/booking/TourDescription.tsx +273 -0
- package/src/components/booking/booking-flow-ui.ts +15 -1
- package/src/components/booking/booking-flow.css +944 -0
- package/src/components/bottom-sheet.module.css +78 -0
- package/src/components/bottom-sheet.tsx +60 -0
- package/src/components/breadcrumb.module.css +40 -0
- package/src/components/breadcrumb.tsx +36 -0
- package/src/components/button.css +245 -0
- package/src/components/button.tsx +152 -0
- package/src/components/client-bottom-sheet.tsx +14 -0
- package/src/components/colorable-svg.tsx +29 -0
- package/src/components/conditional-footer.tsx +27 -0
- package/src/components/contact-us.module.css +147 -0
- package/src/components/contact-us.tsx +49 -0
- package/src/components/email-signup.css +151 -0
- package/src/components/email-signup.tsx +63 -0
- package/src/components/faq-wrapper.module.css +47 -0
- package/src/components/faq-wrapper.tsx +15 -0
- package/src/components/footer.css +187 -0
- package/src/components/footer.tsx +143 -0
- package/src/components/global-simple-modal.tsx +33 -0
- package/src/components/google-review-summary.module.css +77 -0
- package/src/components/google-review-summary.tsx +50 -0
- package/src/components/hero-image.css +13 -0
- package/src/components/hero-image.tsx +44 -0
- package/src/components/image.css +29 -0
- package/src/components/image.tsx +113 -0
- package/src/components/language-aware-link.tsx +72 -0
- package/src/components/language-switcher.module.css +124 -0
- package/src/components/language-switcher.tsx +75 -0
- package/src/components/map-section.css +59 -0
- package/src/components/map-section.tsx +63 -0
- package/src/components/navbar.module.css +152 -0
- package/src/components/navbar.tsx +125 -0
- package/src/components/parallax-provider.tsx +11 -0
- package/src/components/partner/PartnerBookingPage.module.css +130 -0
- package/src/components/partner/PartnerBookingPage.tsx +390 -0
- package/src/components/partner/PartnerBookingPageWithBrowserMetadata.tsx +19 -35
- package/src/components/product-tag.module.css +30 -0
- package/src/components/product-tag.tsx +34 -0
- package/src/components/product-theme-pages/best-option.module.css +70 -0
- package/src/components/product-theme-pages/best-option.tsx +35 -0
- package/src/components/product-theme-pages/extended-tour-options.module.css +22 -0
- package/src/components/product-theme-pages/extended-tour-options.tsx +11 -0
- package/src/components/product-theme-pages/image-modal.tsx +248 -0
- package/src/components/product-theme-pages/photo-gallery.module.css +200 -0
- package/src/components/product-theme-pages/photo-gallery.tsx +90 -0
- package/src/components/product-theme-pages/product-theme-page-layout.module.css +13 -0
- package/src/components/product-theme-pages/product-theme-page-layout.tsx +67 -0
- package/src/components/product-theme-pages/top-of-fold.module.css +179 -0
- package/src/components/product-theme-pages/top-of-fold.tsx +80 -0
- package/src/components/product-tile/image-only-product-tile-desktop.module.css +106 -0
- package/src/components/product-tile/image-only-product-tile-desktop.tsx +56 -0
- package/src/components/product-tile/image-only-product-tile-mobile.module.css +122 -0
- package/src/components/product-tile/image-only-product-tile-mobile.tsx +89 -0
- package/src/components/product-tile/image-only-product-tile.tsx +44 -0
- package/src/components/product-tile/product-tile-card.module.css +84 -0
- package/src/components/product-tile/product-tile-card.tsx +61 -0
- package/src/components/review-highlights-section.css +85 -0
- package/src/components/review-highlights-section.tsx +127 -0
- package/src/components/season-closure-overlay.module.css +99 -0
- package/src/components/season-closure-overlay.tsx +98 -0
- package/src/components/simple-modal.tsx +69 -0
- package/src/components/simple-top-of-fold.module.css +76 -0
- package/src/components/simple-top-of-fold.tsx +34 -0
- package/src/components/spacer.css +41 -0
- package/src/components/spacer.tsx +23 -0
- package/src/components/star-rating.module.css +74 -0
- package/src/components/star-rating.tsx +48 -0
- package/src/components/terms/TermsContent.tsx +178 -0
- package/src/components/title-subtitle.module.css +10 -0
- package/src/components/title-subtitle.tsx +30 -0
- package/src/components/translatable-reviews.tsx +75 -0
- package/src/components/value-pill.module.css +59 -0
- package/src/components/value-pill.tsx +46 -0
- package/src/components/value-props.css +185 -0
- package/src/components/value-props.tsx +88 -0
- package/src/constants/booking-guide-quiz.ts +64 -0
- package/src/constants/contact-info.ts +2 -0
- package/src/constants/faq.ts +44 -0
- package/src/constants/images.ts +556 -0
- package/src/constants/json-ld/faq-json-ld.tsx +170 -0
- package/src/constants/json-ld/homepage-json-ld.tsx +138 -0
- package/src/constants/json-ld/job-posting-json-ld.tsx +92 -0
- package/src/constants/json-ld/organization-json-ld.tsx +62 -0
- package/src/constants/json-ld/page-json-ld.tsx +6 -0
- package/src/constants/json-ld/product-json-ld.tsx +154 -0
- package/src/constants/json-ld/review-json-ld.tsx +377 -0
- package/src/constants/navigation-links/footer-links.ts +48 -0
- package/src/constants/navigation-links/nav-bar-links.ts +41 -0
- package/src/constants/navigation-links/navigation-link.ts +6 -0
- package/src/constants/pill-values.ts +210 -0
- package/src/constants/products.ts +155 -0
- package/src/constants/quiz-recommendations.ts +506 -0
- package/src/constants/reviews.ts +75 -0
- package/src/constants/staff.ts +197 -0
- package/src/constants/value-props.ts +58 -0
- package/src/data/dap-descriptions/session-couples-families-friends.en.json +61 -0
- package/src/data/dap-descriptions/session-elopements.en.json +60 -0
- package/src/data/dap-descriptions/session-proposals.en.json +60 -0
- package/src/data/product-descriptions/afternoon-delight.en.json +35 -0
- package/src/data/product-descriptions/emerald-lake-escape.en.json +68 -0
- package/src/data/product-descriptions/lake-louise-adventure.en.json +74 -0
- package/src/data/product-descriptions/moraine-lake-adventure.en.json +78 -0
- package/src/data/product-descriptions/moraine-lake-sunrise-lake-louise-golden-hour.en.json +65 -0
- package/src/data/product-descriptions/moraine-lake-sunrise.en.json +64 -0
- package/src/data/product-descriptions/private-tour.en.json +80 -0
- package/src/data/product-descriptions/two-lakes-combo.en.json +65 -0
- package/src/data/products-config.json +101 -0
- package/src/hooks/use-bottom-sheet.tsx +15 -0
- package/src/hooks/use-simple-modal.tsx +27 -0
- package/src/hooks/useBookingSourceMetadataFromLocation.ts +21 -0
- package/src/hooks/useEmailSubscription.tsx +103 -0
- package/src/hooks/useEmbeddedInIframe.ts +16 -0
- package/src/hooks/useIsBookingLaunchLive.ts +49 -0
- package/src/hooks/useQuiz.tsx +210 -0
- package/src/index.ts +27 -2
- package/src/lib/analytics.ts +197 -0
- package/src/lib/booking/booking-source.ts +20 -2
- package/src/lib/{checkout-breakdown.ts → booking/checkout-breakdown.ts} +1 -1
- package/src/lib/booking/correlation-id.ts +46 -0
- package/src/lib/{i18n → booking/i18n}/messages/en.json +48 -4
- package/src/lib/{i18n → booking/i18n}/messages/fr.json +48 -4
- package/src/lib/booking/itinerary-display.ts +36 -0
- package/src/lib/{itinerary-labels.ts → booking/itinerary-labels.ts} +1 -1
- package/src/lib/{location-calculations.ts → booking/location-calculations.ts} +4 -4
- package/src/lib/{location-utils.ts → booking/location-utils.ts} +26 -0
- package/src/lib/{map-utils.ts → booking/map-utils.ts} +3 -3
- package/src/lib/booking/normalize-booking-product-id.ts +7 -0
- package/src/lib/{pickup-location-types.ts → booking/pickup-location-types.ts} +2 -2
- package/src/lib/{pricing.ts → booking/pricing.ts} +2 -2
- package/src/lib/booking/product-option-id.ts +35 -0
- package/src/lib/booking/source-metadata.ts +72 -7
- package/src/lib/booking/sunday-week.ts +14 -0
- package/src/lib/booking/trace-context.ts +62 -0
- package/src/lib/booking-api.ts +1793 -0
- package/src/lib/{constants.ts → booking-constants.ts} +11 -5
- package/src/lib/booking-types.ts +36 -0
- package/src/lib/currency.ts +38 -45
- package/src/lib/dap-descriptions.ts +50 -0
- package/src/lib/dap-itinerary-preview.ts +315 -0
- package/src/lib/dependent-add-on-api.ts +434 -0
- package/src/lib/env.ts +89 -5
- package/src/lib/firebase.ts +20 -0
- package/src/lib/job-application-api.ts +83 -0
- package/src/lib/manage-booking-embed-print.ts +16 -0
- package/src/lib/manage-booking-post-checkout.ts +68 -0
- package/src/lib/photo-dap-config.ts +228 -0
- package/src/lib/pickup/map-utils.ts +56 -0
- package/src/lib/pickup/marker-icons.ts +19 -0
- package/src/lib/product-descriptions.ts +66 -0
- package/src/lib/products-config.ts +73 -0
- package/src/providers/booking-dialog-provider.tsx +107 -38
- package/src/providers/bottom-sheet-provider.tsx +40 -0
- package/src/providers/dependent-add-on-dialog-provider.tsx +105 -0
- package/src/radius.css +5 -0
- package/src/spacing.css +7 -0
- package/src/strings/en.json +1774 -0
- package/src/strings/es.json +1573 -0
- package/src/strings/fr.json +1573 -0
- package/src/strings/index.js +23 -0
- package/src/text-style.css +97 -0
- package/src/types/fareharbor.d.ts +12 -0
- package/src/types/quiz.ts +59 -0
- package/src/utils/currency-converter.ts +101 -0
- package/src/components/BookingFlow.tsx +0 -2952
- package/src/components/LanguageSwitcher.tsx +0 -30
- package/src/components/PrivateShuttleBookingFlow.tsx +0 -2290
- package/src/components/ProductList.tsx +0 -78
- package/src/components/WhatsAppPhoneInput.tsx +0 -224
- package/src/components/index.ts +0 -31
- package/src/lib/api.ts +0 -801
- package/src/lib/booking-api-auth.ts +0 -9
- package/src/lib/checkout-breakdown.test.ts +0 -70
- package/src/types/google-maps.d.ts +0 -2
- /package/src/components/{CurrencySwitcher.tsx → booking/CurrencySwitcher.tsx} +0 -0
- /package/src/components/{ErrorBoundary.tsx → booking/ErrorBoundary.tsx} +0 -0
- /package/src/lib/{i18n → booking/i18n}/config.ts +0 -0
- /package/src/lib/{i18n → booking/i18n}/index.tsx +0 -0
- /package/src/lib/{marker-icons.ts → booking/marker-icons.ts} +0 -0
- /package/src/lib/{places-api.ts → booking/places-api.ts} +0 -0
- /package/src/lib/{theme.ts → booking/theme.ts} +0 -0
- /package/src/lib/{utils.ts → booking/utils.ts} +0 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
4
|
+
import { db } from '@/lib/firebase';
|
|
5
|
+
import { collection, addDoc, query, where, getDocs } from 'firebase/firestore';
|
|
6
|
+
|
|
7
|
+
interface UseEmailSubscriptionProps {
|
|
8
|
+
initialEmail?: string;
|
|
9
|
+
autoSubmit?: boolean;
|
|
10
|
+
source?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function useEmailSubscription({ initialEmail = '', autoSubmit = false, source }: UseEmailSubscriptionProps) {
|
|
14
|
+
const [email, setEmail] = useState(initialEmail);
|
|
15
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
16
|
+
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error' | 'duplicate'>('idle');
|
|
17
|
+
|
|
18
|
+
const subscribeEmail = useCallback(async (emailToSubscribe: string) => {
|
|
19
|
+
if (!emailToSubscribe || isSubmitting) return false;
|
|
20
|
+
|
|
21
|
+
setIsSubmitting(true);
|
|
22
|
+
setSubmitStatus('idle');
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
// Check if email already exists
|
|
26
|
+
const emailQuery = query(
|
|
27
|
+
collection(db, 'mail'),
|
|
28
|
+
where('to', '==', emailToSubscribe)
|
|
29
|
+
);
|
|
30
|
+
const existingEmails = await getDocs(emailQuery);
|
|
31
|
+
|
|
32
|
+
if (!existingEmails.empty) {
|
|
33
|
+
setSubmitStatus('duplicate');
|
|
34
|
+
setIsSubmitting(false);
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Add email to Firestore (this will trigger your email function)
|
|
39
|
+
await addDoc(collection(db, 'mail'), {
|
|
40
|
+
to: emailToSubscribe,
|
|
41
|
+
message: {
|
|
42
|
+
subject: 'Via Via Moraine Lake Shuttle - 2026 Season Updates',
|
|
43
|
+
text: `Hi there! Thank you for subscribing to our updates! We're excited to keep you informed about the 2026 season at Moraine Lake, Lake Louise, and Emerald Lake. Moraine Lake Road is currently closed as Parks Canada closes the access road every year for the winter season. We'll get back to you with updates about 2026 ticket release dates and special offers as soon as we have more information. Best regards, The Via Via Team`,
|
|
44
|
+
html: `
|
|
45
|
+
<div style="font-family: Figtree, sans-serif; max-width: 700px; margin: 0 auto; padding: 20px;">
|
|
46
|
+
<h2 style="color: #ff4d00; text-align: center; margin-bottom: 20px;">Thanks for signing up!</h2>
|
|
47
|
+
|
|
48
|
+
<p style="color: inherit;">Hi there,</p>
|
|
49
|
+
<p style="color: inherit;">Thank you for subscribing to our updates! We're excited to keep you informed about the 2026 season at Moraine Lake, Lake Louise, and Emerald Lake.</p>
|
|
50
|
+
|
|
51
|
+
<p style="color: inherit;">Moraine Lake Road is currently closed as Parks Canada closes the access road every year for the winter season.</p>
|
|
52
|
+
|
|
53
|
+
<p style="color: inherit;">We'll get back to you with updates about 2026 ticket release dates and special offers as soon as we have more information.</p>
|
|
54
|
+
|
|
55
|
+
<p style="color: inherit;">In the meantime, feel free to follow us on <a href="https://www.instagram.com/viaviamorainelake/" style="color: #ff4d00; -webkit-text-size-adjust: 100%;">Instagram</a> for the latest news and beautiful photos from Banff National Park.</p>
|
|
56
|
+
|
|
57
|
+
<p style="color: inherit;">Best regards,<br>
|
|
58
|
+
The Via Via Team 🌄🚐</p>
|
|
59
|
+
|
|
60
|
+
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
|
61
|
+
|
|
62
|
+
<div style="text-align: center; margin-bottom: 30px;">
|
|
63
|
+
<img src="https://viaviamorainelake.b-cdn.net/logo192.png?format=png&width=200" alt="Via Via Moraine Lake Shuttle" style="max-width: 200px; height: auto;">
|
|
64
|
+
</div>
|
|
65
|
+
<p style="font-size: 12px; color: #666; text-align: center;">
|
|
66
|
+
Via Via Moraine Lake Shuttle • Canmore, Alberta<br>
|
|
67
|
+
<a href="https://viaviamorainelake.com/?utm_source=email&utm_medium=sign-up-email&utm_campaign=2026-updates-signup-confirmation" style="color: #ff4d00;">viaviamorainelake.com</a><br>
|
|
68
|
+
<a href="https://viaviamorainelake.com/unsubscribe?email=${emailToSubscribe}" style="color: #ff4d00; font-size: 11px;">Unsubscribe</a>
|
|
69
|
+
</p>
|
|
70
|
+
</div>
|
|
71
|
+
`
|
|
72
|
+
},
|
|
73
|
+
createdAt: new Date(),
|
|
74
|
+
source: source || 'unknown'
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
setSubmitStatus('success');
|
|
78
|
+
setEmail('');
|
|
79
|
+
return true;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
console.error('Email signup error:', error);
|
|
82
|
+
setSubmitStatus('error');
|
|
83
|
+
return false;
|
|
84
|
+
} finally {
|
|
85
|
+
setIsSubmitting(false);
|
|
86
|
+
}
|
|
87
|
+
}, [isSubmitting, source]);
|
|
88
|
+
|
|
89
|
+
// Auto-submit if requested and email is provided
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (autoSubmit && initialEmail && submitStatus === 'idle') {
|
|
92
|
+
subscribeEmail(initialEmail);
|
|
93
|
+
}
|
|
94
|
+
}, [autoSubmit, initialEmail, submitStatus, subscribeEmail]);
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
email,
|
|
98
|
+
setEmail,
|
|
99
|
+
isSubmitting,
|
|
100
|
+
submitStatus,
|
|
101
|
+
subscribeEmail
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useLayoutEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
/** True when this document is loaded inside an iframe (e.g. partner portal manage-booking embed). */
|
|
6
|
+
export function useEmbeddedInIframe(): boolean {
|
|
7
|
+
const [embedded, setEmbedded] = useState(() => {
|
|
8
|
+
if (typeof window === 'undefined') return false;
|
|
9
|
+
return window.parent !== window.self;
|
|
10
|
+
});
|
|
11
|
+
useLayoutEffect(() => {
|
|
12
|
+
if (typeof window === 'undefined') return;
|
|
13
|
+
setEmbedded(window.parent !== window.self);
|
|
14
|
+
}, []);
|
|
15
|
+
return embedded;
|
|
16
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import { BOOKING_LAUNCH_AT } from '@/lib/booking-constants';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Returns whether the full booking flow is live (past launch time).
|
|
8
|
+
* Before launch: shows partial flow (collage + tour description).
|
|
9
|
+
* After launch: shows full flow (calendar, checkout, etc.).
|
|
10
|
+
*
|
|
11
|
+
* Schedules a state update at launch time so users on the site at 8am
|
|
12
|
+
* see the full flow without refreshing.
|
|
13
|
+
*
|
|
14
|
+
* Dev override: ?booking_preview=full or ?booking_preview=partial in the URL
|
|
15
|
+
* forces the flow for local testing (e.g. localhost:3000/?booking_preview=partial).
|
|
16
|
+
*/
|
|
17
|
+
export function useIsBookingLaunchLive(): boolean {
|
|
18
|
+
const [isLive, setIsLive] = useState(false);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
// Dev override for local testing
|
|
22
|
+
if (typeof window !== 'undefined') {
|
|
23
|
+
const params = new URLSearchParams(window.location.search);
|
|
24
|
+
const preview = params.get('booking_preview');
|
|
25
|
+
if (preview === 'full') {
|
|
26
|
+
setIsLive(true);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (preview === 'partial') {
|
|
30
|
+
setIsLive(false);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const launchMs = BOOKING_LAUNCH_AT.getTime();
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
|
|
38
|
+
if (now >= launchMs) {
|
|
39
|
+
setIsLive(true);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const msUntil = launchMs - now;
|
|
44
|
+
const id = setTimeout(() => setIsLive(true), msUntil);
|
|
45
|
+
return () => clearTimeout(id);
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
return isLive;
|
|
49
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useCallback, useState, ReactNode, Dispatch, SetStateAction } from 'react';
|
|
4
|
+
import { QuizState, UserAnswers } from '@/types/quiz';
|
|
5
|
+
import { QUIZ_QUESTIONS } from '@/constants/booking-guide-quiz';
|
|
6
|
+
import { getQuizRecommendations } from '@/constants/quiz-recommendations';
|
|
7
|
+
|
|
8
|
+
interface QuizContextType {
|
|
9
|
+
state: QuizState;
|
|
10
|
+
setState: Dispatch<SetStateAction<QuizState>>;
|
|
11
|
+
submitAnswer: (questionId: string, answerId: string) => boolean;
|
|
12
|
+
toggleAnswer: (questionId: string, answerId: string) => void;
|
|
13
|
+
getSelectedAnswers: (questionId: string) => string[];
|
|
14
|
+
goToNextQuestion: () => void;
|
|
15
|
+
goToPreviousQuestion: () => void;
|
|
16
|
+
restartQuiz: () => void;
|
|
17
|
+
getRecommendedProducts: () => ReturnType<typeof getQuizRecommendations>;
|
|
18
|
+
getCurrentQuestion: () => typeof QUIZ_QUESTIONS[0] | undefined;
|
|
19
|
+
deselectAnswer: (questionId: string) => boolean;
|
|
20
|
+
getProgress: () => number;
|
|
21
|
+
getVisibleQuestions: () => typeof QUIZ_QUESTIONS[0][];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const QuizContext = createContext<QuizContextType | undefined>(undefined);
|
|
25
|
+
|
|
26
|
+
const initialState: QuizState = {
|
|
27
|
+
currentQuestionIndex: 0,
|
|
28
|
+
answers: {},
|
|
29
|
+
isComplete: false,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function QuizProvider({ children }: { children: ReactNode }) {
|
|
33
|
+
const [state, setState] = useState<QuizState>(initialState);
|
|
34
|
+
|
|
35
|
+
const getVisibleQuestions = useCallback((answers = state.answers) => {
|
|
36
|
+
return QUIZ_QUESTIONS.filter(question => {
|
|
37
|
+
if (!question.showIf) return true;
|
|
38
|
+
return question.showIf(answers);
|
|
39
|
+
});
|
|
40
|
+
}, [state.answers]);
|
|
41
|
+
|
|
42
|
+
const getCurrentQuestion = useCallback(() => {
|
|
43
|
+
const visibleQuestions = getVisibleQuestions();
|
|
44
|
+
return visibleQuestions[state.currentQuestionIndex];
|
|
45
|
+
}, [state.currentQuestionIndex, getVisibleQuestions]);
|
|
46
|
+
|
|
47
|
+
const getSelectedAnswers = useCallback((questionId: string): string[] => {
|
|
48
|
+
const answer = state.answers[questionId];
|
|
49
|
+
if (!answer) return [];
|
|
50
|
+
|
|
51
|
+
const question = QUIZ_QUESTIONS.find(q => q.id === questionId);
|
|
52
|
+
if (question?.isMultiSelect) {
|
|
53
|
+
return Array.isArray(answer) ? answer : [answer];
|
|
54
|
+
}
|
|
55
|
+
return [answer as string];
|
|
56
|
+
}, [state.answers]);
|
|
57
|
+
|
|
58
|
+
const submitAnswer = useCallback((questionId: string, answerId: string) => {
|
|
59
|
+
const question = QUIZ_QUESTIONS.find(q => q.id === questionId);
|
|
60
|
+
|
|
61
|
+
setState(prev => {
|
|
62
|
+
if (question?.isMultiSelect) {
|
|
63
|
+
const currentAnswers = Array.isArray(prev.answers[questionId])
|
|
64
|
+
? prev.answers[questionId] as string[]
|
|
65
|
+
: [];
|
|
66
|
+
|
|
67
|
+
const updatedAnswers = currentAnswers.includes(answerId)
|
|
68
|
+
? currentAnswers.filter(id => id !== answerId)
|
|
69
|
+
: [...currentAnswers, answerId];
|
|
70
|
+
|
|
71
|
+
// If this is the activities question, handle hiking check
|
|
72
|
+
if (questionId === 'what_activities') {
|
|
73
|
+
const shouldContinue = updatedAnswers.includes('hiking');
|
|
74
|
+
return {
|
|
75
|
+
...prev,
|
|
76
|
+
answers: {
|
|
77
|
+
...prev.answers,
|
|
78
|
+
[questionId]: updatedAnswers
|
|
79
|
+
},
|
|
80
|
+
isComplete: !shouldContinue
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
...prev,
|
|
86
|
+
answers: {
|
|
87
|
+
...prev.answers,
|
|
88
|
+
[questionId]: updatedAnswers
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// For single select, just store the value directly
|
|
94
|
+
return {
|
|
95
|
+
...prev,
|
|
96
|
+
answers: {
|
|
97
|
+
...prev.answers,
|
|
98
|
+
[questionId]: answerId
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Return true unless it's activities without hiking
|
|
104
|
+
if (questionId === 'what_activities') {
|
|
105
|
+
const answers = getSelectedAnswers(questionId);
|
|
106
|
+
return answers.includes('hiking');
|
|
107
|
+
}
|
|
108
|
+
return true;
|
|
109
|
+
}, [getSelectedAnswers]);
|
|
110
|
+
|
|
111
|
+
const toggleAnswer = useCallback((questionId: string, answerId: string) => {
|
|
112
|
+
setState(prev => {
|
|
113
|
+
const currentAnswers = Array.isArray(prev.answers[questionId])
|
|
114
|
+
? prev.answers[questionId] as string[]
|
|
115
|
+
: [];
|
|
116
|
+
|
|
117
|
+
const updatedAnswers = currentAnswers.includes(answerId)
|
|
118
|
+
? currentAnswers.filter(id => id !== answerId)
|
|
119
|
+
: [...currentAnswers, answerId];
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
...prev,
|
|
123
|
+
answers: {
|
|
124
|
+
...prev.answers,
|
|
125
|
+
[questionId]: updatedAnswers
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
}, []);
|
|
130
|
+
|
|
131
|
+
const goToNextQuestion = useCallback(() => {
|
|
132
|
+
setState(prev => ({
|
|
133
|
+
...prev,
|
|
134
|
+
currentQuestionIndex: prev.currentQuestionIndex + 1
|
|
135
|
+
}));
|
|
136
|
+
}, []);
|
|
137
|
+
|
|
138
|
+
const goToPreviousQuestion = useCallback(() => {
|
|
139
|
+
setState(prev => ({
|
|
140
|
+
...prev,
|
|
141
|
+
currentQuestionIndex: Math.max(0, prev.currentQuestionIndex - 1),
|
|
142
|
+
isComplete: false
|
|
143
|
+
}));
|
|
144
|
+
}, []);
|
|
145
|
+
|
|
146
|
+
const restartQuiz = useCallback(() => {
|
|
147
|
+
setState(initialState);
|
|
148
|
+
}, []);
|
|
149
|
+
|
|
150
|
+
const getRecommendedProducts = useCallback(() => {
|
|
151
|
+
return getQuizRecommendations(state.answers);
|
|
152
|
+
}, [state.answers]);
|
|
153
|
+
|
|
154
|
+
const deselectAnswer = useCallback((questionId: string) => {
|
|
155
|
+
const newAnswers = { ...state.answers };
|
|
156
|
+
delete newAnswers[questionId];
|
|
157
|
+
|
|
158
|
+
// Check if removing this answer makes any questions disappear
|
|
159
|
+
const nextVisible = QUIZ_QUESTIONS.filter(question => {
|
|
160
|
+
if (!question.showIf) return true;
|
|
161
|
+
return question.showIf(newAnswers);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// If we're currently on a question that will disappear, mark as complete
|
|
165
|
+
const willCurrentQuestionDisappear = state.currentQuestionIndex >= nextVisible.length;
|
|
166
|
+
|
|
167
|
+
setState(prev => ({
|
|
168
|
+
...prev,
|
|
169
|
+
answers: newAnswers,
|
|
170
|
+
isComplete: willCurrentQuestionDisappear
|
|
171
|
+
}));
|
|
172
|
+
|
|
173
|
+
return !willCurrentQuestionDisappear;
|
|
174
|
+
}, [state.currentQuestionIndex, state.answers]);
|
|
175
|
+
|
|
176
|
+
const getProgress = useCallback(() => {
|
|
177
|
+
const visibleQuestions = getVisibleQuestions();
|
|
178
|
+
return ((state.currentQuestionIndex + 1) / visibleQuestions.length) * 100;
|
|
179
|
+
}, [state.currentQuestionIndex, getVisibleQuestions]);
|
|
180
|
+
|
|
181
|
+
const value = {
|
|
182
|
+
state,
|
|
183
|
+
setState,
|
|
184
|
+
submitAnswer,
|
|
185
|
+
toggleAnswer,
|
|
186
|
+
getSelectedAnswers,
|
|
187
|
+
goToNextQuestion,
|
|
188
|
+
goToPreviousQuestion,
|
|
189
|
+
restartQuiz,
|
|
190
|
+
getRecommendedProducts,
|
|
191
|
+
getCurrentQuestion,
|
|
192
|
+
deselectAnswer,
|
|
193
|
+
getProgress,
|
|
194
|
+
getVisibleQuestions
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<QuizContext.Provider value={value}>
|
|
199
|
+
{children}
|
|
200
|
+
</QuizContext.Provider>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function useQuiz() {
|
|
205
|
+
const context = useContext(QuizContext);
|
|
206
|
+
if (context === undefined) {
|
|
207
|
+
throw new Error('useQuiz must be used within a QuizProvider');
|
|
208
|
+
}
|
|
209
|
+
return context;
|
|
210
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -27,10 +27,36 @@ export {
|
|
|
27
27
|
type BookingFlowUiOptions,
|
|
28
28
|
} from './components/booking/booking-flow-ui';
|
|
29
29
|
|
|
30
|
+
export {
|
|
31
|
+
CompanyProvider,
|
|
32
|
+
useCompany,
|
|
33
|
+
useCompanyTimezone,
|
|
34
|
+
type Company,
|
|
35
|
+
} from './contexts/CompanyContext';
|
|
36
|
+
|
|
37
|
+
export {
|
|
38
|
+
AVAILABILITIES_CACHE_TTL_MS,
|
|
39
|
+
AvailabilitiesCacheProvider,
|
|
40
|
+
useAvailabilitiesCache,
|
|
41
|
+
buildAvailabilitiesCacheKey,
|
|
42
|
+
type CachedAvailabilitiesData,
|
|
43
|
+
} from './contexts/AvailabilitiesCacheContext';
|
|
44
|
+
|
|
45
|
+
export {
|
|
46
|
+
BookingDialogProvider,
|
|
47
|
+
useBookingDialog,
|
|
48
|
+
OPEN_BOOKING_FOR_PRODUCT,
|
|
49
|
+
OPEN_BOOKING_WITH_FILTER,
|
|
50
|
+
BOOKING_FLOW_ABANDON_EVENT,
|
|
51
|
+
type ProductGridFilterId,
|
|
52
|
+
type BookingScreen,
|
|
53
|
+
type ProductGridRestoreState,
|
|
54
|
+
} from './providers/booking-dialog-provider';
|
|
55
|
+
|
|
30
56
|
export {
|
|
31
57
|
setPartnerPortalBookingJwtGetter,
|
|
32
58
|
getPartnerPortalBookingJwt,
|
|
33
|
-
} from './lib/booking-api
|
|
59
|
+
} from './lib/booking-api';
|
|
34
60
|
|
|
35
61
|
export {
|
|
36
62
|
KnownBookingSource,
|
|
@@ -50,7 +76,6 @@ export {
|
|
|
50
76
|
export {
|
|
51
77
|
default as PartnerBookingPageWithBrowserMetadata,
|
|
52
78
|
type PartnerBookingPageWithBrowserMetadataProps,
|
|
53
|
-
type PartnerBookingPageRenderProps,
|
|
54
79
|
} from './components/partner/PartnerBookingPageWithBrowserMetadata';
|
|
55
80
|
|
|
56
81
|
export {
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frontend analytics for GA4 and Meta Pixel.
|
|
3
|
+
* Events deduplicate with backend using transaction_id / eventID = Stripe paymentIntent.id.
|
|
4
|
+
* - development/staging: log to console only (no GA4/Meta)
|
|
5
|
+
* - production: send to GA4/Meta when consent is granted
|
|
6
|
+
*/
|
|
7
|
+
import { ENV, isLocalhost, isProduction, shouldLogPurchaseToConsole } from '@/lib/env';
|
|
8
|
+
|
|
9
|
+
const CONSENT_KEY = 'cookie-consent';
|
|
10
|
+
const PENDING_PURCHASE_KEY = 'pending_purchase';
|
|
11
|
+
|
|
12
|
+
declare global {
|
|
13
|
+
interface Window {
|
|
14
|
+
dataLayer?: unknown[];
|
|
15
|
+
gtag?: (...args: unknown[]) => void;
|
|
16
|
+
fbq?: (...args: unknown[]) => void;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Check if user has granted cookie consent. */
|
|
21
|
+
export function hasAnalyticsConsent(): boolean {
|
|
22
|
+
if (typeof window === 'undefined') return false;
|
|
23
|
+
try {
|
|
24
|
+
return localStorage.getItem(CONSENT_KEY) === 'granted';
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Mark consent as granted (call from cookie banner on Accept). */
|
|
31
|
+
export function setAnalyticsConsentGranted(): void {
|
|
32
|
+
if (typeof window === 'undefined') return;
|
|
33
|
+
try {
|
|
34
|
+
localStorage.setItem(CONSENT_KEY, 'granted');
|
|
35
|
+
} catch {
|
|
36
|
+
/* ignore */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Store purchase data before Stripe redirect.
|
|
42
|
+
* Success page reads payment_intent from URL and fires purchase with transaction_id = payment_intent.
|
|
43
|
+
*/
|
|
44
|
+
export function storePendingPurchase(value: number, currency: string): void {
|
|
45
|
+
if (typeof window === 'undefined') return;
|
|
46
|
+
try {
|
|
47
|
+
sessionStorage.setItem(
|
|
48
|
+
PENDING_PURCHASE_KEY,
|
|
49
|
+
JSON.stringify({ value, currency })
|
|
50
|
+
);
|
|
51
|
+
} catch {
|
|
52
|
+
/* ignore */
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Read and clear pending purchase from sessionStorage. */
|
|
57
|
+
export function consumePendingPurchase(): { value: number; currency: string } | null {
|
|
58
|
+
if (typeof window === 'undefined') return null;
|
|
59
|
+
try {
|
|
60
|
+
const raw = sessionStorage.getItem(PENDING_PURCHASE_KEY);
|
|
61
|
+
sessionStorage.removeItem(PENDING_PURCHASE_KEY);
|
|
62
|
+
if (!raw) return null;
|
|
63
|
+
const parsed = JSON.parse(raw) as { value?: number; currency?: string };
|
|
64
|
+
if (typeof parsed?.value === 'number' && typeof parsed?.currency === 'string') {
|
|
65
|
+
return { value: parsed.value, currency: parsed.currency };
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function safeGtag(...args: unknown[]): void {
|
|
74
|
+
if (typeof window !== 'undefined' && window.gtag) {
|
|
75
|
+
window.gtag(...args);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function safeFbq(...args: unknown[]): void {
|
|
80
|
+
if (typeof window !== 'undefined' && window.fbq) {
|
|
81
|
+
window.fbq(...args);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Fire view_item when user selects or views a product. */
|
|
86
|
+
export function trackViewItem(
|
|
87
|
+
productId: string,
|
|
88
|
+
productName: string,
|
|
89
|
+
price: number,
|
|
90
|
+
currency: string
|
|
91
|
+
): void {
|
|
92
|
+
const data = { productId, productName, price, currency };
|
|
93
|
+
|
|
94
|
+
if (isLocalhost() || shouldLogPurchaseToConsole()) {
|
|
95
|
+
console.log('analytics view_item', data);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!hasAnalyticsConsent()) return;
|
|
100
|
+
|
|
101
|
+
if (ENV.GA4_MEASUREMENT_ID) {
|
|
102
|
+
safeGtag('event', 'view_item', {
|
|
103
|
+
items: [{ item_id: productId, item_name: productName, price, currency }],
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
if (ENV.META_PIXEL_ID) {
|
|
107
|
+
safeFbq('track', 'ViewContent', {
|
|
108
|
+
content_ids: [productId],
|
|
109
|
+
content_name: productName,
|
|
110
|
+
content_type: 'product',
|
|
111
|
+
value: price,
|
|
112
|
+
currency,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface BeginCheckoutItem {
|
|
118
|
+
id: string;
|
|
119
|
+
name: string;
|
|
120
|
+
qty: number;
|
|
121
|
+
price: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Fire begin_checkout when checkout modal opens. */
|
|
125
|
+
export function trackBeginCheckout(
|
|
126
|
+
value: number,
|
|
127
|
+
currency: string,
|
|
128
|
+
items: BeginCheckoutItem[]
|
|
129
|
+
): void {
|
|
130
|
+
const data = { value, currency, items };
|
|
131
|
+
|
|
132
|
+
if (isLocalhost() || shouldLogPurchaseToConsole()) {
|
|
133
|
+
console.log('analytics begin_checkout', data);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!hasAnalyticsConsent()) return;
|
|
138
|
+
|
|
139
|
+
if (ENV.GA4_MEASUREMENT_ID) {
|
|
140
|
+
safeGtag('event', 'begin_checkout', {
|
|
141
|
+
value,
|
|
142
|
+
currency,
|
|
143
|
+
items: items.map((i) => ({
|
|
144
|
+
item_id: i.id,
|
|
145
|
+
item_name: i.name,
|
|
146
|
+
quantity: i.qty,
|
|
147
|
+
price: i.price,
|
|
148
|
+
})),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
if (ENV.META_PIXEL_ID) {
|
|
152
|
+
safeFbq('track', 'InitiateCheckout', {
|
|
153
|
+
value,
|
|
154
|
+
currency,
|
|
155
|
+
content_ids: items.map((i) => i.id),
|
|
156
|
+
content_type: 'product',
|
|
157
|
+
num_items: items.reduce((s, i) => s + i.qty, 0),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Fire purchase on success page.
|
|
164
|
+
* transactionId MUST be Stripe paymentIntent.id (from URL param payment_intent) for dedup with backend.
|
|
165
|
+
*/
|
|
166
|
+
export function trackPurchase(
|
|
167
|
+
transactionId: string,
|
|
168
|
+
value: number,
|
|
169
|
+
currency: string,
|
|
170
|
+
items: Array<{ item_id: string; item_name: string; quantity: number; price: number }> = []
|
|
171
|
+
): void {
|
|
172
|
+
const data = { transaction_id: transactionId, value, currency, items };
|
|
173
|
+
|
|
174
|
+
if (isLocalhost() || shouldLogPurchaseToConsole()) {
|
|
175
|
+
console.log('analytics purchase', data);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!hasAnalyticsConsent()) return;
|
|
180
|
+
|
|
181
|
+
if (ENV.GA4_MEASUREMENT_ID) {
|
|
182
|
+
safeGtag('event', 'purchase', {
|
|
183
|
+
transaction_id: transactionId,
|
|
184
|
+
value,
|
|
185
|
+
currency,
|
|
186
|
+
items,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
if (ENV.META_PIXEL_ID) {
|
|
190
|
+
safeFbq('track', 'Purchase', {
|
|
191
|
+
value,
|
|
192
|
+
currency,
|
|
193
|
+
order_id: transactionId,
|
|
194
|
+
eventID: transactionId,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -1,5 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel values the API persists on reservations/bookings (aligned with ticketbooth-be
|
|
3
|
+
* `BookingMarketingSource` + `SourceTrackingService` auto-detect: WEBSITE, AFFILIATE, DASHBOARD,
|
|
4
|
+
* GYG, VIATOR, plus dynamic uppercase `utm_source` strings when no fixed bucket applies).
|
|
5
|
+
*/
|
|
1
6
|
export enum KnownBookingSource {
|
|
2
7
|
WEBSITE = 'WEBSITE',
|
|
8
|
+
/** Main-site partner embed (e.g. `/partner/{slug}`); not the dedicated `booking.*` portal app. */
|
|
3
9
|
PUBLIC_PARTNER_WEBSITE = 'PUBLIC_PARTNER_WEBSITE',
|
|
4
10
|
PARTNER_PORTAL = 'PARTNER_PORTAL',
|
|
5
11
|
AFFILIATE = 'AFFILIATE',
|
|
@@ -8,13 +14,21 @@ export enum KnownBookingSource {
|
|
|
8
14
|
VIATOR = 'VIATOR',
|
|
9
15
|
}
|
|
10
16
|
|
|
17
|
+
/** Default reserve/checkout `source` when the client does not send a more specific channel. */
|
|
11
18
|
export const DEFAULT_BOOKING_SOURCE = KnownBookingSource.WEBSITE;
|
|
19
|
+
|
|
20
|
+
/** Client `source` when booking through the signed-in partner org / marketing partner flows. */
|
|
12
21
|
export const PARTNER_PORTAL_BOOKING_SOURCE = KnownBookingSource.PARTNER_PORTAL;
|
|
13
22
|
|
|
14
23
|
const PUBLIC_BOOKING_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
15
24
|
|
|
25
|
+
/**
|
|
26
|
+
* True when metadata carries a partner org id or a normalized public marketing slug (same shape
|
|
27
|
+
* as backend `publicBookingSlug`), independent of hostname or URL path — use with route-level
|
|
28
|
+
* `canonicalAttribution.partnerSlug` so localhost and stripped URLs still attribute.
|
|
29
|
+
*/
|
|
16
30
|
export function mergedMetadataImpliesPartnerPortal(
|
|
17
|
-
merged: Partial<{ partnerId?: string; partnerSlug?: string }
|
|
31
|
+
merged: Partial<{ partnerId?: string; partnerSlug?: string }>,
|
|
18
32
|
): boolean {
|
|
19
33
|
const pid = typeof merged.partnerId === 'string' ? merged.partnerId.trim() : '';
|
|
20
34
|
if (pid.startsWith('par_')) return true;
|
|
@@ -22,9 +36,13 @@ export function mergedMetadataImpliesPartnerPortal(
|
|
|
22
36
|
return slug.length > 0 && PUBLIC_BOOKING_SLUG_RE.test(slug);
|
|
23
37
|
}
|
|
24
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Fixed client channel from product / option identifiers (GYG / Viator embeds). Everything else
|
|
41
|
+
* is treated as main-site {@link KnownBookingSource.WEBSITE}; AFFILIATE/DASHBOARD are set server-side.
|
|
42
|
+
*/
|
|
25
43
|
export function inferClientBookingSourceFromProductIds(
|
|
26
44
|
productId: string,
|
|
27
|
-
productOptionId?: string | null
|
|
45
|
+
productOptionId?: string | null,
|
|
28
46
|
): KnownBookingSource {
|
|
29
47
|
const haystack = `${productId} ${productOptionId ?? ''}`.toLowerCase();
|
|
30
48
|
if (haystack.includes('gyg_')) return KnownBookingSource.GYG;
|