@ticketboothapp/booking 0.1.1 → 0.1.3
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/components/booking/booking-flow-ui.ts +24 -0
- package/src/components/partner/PartnerBookingPageWithBrowserMetadata.tsx +61 -0
- package/src/contexts/AvailabilitiesCacheContext.tsx +107 -0
- package/src/contexts/BookingAppContext.tsx +93 -0
- package/src/contexts/CompanyContext.tsx +67 -0
- package/src/index.ts +68 -0
- package/src/lib/booking/booking-source.ts +33 -0
- package/src/lib/booking/normalize-booking-product-id.ts +14 -0
- package/src/lib/booking/source-metadata.ts +161 -0
- package/src/lib/booking-api-auth.ts +9 -0
- package/src/lib/env.ts +14 -0
- package/src/providers/booking-dialog-provider.tsx +213 -0
package/package.json
CHANGED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional UI/behavior overrides for booking flows.
|
|
3
|
+
*/
|
|
4
|
+
export interface BookingFlowUiOptions {
|
|
5
|
+
showCollage?: boolean;
|
|
6
|
+
showTourDescription?: boolean;
|
|
7
|
+
autoSelectFirstAvailableDate?: boolean;
|
|
8
|
+
autoSelectFirstHighlightedPickup?: boolean;
|
|
9
|
+
partnerDeferredInvoice?: boolean;
|
|
10
|
+
partnerDeferredInvoiceSubmitLabel?: string;
|
|
11
|
+
partnerAttributionSummary?: string;
|
|
12
|
+
partnerAttributionConfirmLabel?: string;
|
|
13
|
+
itineraryStickyTopOffsetPx?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Baseline UX for embedded partner-style surfaces.
|
|
18
|
+
*/
|
|
19
|
+
export const PARTNER_EMBEDDED_BOOKING_FLOW_UI_BASE: BookingFlowUiOptions = {
|
|
20
|
+
showCollage: false,
|
|
21
|
+
showTourDescription: false,
|
|
22
|
+
autoSelectFirstAvailableDate: true,
|
|
23
|
+
autoSelectFirstHighlightedPickup: true,
|
|
24
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo, type ReactNode } from 'react';
|
|
4
|
+
import { usePathname } from 'next/navigation';
|
|
5
|
+
import type { BookingFlowUiOptions } from '../booking/booking-flow-ui';
|
|
6
|
+
import {
|
|
7
|
+
buildBookingSourceMetadataFromLocation,
|
|
8
|
+
mergeBookingSourceMetadata,
|
|
9
|
+
type BookingSourceMetadata,
|
|
10
|
+
} from '../../lib/booking/source-metadata';
|
|
11
|
+
|
|
12
|
+
export interface PartnerBookingPageRenderProps {
|
|
13
|
+
bookingSourceAttribution: Partial<BookingSourceMetadata>;
|
|
14
|
+
flowUi?: BookingFlowUiOptions;
|
|
15
|
+
onBookingFlowSuccess?: (data: { reservationReference: string; bookingReference?: string }) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PartnerBookingPageWithBrowserMetadataProps {
|
|
19
|
+
canonicalAttribution: Partial<BookingSourceMetadata>;
|
|
20
|
+
bookingSourceAttributionMerge?: Partial<BookingSourceMetadata> | null;
|
|
21
|
+
flowUi?: BookingFlowUiOptions;
|
|
22
|
+
onBookingFlowSuccess?: (data: { reservationReference: string; bookingReference?: string }) => void;
|
|
23
|
+
render: (props: PartnerBookingPageRenderProps) => ReactNode;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Lightweight, package-safe partner metadata wrapper.
|
|
28
|
+
* It computes attribution from browser URL + canonical route + runtime merge.
|
|
29
|
+
*/
|
|
30
|
+
export default function PartnerBookingPageWithBrowserMetadata({
|
|
31
|
+
canonicalAttribution,
|
|
32
|
+
bookingSourceAttributionMerge,
|
|
33
|
+
flowUi,
|
|
34
|
+
onBookingFlowSuccess,
|
|
35
|
+
render,
|
|
36
|
+
}: PartnerBookingPageWithBrowserMetadataProps) {
|
|
37
|
+
const pathname = usePathname() ?? '';
|
|
38
|
+
|
|
39
|
+
const bookingSourceAttribution = useMemo(
|
|
40
|
+
() => {
|
|
41
|
+
// Trigger recompute when route path changes; location parser reads from window.
|
|
42
|
+
void pathname;
|
|
43
|
+
return mergeBookingSourceMetadata(
|
|
44
|
+
buildBookingSourceMetadataFromLocation(),
|
|
45
|
+
canonicalAttribution,
|
|
46
|
+
bookingSourceAttributionMerge,
|
|
47
|
+
);
|
|
48
|
+
},
|
|
49
|
+
[pathname, canonicalAttribution, bookingSourceAttributionMerge],
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<>
|
|
54
|
+
{render({
|
|
55
|
+
bookingSourceAttribution,
|
|
56
|
+
flowUi,
|
|
57
|
+
onBookingFlowSuccess,
|
|
58
|
+
})}
|
|
59
|
+
</>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useRef, useCallback, type ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
export const AVAILABILITIES_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
6
|
+
|
|
7
|
+
export type AvailabilityLike = Record<string, unknown>;
|
|
8
|
+
export type PricingConfigLike = Record<string, unknown>;
|
|
9
|
+
export type PrecomputedPricesByCategoryLike = Record<string, unknown>;
|
|
10
|
+
|
|
11
|
+
export interface CachedAvailabilitiesData {
|
|
12
|
+
fetchedRanges: Array<{ start: Date; end: Date }>;
|
|
13
|
+
availabilities: AvailabilityLike[];
|
|
14
|
+
pricingConfig: PricingConfigLike | null;
|
|
15
|
+
precomputedPricesByOption: Record<string, PrecomputedPricesByCategoryLike> | null;
|
|
16
|
+
precomputedPrices?: PrecomputedPricesByCategoryLike | null;
|
|
17
|
+
resourcePriceByCurrency?: Record<string, number> | null;
|
|
18
|
+
resourcePriceByOption?: Record<string, Record<string, number>> | null;
|
|
19
|
+
cachedAt: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface AvailabilitiesCacheContextValue {
|
|
23
|
+
get: (cacheKey: string) => CachedAvailabilitiesData | undefined;
|
|
24
|
+
isStale: (cached: CachedAvailabilitiesData) => boolean;
|
|
25
|
+
set: (cacheKey: string, data: CachedAvailabilitiesData) => void;
|
|
26
|
+
merge: (
|
|
27
|
+
cacheKey: string,
|
|
28
|
+
update: {
|
|
29
|
+
fetchedRanges?: Array<{ start: Date; end: Date }>;
|
|
30
|
+
availabilities?: AvailabilityLike[];
|
|
31
|
+
pricingConfig?: PricingConfigLike | null;
|
|
32
|
+
precomputedPricesByOption?: Record<string, PrecomputedPricesByCategoryLike> | null;
|
|
33
|
+
precomputedPrices?: PrecomputedPricesByCategoryLike | null;
|
|
34
|
+
resourcePriceByCurrency?: Record<string, number> | null;
|
|
35
|
+
resourcePriceByOption?: Record<string, Record<string, number>> | null;
|
|
36
|
+
}
|
|
37
|
+
) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const AvailabilitiesCacheContext = createContext<AvailabilitiesCacheContextValue | null>(null);
|
|
41
|
+
|
|
42
|
+
export function useAvailabilitiesCache() {
|
|
43
|
+
const ctx = useContext(AvailabilitiesCacheContext);
|
|
44
|
+
return ctx;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function buildAvailabilitiesCacheKey(
|
|
48
|
+
productId: string,
|
|
49
|
+
optionIdsKey: string,
|
|
50
|
+
promoCode: string | null,
|
|
51
|
+
pricingProfileId?: string | null
|
|
52
|
+
): string {
|
|
53
|
+
const pp = (pricingProfileId ?? '').trim();
|
|
54
|
+
return `${productId}|${optionIdsKey}|${promoCode ?? ''}|pp:${pp}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function AvailabilitiesCacheProvider({ children }: { children: ReactNode }) {
|
|
58
|
+
const cacheRef = useRef<Map<string, CachedAvailabilitiesData>>(new Map());
|
|
59
|
+
|
|
60
|
+
const get = useCallback((cacheKey: string) => cacheRef.current.get(cacheKey), []);
|
|
61
|
+
|
|
62
|
+
const isStale = useCallback((cached: CachedAvailabilitiesData) => {
|
|
63
|
+
const age = Date.now() - (cached.cachedAt ?? 0);
|
|
64
|
+
return age > AVAILABILITIES_CACHE_TTL_MS;
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
const set = useCallback((cacheKey: string, data: CachedAvailabilitiesData) => {
|
|
68
|
+
cacheRef.current.set(cacheKey, data);
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
const merge = useCallback(
|
|
72
|
+
(
|
|
73
|
+
cacheKey: string,
|
|
74
|
+
update: {
|
|
75
|
+
fetchedRanges?: Array<{ start: Date; end: Date }>;
|
|
76
|
+
availabilities?: AvailabilityLike[];
|
|
77
|
+
pricingConfig?: PricingConfigLike | null;
|
|
78
|
+
precomputedPricesByOption?: Record<string, PrecomputedPricesByCategoryLike> | null;
|
|
79
|
+
precomputedPrices?: PrecomputedPricesByCategoryLike | null;
|
|
80
|
+
resourcePriceByCurrency?: Record<string, number> | null;
|
|
81
|
+
resourcePriceByOption?: Record<string, Record<string, number>> | null;
|
|
82
|
+
}
|
|
83
|
+
) => {
|
|
84
|
+
const existing = cacheRef.current.get(cacheKey);
|
|
85
|
+
const next: CachedAvailabilitiesData = {
|
|
86
|
+
fetchedRanges: update.fetchedRanges ?? existing?.fetchedRanges ?? [],
|
|
87
|
+
availabilities: update.availabilities ?? existing?.availabilities ?? [],
|
|
88
|
+
pricingConfig: update.pricingConfig !== undefined ? update.pricingConfig : existing?.pricingConfig ?? null,
|
|
89
|
+
precomputedPricesByOption:
|
|
90
|
+
update.precomputedPricesByOption !== undefined
|
|
91
|
+
? update.precomputedPricesByOption
|
|
92
|
+
: existing?.precomputedPricesByOption ?? null,
|
|
93
|
+
precomputedPrices: update.precomputedPrices !== undefined ? update.precomputedPrices : existing?.precomputedPrices ?? null,
|
|
94
|
+
resourcePriceByCurrency:
|
|
95
|
+
update.resourcePriceByCurrency !== undefined ? update.resourcePriceByCurrency : existing?.resourcePriceByCurrency ?? null,
|
|
96
|
+
resourcePriceByOption:
|
|
97
|
+
update.resourcePriceByOption !== undefined ? update.resourcePriceByOption : existing?.resourcePriceByOption ?? null,
|
|
98
|
+
cachedAt: Date.now(),
|
|
99
|
+
};
|
|
100
|
+
cacheRef.current.set(cacheKey, next);
|
|
101
|
+
},
|
|
102
|
+
[]
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const value: AvailabilitiesCacheContextValue = { get, set, merge, isStale };
|
|
106
|
+
return <AvailabilitiesCacheContext.Provider value={value}>{children}</AvailabilitiesCacheContext.Provider>;
|
|
107
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, type ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
/** Host app / embedding context (standalone site, provider-dashboard, etc.). */
|
|
6
|
+
export type BookingAppMode = 'standalone' | 'provider-dashboard' | (string & Record<never, never>);
|
|
7
|
+
|
|
8
|
+
/** Viewer role for the booking app. */
|
|
9
|
+
export type ViewerRole = 'public' | 'reseller' | 'admin';
|
|
10
|
+
|
|
11
|
+
/** Feature flags and permissions for the booking app in this context. */
|
|
12
|
+
export interface BookingAppPermissions {
|
|
13
|
+
canViewPriceBreakdown?: boolean;
|
|
14
|
+
viewerRole?: ViewerRole;
|
|
15
|
+
/** @deprecated Prefer viewerRole. */
|
|
16
|
+
priceBreakdownDetail?: 'full' | 'simplified';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ManageParams {
|
|
20
|
+
ref?: string;
|
|
21
|
+
reservationRef?: string;
|
|
22
|
+
lastName: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface BookingAppContextValue {
|
|
26
|
+
mode: BookingAppMode;
|
|
27
|
+
permissions: BookingAppPermissions;
|
|
28
|
+
isSimplifiedPricingView: boolean;
|
|
29
|
+
googleMapsApiKey?: string;
|
|
30
|
+
onShowManage?: (params: ManageParams) => void;
|
|
31
|
+
getSuccessUrl?: (params: { reservationRef: string; lastName: string; focusDate?: string }) => string;
|
|
32
|
+
showLanguageSelector: boolean;
|
|
33
|
+
suppressCalendarDateScroll?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const BookingAppContext = createContext<BookingAppContextValue | undefined>(undefined);
|
|
37
|
+
|
|
38
|
+
function deriveIsSimplifiedPricingView(permissions: BookingAppPermissions): boolean {
|
|
39
|
+
const role = permissions.viewerRole;
|
|
40
|
+
if (role === 'public' || role === 'reseller') return true;
|
|
41
|
+
if (role === 'admin') return false;
|
|
42
|
+
return permissions.priceBreakdownDetail === 'simplified';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const DEFAULT_STANDALONE: BookingAppContextValue = {
|
|
46
|
+
mode: 'standalone',
|
|
47
|
+
permissions: { canViewPriceBreakdown: false },
|
|
48
|
+
isSimplifiedPricingView: false,
|
|
49
|
+
showLanguageSelector: true,
|
|
50
|
+
suppressCalendarDateScroll: false,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export interface BookingAppProviderProps {
|
|
54
|
+
children: ReactNode;
|
|
55
|
+
mode?: BookingAppMode;
|
|
56
|
+
permissions?: Partial<BookingAppPermissions>;
|
|
57
|
+
googleMapsApiKey?: string;
|
|
58
|
+
onShowManage?: (params: ManageParams) => void;
|
|
59
|
+
getSuccessUrl?: (params: { reservationRef: string; lastName: string; focusDate?: string }) => string;
|
|
60
|
+
showLanguageSelector?: boolean;
|
|
61
|
+
suppressCalendarDateScroll?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function BookingAppProvider({
|
|
65
|
+
children,
|
|
66
|
+
mode = 'standalone',
|
|
67
|
+
permissions = {},
|
|
68
|
+
googleMapsApiKey,
|
|
69
|
+
onShowManage,
|
|
70
|
+
getSuccessUrl,
|
|
71
|
+
showLanguageSelector = true,
|
|
72
|
+
suppressCalendarDateScroll = false,
|
|
73
|
+
}: BookingAppProviderProps) {
|
|
74
|
+
const mergedPermissions = { ...DEFAULT_STANDALONE.permissions, ...permissions };
|
|
75
|
+
const value: BookingAppContextValue = {
|
|
76
|
+
mode,
|
|
77
|
+
permissions: mergedPermissions,
|
|
78
|
+
isSimplifiedPricingView: deriveIsSimplifiedPricingView(mergedPermissions),
|
|
79
|
+
googleMapsApiKey,
|
|
80
|
+
onShowManage,
|
|
81
|
+
getSuccessUrl,
|
|
82
|
+
showLanguageSelector,
|
|
83
|
+
suppressCalendarDateScroll,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
return <BookingAppContext.Provider value={value}>{children}</BookingAppContext.Provider>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function useBookingApp(): BookingAppContextValue {
|
|
90
|
+
const context = useContext(BookingAppContext);
|
|
91
|
+
if (context === undefined) return DEFAULT_STANDALONE;
|
|
92
|
+
return context;
|
|
93
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
|
4
|
+
import { ENV } from '../lib/env';
|
|
5
|
+
|
|
6
|
+
export interface Company {
|
|
7
|
+
settings?: {
|
|
8
|
+
timezone?: string;
|
|
9
|
+
};
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface CompanyContextType {
|
|
14
|
+
company: Company | null;
|
|
15
|
+
loading: boolean;
|
|
16
|
+
error: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const CompanyContext = createContext<CompanyContextType | undefined>(undefined);
|
|
20
|
+
|
|
21
|
+
interface CompanyProviderProps {
|
|
22
|
+
children: ReactNode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function getCompany(companyId: string): Promise<Company | null> {
|
|
26
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
27
|
+
if (ENV.BASIC_AUTH) headers.Authorization = `Basic ${ENV.BASIC_AUTH}`;
|
|
28
|
+
const response = await fetch(`${ENV.API_URL}/1/companies/${companyId}`, { method: 'GET', headers });
|
|
29
|
+
if (!response.ok) throw new Error(`Failed to load company (${response.status})`);
|
|
30
|
+
const data = await response.json();
|
|
31
|
+
return data?.data?.company ?? null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function CompanyProvider({ children }: CompanyProviderProps) {
|
|
35
|
+
const [company, setCompany] = useState<Company | null>(null);
|
|
36
|
+
const [loading, setLoading] = useState(true);
|
|
37
|
+
const [error, setError] = useState<string | null>(null);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
async function fetchCompany() {
|
|
41
|
+
try {
|
|
42
|
+
const companyData = await getCompany(ENV.COMPANY_ID);
|
|
43
|
+
setCompany(companyData);
|
|
44
|
+
setError(null);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
console.warn('Failed to fetch company data, using defaults:', err);
|
|
47
|
+
setError(err instanceof Error ? err.message : 'Failed to fetch company');
|
|
48
|
+
} finally {
|
|
49
|
+
setLoading(false);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
fetchCompany();
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
return <CompanyContext.Provider value={{ company, loading, error }}>{children}</CompanyContext.Provider>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function useCompany() {
|
|
59
|
+
const context = useContext(CompanyContext);
|
|
60
|
+
if (context === undefined) throw new Error('useCompany must be used within a CompanyProvider');
|
|
61
|
+
return context;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function useCompanyTimezone(): string {
|
|
65
|
+
const { company } = useCompany();
|
|
66
|
+
return company?.settings?.timezone || 'America/Edmonton';
|
|
67
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -10,3 +10,71 @@ export {
|
|
|
10
10
|
type PublicPartnerAgent,
|
|
11
11
|
type PublicStaffPortalSignInOption,
|
|
12
12
|
} from './public-partners';
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
BookingAppProvider,
|
|
16
|
+
useBookingApp,
|
|
17
|
+
type BookingAppMode,
|
|
18
|
+
type ViewerRole,
|
|
19
|
+
type BookingAppPermissions,
|
|
20
|
+
type BookingAppContextValue,
|
|
21
|
+
type BookingAppProviderProps,
|
|
22
|
+
type ManageParams,
|
|
23
|
+
} from './contexts/BookingAppContext';
|
|
24
|
+
|
|
25
|
+
export {
|
|
26
|
+
PARTNER_EMBEDDED_BOOKING_FLOW_UI_BASE,
|
|
27
|
+
type BookingFlowUiOptions,
|
|
28
|
+
} from './components/booking/booking-flow-ui';
|
|
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
|
+
|
|
56
|
+
export {
|
|
57
|
+
setPartnerPortalBookingJwtGetter,
|
|
58
|
+
getPartnerPortalBookingJwt,
|
|
59
|
+
} from './lib/booking-api-auth';
|
|
60
|
+
|
|
61
|
+
export {
|
|
62
|
+
KnownBookingSource,
|
|
63
|
+
DEFAULT_BOOKING_SOURCE,
|
|
64
|
+
PARTNER_PORTAL_BOOKING_SOURCE,
|
|
65
|
+
inferClientBookingSourceFromProductIds,
|
|
66
|
+
mergedMetadataImpliesPartnerPortal,
|
|
67
|
+
isPublicPartnerMarketingPath,
|
|
68
|
+
isDedicatedPartnerBookingPortalHost,
|
|
69
|
+
buildBookingSourceMetadataFromLocation,
|
|
70
|
+
mergeBookingSourceMetadata,
|
|
71
|
+
buildBookingSourceContext,
|
|
72
|
+
type BookingSourceMetadata,
|
|
73
|
+
type BuildBookingSourceContextOptions,
|
|
74
|
+
} from './lib/booking/source-metadata';
|
|
75
|
+
|
|
76
|
+
export {
|
|
77
|
+
default as PartnerBookingPageWithBrowserMetadata,
|
|
78
|
+
type PartnerBookingPageWithBrowserMetadataProps,
|
|
79
|
+
type PartnerBookingPageRenderProps,
|
|
80
|
+
} from './components/partner/PartnerBookingPageWithBrowserMetadata';
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export enum KnownBookingSource {
|
|
2
|
+
WEBSITE = 'WEBSITE',
|
|
3
|
+
PUBLIC_PARTNER_WEBSITE = 'PUBLIC_PARTNER_WEBSITE',
|
|
4
|
+
PARTNER_PORTAL = 'PARTNER_PORTAL',
|
|
5
|
+
AFFILIATE = 'AFFILIATE',
|
|
6
|
+
DASHBOARD = 'DASHBOARD',
|
|
7
|
+
GYG = 'GYG',
|
|
8
|
+
VIATOR = 'VIATOR',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_BOOKING_SOURCE = KnownBookingSource.WEBSITE;
|
|
12
|
+
export const PARTNER_PORTAL_BOOKING_SOURCE = KnownBookingSource.PARTNER_PORTAL;
|
|
13
|
+
|
|
14
|
+
const PUBLIC_BOOKING_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
15
|
+
|
|
16
|
+
export function mergedMetadataImpliesPartnerPortal(
|
|
17
|
+
merged: Partial<{ partnerId?: string; partnerSlug?: string }>
|
|
18
|
+
): boolean {
|
|
19
|
+
const pid = typeof merged.partnerId === 'string' ? merged.partnerId.trim() : '';
|
|
20
|
+
if (pid.startsWith('par_')) return true;
|
|
21
|
+
const slug = typeof merged.partnerSlug === 'string' ? merged.partnerSlug.trim().toLowerCase() : '';
|
|
22
|
+
return slug.length > 0 && PUBLIC_BOOKING_SLUG_RE.test(slug);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function inferClientBookingSourceFromProductIds(
|
|
26
|
+
productId: string,
|
|
27
|
+
productOptionId?: string | null
|
|
28
|
+
): KnownBookingSource {
|
|
29
|
+
const haystack = `${productId} ${productOptionId ?? ''}`.toLowerCase();
|
|
30
|
+
if (haystack.includes('gyg_')) return KnownBookingSource.GYG;
|
|
31
|
+
if (haystack.includes('viator_')) return KnownBookingSource.VIATOR;
|
|
32
|
+
return KnownBookingSource.WEBSITE;
|
|
33
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function normalizeBookingProductId(rawId: string): string {
|
|
2
|
+
const trimmed = rawId.trim();
|
|
3
|
+
if (!trimmed) return '';
|
|
4
|
+
const withoutHash = trimmed.split('#', 1)[0] ?? '';
|
|
5
|
+
const withoutQuery = withoutHash.split('?', 1)[0] ?? '';
|
|
6
|
+
const normalized = withoutQuery.split('&', 1)[0] ?? '';
|
|
7
|
+
return normalized.trim();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function isSuspiciousBookingProductId(value: string): boolean {
|
|
11
|
+
return /[?&=]/.test(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const normalizeAvailabilityLookupId = normalizeBookingProductId;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
export {
|
|
2
|
+
KnownBookingSource,
|
|
3
|
+
DEFAULT_BOOKING_SOURCE,
|
|
4
|
+
PARTNER_PORTAL_BOOKING_SOURCE,
|
|
5
|
+
inferClientBookingSourceFromProductIds,
|
|
6
|
+
mergedMetadataImpliesPartnerPortal,
|
|
7
|
+
} from './booking-source';
|
|
8
|
+
|
|
9
|
+
import { KnownBookingSource, mergedMetadataImpliesPartnerPortal } from './booking-source';
|
|
10
|
+
|
|
11
|
+
export interface BookingSourceMetadata {
|
|
12
|
+
pageUrl?: string;
|
|
13
|
+
pagePath?: string;
|
|
14
|
+
pageQuery?: string;
|
|
15
|
+
referrerUrl?: string;
|
|
16
|
+
utmSource?: string;
|
|
17
|
+
utmMedium?: string;
|
|
18
|
+
utmCampaign?: string;
|
|
19
|
+
utmTerm?: string;
|
|
20
|
+
utmContent?: string;
|
|
21
|
+
gclid?: string;
|
|
22
|
+
fbclid?: string;
|
|
23
|
+
msclkid?: string;
|
|
24
|
+
partnerId?: string;
|
|
25
|
+
partnerSlug?: string;
|
|
26
|
+
agentId?: string;
|
|
27
|
+
agentName?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function withDefinedValues<T extends object>(obj: T): Partial<T> {
|
|
31
|
+
return Object.fromEntries(
|
|
32
|
+
Object.entries(obj).filter(([, value]) => value !== undefined && value !== null && value !== ''),
|
|
33
|
+
) as Partial<T>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isPublicPartnerMarketingPath(pathname: string): boolean {
|
|
37
|
+
return /^\/partner\/[^/]+/i.test(pathname.trim());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isDedicatedPartnerBookingPortalHost(hostname: string): boolean {
|
|
41
|
+
const h = hostname.trim().toLowerCase();
|
|
42
|
+
return h === 'booking.viaviamorainelake.com' || h === 'staging.booking.viaviamorainelake.com';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function buildBookingSourceMetadataFromLocation(): Partial<BookingSourceMetadata> {
|
|
46
|
+
if (typeof window === 'undefined') return {};
|
|
47
|
+
const currentUrl = new URL(window.location.href);
|
|
48
|
+
const params = currentUrl.searchParams;
|
|
49
|
+
const partnerMatch = currentUrl.pathname.match(/^\/partner\/([^/]+)/i);
|
|
50
|
+
const onPartnerEmbedPath = isPublicPartnerMarketingPath(currentUrl.pathname);
|
|
51
|
+
const onPortalHost = isDedicatedPartnerBookingPortalHost(currentUrl.hostname);
|
|
52
|
+
const partnerFromQuery =
|
|
53
|
+
onPartnerEmbedPath || onPortalHost ? params.get('partnerId') || undefined : undefined;
|
|
54
|
+
const partnerSlugFromPath = partnerMatch?.[1];
|
|
55
|
+
const agentIdFromQuery = params.get('agentId') || undefined;
|
|
56
|
+
const agentNameFromQuery = params.get('agentName') || undefined;
|
|
57
|
+
|
|
58
|
+
return withDefinedValues<BookingSourceMetadata>({
|
|
59
|
+
pageUrl: currentUrl.href,
|
|
60
|
+
pagePath: currentUrl.pathname,
|
|
61
|
+
pageQuery: currentUrl.search || undefined,
|
|
62
|
+
referrerUrl: document.referrer || undefined,
|
|
63
|
+
utmSource: params.get('utm_source') || undefined,
|
|
64
|
+
utmMedium: params.get('utm_medium') || undefined,
|
|
65
|
+
utmCampaign: params.get('utm_campaign') || undefined,
|
|
66
|
+
utmTerm: params.get('utm_term') || undefined,
|
|
67
|
+
utmContent: params.get('utm_content') || undefined,
|
|
68
|
+
gclid: params.get('gclid') || undefined,
|
|
69
|
+
fbclid: params.get('fbclid') || undefined,
|
|
70
|
+
msclkid: params.get('msclkid') || undefined,
|
|
71
|
+
partnerId: partnerFromQuery,
|
|
72
|
+
partnerSlug: partnerSlugFromPath,
|
|
73
|
+
agentId: agentIdFromQuery,
|
|
74
|
+
agentName: agentNameFromQuery,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function mergeBookingSourceMetadata(
|
|
79
|
+
...layers: Array<Partial<BookingSourceMetadata> | null | undefined>
|
|
80
|
+
): Partial<BookingSourceMetadata> {
|
|
81
|
+
let acc: Partial<BookingSourceMetadata> = {};
|
|
82
|
+
for (const layer of layers) {
|
|
83
|
+
if (!layer) continue;
|
|
84
|
+
acc = { ...acc, ...withDefinedValues(layer as BookingSourceMetadata) };
|
|
85
|
+
}
|
|
86
|
+
return acc;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function hostnameForSourceDecision(merged: Partial<BookingSourceMetadata>): string {
|
|
90
|
+
if (typeof window !== 'undefined') {
|
|
91
|
+
try {
|
|
92
|
+
return new URL(window.location.href).hostname.toLowerCase();
|
|
93
|
+
} catch {
|
|
94
|
+
return '';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const pu = merged.pageUrl?.trim();
|
|
98
|
+
if (!pu) return '';
|
|
99
|
+
try {
|
|
100
|
+
return new URL(pu).hostname.toLowerCase();
|
|
101
|
+
} catch {
|
|
102
|
+
return '';
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type BuildBookingSourceContextOptions = {
|
|
107
|
+
clientChannelSource?: KnownBookingSource;
|
|
108
|
+
forcePartnerPortalChannel?: boolean;
|
|
109
|
+
forceDashboardSource?: boolean;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export function buildBookingSourceContext(
|
|
113
|
+
mergedMetadata: Partial<BookingSourceMetadata>,
|
|
114
|
+
options?: BuildBookingSourceContextOptions,
|
|
115
|
+
): {
|
|
116
|
+
source: KnownBookingSource;
|
|
117
|
+
sourceMetadata?: BookingSourceMetadata;
|
|
118
|
+
source_metadata?: BookingSourceMetadata;
|
|
119
|
+
} {
|
|
120
|
+
const merged = withDefinedValues(mergedMetadata as BookingSourceMetadata);
|
|
121
|
+
const channel = options?.clientChannelSource ?? KnownBookingSource.WEBSITE;
|
|
122
|
+
|
|
123
|
+
if (options?.forceDashboardSource === true) {
|
|
124
|
+
const hasMeta = Object.keys(merged).length > 0;
|
|
125
|
+
return {
|
|
126
|
+
source: KnownBookingSource.DASHBOARD,
|
|
127
|
+
...(hasMeta
|
|
128
|
+
? {
|
|
129
|
+
sourceMetadata: merged as BookingSourceMetadata,
|
|
130
|
+
source_metadata: merged as BookingSourceMetadata,
|
|
131
|
+
}
|
|
132
|
+
: {}),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (Object.keys(merged).length === 0) return { source: channel };
|
|
137
|
+
|
|
138
|
+
const host = hostnameForSourceDecision(merged);
|
|
139
|
+
const dedicatedPartnerBookingHost =
|
|
140
|
+
host === 'booking.viaviamorainelake.com' || host === 'staging.booking.viaviamorainelake.com';
|
|
141
|
+
const pid = typeof merged.partnerId === 'string' ? merged.partnerId.trim() : '';
|
|
142
|
+
const pslug = typeof merged.partnerSlug === 'string' ? merged.partnerSlug.trim() : '';
|
|
143
|
+
|
|
144
|
+
const forceDedicatedPortal =
|
|
145
|
+
options?.forcePartnerPortalChannel === true ||
|
|
146
|
+
(dedicatedPartnerBookingHost && (pid.length > 0 || pslug.length > 0));
|
|
147
|
+
const mainSitePartnerEmbed =
|
|
148
|
+
!forceDedicatedPortal && mergedMetadataImpliesPartnerPortal(merged);
|
|
149
|
+
|
|
150
|
+
const source = forceDedicatedPortal
|
|
151
|
+
? KnownBookingSource.PARTNER_PORTAL
|
|
152
|
+
: mainSitePartnerEmbed
|
|
153
|
+
? KnownBookingSource.PUBLIC_PARTNER_WEBSITE
|
|
154
|
+
: channel;
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
source,
|
|
158
|
+
sourceMetadata: merged as BookingSourceMetadata,
|
|
159
|
+
source_metadata: merged as BookingSourceMetadata,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
let partnerPortalBookingJwtGetter: () => string | null = () => null;
|
|
2
|
+
|
|
3
|
+
export function setPartnerPortalBookingJwtGetter(fn: () => string | null): void {
|
|
4
|
+
partnerPortalBookingJwtGetter = fn;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function getPartnerPortalBookingJwt(): string | null {
|
|
8
|
+
return partnerPortalBookingJwtGetter();
|
|
9
|
+
}
|
package/src/lib/env.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const getApiUrl = (): string => {
|
|
2
|
+
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
|
|
3
|
+
if (!apiUrl) return 'http://localhost:3001';
|
|
4
|
+
return apiUrl;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
const getBasicAuth = (): string => process.env.NEXT_PUBLIC_BASIC_AUTH ?? '';
|
|
8
|
+
const getCompanyId = (): string => process.env.NEXT_PUBLIC_COMPANY_ID ?? 'c_LFU0Vx9hS5v3';
|
|
9
|
+
|
|
10
|
+
export const ENV = {
|
|
11
|
+
API_URL: getApiUrl(),
|
|
12
|
+
BASIC_AUTH: getBasicAuth(),
|
|
13
|
+
COMPANY_ID: getCompanyId(),
|
|
14
|
+
} as const;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useContext,
|
|
6
|
+
useState,
|
|
7
|
+
useCallback,
|
|
8
|
+
useEffect,
|
|
9
|
+
useMemo,
|
|
10
|
+
useRef,
|
|
11
|
+
type ReactNode,
|
|
12
|
+
} from 'react';
|
|
13
|
+
import { getOrCreateBookingCorrelationId } from '../correlation-id';
|
|
14
|
+
import { withBookingOutboundHeaders } from '../trace-context';
|
|
15
|
+
import {
|
|
16
|
+
isSuspiciousBookingProductId,
|
|
17
|
+
normalizeBookingProductId,
|
|
18
|
+
} from '../lib/booking/normalize-booking-product-id';
|
|
19
|
+
import { ENV } from '../lib/env';
|
|
20
|
+
|
|
21
|
+
export type ProductGridFilterId =
|
|
22
|
+
| 'all'
|
|
23
|
+
| 'sunrise'
|
|
24
|
+
| 'moraine-lake'
|
|
25
|
+
| 'lake-louise'
|
|
26
|
+
| 'emerald-lake'
|
|
27
|
+
| 'private';
|
|
28
|
+
|
|
29
|
+
export type BookingScreen =
|
|
30
|
+
| { type: 'product-grid'; filterId?: ProductGridFilterId }
|
|
31
|
+
| { type: 'book-flow'; productId: string };
|
|
32
|
+
|
|
33
|
+
export type ProductGridRestoreState = {
|
|
34
|
+
expandedId: string | null;
|
|
35
|
+
scrollTop: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const OPEN_BOOKING_FOR_PRODUCT = 'openBookingForProduct';
|
|
39
|
+
export const OPEN_BOOKING_WITH_FILTER = 'openBookingWithFilter';
|
|
40
|
+
export const BOOKING_FLOW_ABANDON_EVENT = 'ticketbooth:booking-flow-abandon';
|
|
41
|
+
|
|
42
|
+
function reportSuspiciousBookingProductId(original: string, sanitized: string): void {
|
|
43
|
+
if (typeof window === 'undefined') return;
|
|
44
|
+
const telemetryEndpoint = `${ENV.API_URL}/1/client-telemetry`;
|
|
45
|
+
const correlationId = getOrCreateBookingCorrelationId();
|
|
46
|
+
const event = {
|
|
47
|
+
event: 'BOOKING_DIALOG_SUSPICIOUS_PRODUCT_ID',
|
|
48
|
+
endpoint: '/booking-open',
|
|
49
|
+
correlationId,
|
|
50
|
+
originalProductId: original,
|
|
51
|
+
sanitizedProductId: sanitized,
|
|
52
|
+
pageUrl: window.location.href,
|
|
53
|
+
referrer: document.referrer || null,
|
|
54
|
+
userAgent: window.navigator.userAgent,
|
|
55
|
+
occurredAt: new Date().toISOString(),
|
|
56
|
+
stack: new Error('Suspicious booking productId input').stack ?? null,
|
|
57
|
+
};
|
|
58
|
+
fetch(telemetryEndpoint, {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: withBookingOutboundHeaders({ 'Content-Type': 'application/json' }),
|
|
61
|
+
body: JSON.stringify(event),
|
|
62
|
+
keepalive: true,
|
|
63
|
+
}).catch(() => {});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface BookingDialogContextValue {
|
|
67
|
+
isOpen: boolean;
|
|
68
|
+
open: (options?: { filterId?: ProductGridFilterId }) => void;
|
|
69
|
+
openForProduct: (productId: string) => void;
|
|
70
|
+
close: (options?: { reason?: 'user' | 'completed' }) => void;
|
|
71
|
+
stack: BookingScreen[];
|
|
72
|
+
push: (screen: BookingScreen, options?: { productGridState?: ProductGridRestoreState }) => void;
|
|
73
|
+
pop: () => void;
|
|
74
|
+
canGoBack: boolean;
|
|
75
|
+
productGridRestoreState: ProductGridRestoreState | null;
|
|
76
|
+
clearProductGridRestoreState: () => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const BookingDialogContext = createContext<BookingDialogContextValue | null>(null);
|
|
80
|
+
|
|
81
|
+
export function useBookingDialog() {
|
|
82
|
+
const ctx = useContext(BookingDialogContext);
|
|
83
|
+
if (!ctx) throw new Error('useBookingDialog must be used within BookingDialogProvider');
|
|
84
|
+
return ctx;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function BookingDialogProvider({ children }: { children: ReactNode }) {
|
|
88
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
89
|
+
const [stack, setStack] = useState<BookingScreen[]>([{ type: 'product-grid' }]);
|
|
90
|
+
const [productGridRestoreState, setProductGridRestoreState] = useState<ProductGridRestoreState | null>(null);
|
|
91
|
+
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
|
|
92
|
+
|
|
93
|
+
const open = useCallback((options?: { filterId?: ProductGridFilterId }) => {
|
|
94
|
+
previouslyFocusedRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
95
|
+
setStack([{ type: 'product-grid', filterId: options?.filterId }]);
|
|
96
|
+
setProductGridRestoreState(null);
|
|
97
|
+
setIsOpen(true);
|
|
98
|
+
}, []);
|
|
99
|
+
|
|
100
|
+
const openForProduct = useCallback((productId: string) => {
|
|
101
|
+
const sanitizedProductId = normalizeBookingProductId(productId);
|
|
102
|
+
if (!sanitizedProductId) return;
|
|
103
|
+
if (isSuspiciousBookingProductId(productId)) {
|
|
104
|
+
reportSuspiciousBookingProductId(productId, sanitizedProductId);
|
|
105
|
+
}
|
|
106
|
+
previouslyFocusedRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
107
|
+
setStack([{ type: 'book-flow', productId: sanitizedProductId }]);
|
|
108
|
+
setProductGridRestoreState(null);
|
|
109
|
+
setIsOpen(true);
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
112
|
+
const close = useCallback((options?: { reason?: 'user' | 'completed' }) => {
|
|
113
|
+
if (options?.reason !== 'completed' && typeof window !== 'undefined') {
|
|
114
|
+
window.dispatchEvent(new Event(BOOKING_FLOW_ABANDON_EVENT));
|
|
115
|
+
}
|
|
116
|
+
const prev = previouslyFocusedRef.current;
|
|
117
|
+
setIsOpen(false);
|
|
118
|
+
requestAnimationFrame(() => {
|
|
119
|
+
if (prev && typeof prev.focus === 'function') prev.focus();
|
|
120
|
+
});
|
|
121
|
+
}, []);
|
|
122
|
+
|
|
123
|
+
const push = useCallback((screen: BookingScreen, options?: { productGridState?: ProductGridRestoreState }) => {
|
|
124
|
+
if (options?.productGridState) setProductGridRestoreState(options.productGridState);
|
|
125
|
+
setStack((prev) => [...prev, screen]);
|
|
126
|
+
}, []);
|
|
127
|
+
|
|
128
|
+
const pop = useCallback(() => {
|
|
129
|
+
setStack((prev) => (prev.length > 1 ? prev.slice(0, -1) : prev));
|
|
130
|
+
}, []);
|
|
131
|
+
|
|
132
|
+
const clearProductGridRestoreState = useCallback(() => {
|
|
133
|
+
setProductGridRestoreState(null);
|
|
134
|
+
}, []);
|
|
135
|
+
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
const handleOpen = () => open();
|
|
138
|
+
window.addEventListener('openSimpleModal', handleOpen);
|
|
139
|
+
return () => window.removeEventListener('openSimpleModal', handleOpen);
|
|
140
|
+
}, [open]);
|
|
141
|
+
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
const handleOpenWithFilter = (e: Event) => {
|
|
144
|
+
const customEvent = e as CustomEvent<{ filterId: ProductGridFilterId }>;
|
|
145
|
+
const filterId = customEvent.detail?.filterId;
|
|
146
|
+
if (filterId) open({ filterId });
|
|
147
|
+
else open();
|
|
148
|
+
};
|
|
149
|
+
window.addEventListener(OPEN_BOOKING_WITH_FILTER, handleOpenWithFilter);
|
|
150
|
+
return () => window.removeEventListener(OPEN_BOOKING_WITH_FILTER, handleOpenWithFilter);
|
|
151
|
+
}, [open]);
|
|
152
|
+
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
const handleOpenForProduct = (e: Event) => {
|
|
155
|
+
const customEvent = e as CustomEvent<{ productId: string }>;
|
|
156
|
+
const productId = customEvent.detail?.productId;
|
|
157
|
+
if (productId) openForProduct(productId);
|
|
158
|
+
};
|
|
159
|
+
window.addEventListener(OPEN_BOOKING_FOR_PRODUCT, handleOpenForProduct);
|
|
160
|
+
return () => window.removeEventListener(OPEN_BOOKING_FOR_PRODUCT, handleOpenForProduct);
|
|
161
|
+
}, [openForProduct]);
|
|
162
|
+
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
const handlePageShow = (e: PageTransitionEvent) => {
|
|
165
|
+
if (e.persisted && isOpen) close();
|
|
166
|
+
};
|
|
167
|
+
window.addEventListener('pageshow', handlePageShow);
|
|
168
|
+
return () => window.removeEventListener('pageshow', handlePageShow);
|
|
169
|
+
}, [isOpen, close]);
|
|
170
|
+
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
const handleBookNowClick = (e: Event) => {
|
|
173
|
+
const target = e.target as HTMLElement;
|
|
174
|
+
const link = target.closest('a[href^="#book-now"]') as HTMLAnchorElement | null;
|
|
175
|
+
if (!link) return;
|
|
176
|
+
e.preventDefault();
|
|
177
|
+
const href = link.getAttribute('href') ?? '';
|
|
178
|
+
const filterMatch = href.match(/^#book-now\/filter\/([a-z0-9-]+)$/i);
|
|
179
|
+
if (filterMatch) {
|
|
180
|
+
const filterId = filterMatch[1] as ProductGridFilterId;
|
|
181
|
+
if (['sunrise', 'moraine-lake', 'lake-louise', 'emerald-lake', 'private'].includes(filterId)) {
|
|
182
|
+
open({ filterId });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const productMatch = href.match(/^#book-now\/([a-z0-9-]+)$/i);
|
|
187
|
+
const productSlug = productMatch?.[1];
|
|
188
|
+
if (productSlug) openForProduct(productSlug);
|
|
189
|
+
else open();
|
|
190
|
+
};
|
|
191
|
+
document.addEventListener('click', handleBookNowClick);
|
|
192
|
+
return () => document.removeEventListener('click', handleBookNowClick);
|
|
193
|
+
}, [open, openForProduct]);
|
|
194
|
+
|
|
195
|
+
const canGoBack = stack.length > 1;
|
|
196
|
+
const value = useMemo(
|
|
197
|
+
() => ({
|
|
198
|
+
isOpen,
|
|
199
|
+
open,
|
|
200
|
+
openForProduct,
|
|
201
|
+
close,
|
|
202
|
+
stack,
|
|
203
|
+
push,
|
|
204
|
+
pop,
|
|
205
|
+
canGoBack,
|
|
206
|
+
productGridRestoreState,
|
|
207
|
+
clearProductGridRestoreState,
|
|
208
|
+
}),
|
|
209
|
+
[isOpen, open, openForProduct, close, stack, push, pop, canGoBack, productGridRestoreState, clearProductGridRestoreState]
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
return <BookingDialogContext.Provider value={value}>{children}</BookingDialogContext.Provider>;
|
|
213
|
+
}
|