@ticketboothapp/booking 1.2.48 → 1.2.50
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/BookingDetails.ts +18 -0
- package/src/components/booking/AddOnsSection.tsx +2 -2
- package/src/components/booking/AdminPaymentChoiceModal.tsx +1 -1
- package/src/components/booking/BookingDialog.tsx +1 -1
- package/src/components/booking/BookingFlow.tsx +16 -16
- package/src/components/booking/BookingFlowCollage.tsx +3 -2
- package/src/components/booking/BookingFlowPlaceholder.tsx +1 -1
- package/src/components/booking/BookingFlowPreview.tsx +1 -1
- package/src/components/booking/BookingProductGrid.tsx +4 -3
- package/src/components/booking/Calendar.tsx +5 -5
- package/src/components/booking/CancellationPolicySelector.tsx +2 -2
- package/src/components/booking/ChangeBookingDialog.tsx +6 -5
- package/src/components/booking/CheckoutForm.tsx +1 -1
- package/src/components/booking/CheckoutModal.tsx +3 -3
- package/src/components/booking/DapTourDescription.tsx +3 -3
- package/src/components/booking/DependentAddOnBookingDialog.tsx +7 -7
- package/src/components/booking/DependentAddOnPaymentForm.tsx +1 -1
- package/src/components/booking/ItineraryBox.tsx +6 -6
- package/src/components/booking/ItineraryBuilder.tsx +1 -1
- package/src/components/booking/MealDrinkAddOnSelector.tsx +3 -3
- package/src/components/booking/PickupLocationSelector.tsx +8 -8
- package/src/components/booking/PickupTimeSelector.tsx +2 -2
- package/src/components/booking/PriceBreakdown.tsx +4 -4
- package/src/components/booking/PriceSummary.tsx +3 -3
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +11 -11
- package/src/components/booking/PromoCodeInput.tsx +1 -1
- package/src/components/booking/ReturnTimeSelector.tsx +2 -2
- package/src/components/booking/TicketSelector.tsx +1 -1
- package/src/components/booking/TourDescription.tsx +1 -1
- package/src/constants/images.ts +556 -0
- package/src/constants/products.ts +33 -0
- package/src/contexts/AvailabilitiesCacheContext.tsx +125 -0
- package/src/contexts/CompanyContext.tsx +70 -0
- package/src/data/dap-descriptions/session-couples-families-friends.en.json +61 -0
- package/src/data/dap-descriptions/session-elopements.en.json +60 -0
- package/src/data/dap-descriptions/session-proposals.en.json +60 -0
- package/src/hooks/useBookingSourceMetadataFromLocation.ts +1 -1
- package/src/hooks/useIsBookingLaunchLive.ts +1 -1
- package/src/lib/booking/booking-source.ts +51 -0
- package/src/lib/booking/checkout-breakdown.ts +69 -0
- package/src/lib/booking/correlation-id.ts +46 -0
- package/src/lib/booking/i18n/config.ts +21 -0
- package/src/lib/booking/i18n/index.tsx +144 -0
- package/src/lib/booking/i18n/messages/en.json +236 -0
- package/src/lib/booking/i18n/messages/fr.json +236 -0
- package/src/lib/booking/itinerary-display.ts +36 -0
- package/src/lib/booking/itinerary-labels.ts +70 -0
- package/src/lib/booking/location-calculations.ts +43 -0
- package/src/lib/booking/location-utils.ts +165 -0
- package/src/lib/booking/map-utils.ts +153 -0
- package/src/lib/booking/marker-icons.ts +113 -0
- package/src/lib/booking/normalize-booking-product-id.ts +21 -0
- package/src/lib/booking/pickup-location-types.ts +25 -0
- package/src/lib/booking/places-api.ts +154 -0
- package/src/lib/booking/pricing.ts +466 -0
- package/src/lib/booking/product-option-id.ts +35 -0
- package/src/lib/booking/source-metadata.ts +226 -0
- package/src/lib/booking/sunday-week.ts +14 -0
- package/src/lib/booking/trace-context.ts +62 -0
- package/src/lib/booking/utils.ts +9 -0
- package/src/lib/booking-api.ts +1793 -0
- package/src/lib/booking-constants.ts +23 -0
- package/src/lib/booking-ref.ts +13 -0
- package/src/lib/booking-types.ts +36 -0
- package/src/lib/currency.ts +81 -0
- package/src/lib/dap-descriptions.ts +50 -0
- package/src/lib/dap-itinerary-preview.ts +315 -0
- package/src/lib/dependent-add-on-api.ts +434 -0
- package/src/lib/env.ts +102 -0
- package/src/lib/manage-booking-post-checkout.ts +68 -0
- package/src/lib/photo-dap-config.ts +228 -0
- package/src/providers/booking-dialog-provider.tsx +3 -3
- package/src/providers/dependent-add-on-dialog-provider.tsx +105 -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/types.d.ts +11 -0
- package/ticketboothapp-booking-1.2.48.tgz +0 -0
- package/ticketboothapp-booking-1.2.49.tgz +0 -0
- package/tsconfig.json +3 -2
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useContext,
|
|
6
|
+
useRef,
|
|
7
|
+
useCallback,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import type { Availability, PricingConfig, PrecomputedPricesByCategory } from '../lib/booking-api';
|
|
11
|
+
|
|
12
|
+
/** Cache TTL in milliseconds (5 minutes). Entries older than this are considered stale. */
|
|
13
|
+
export const AVAILABILITIES_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
14
|
+
|
|
15
|
+
export interface CachedAvailabilitiesData {
|
|
16
|
+
fetchedRanges: Array<{ start: Date; end: Date }>;
|
|
17
|
+
availabilities: Availability[];
|
|
18
|
+
pricingConfig: PricingConfig | null;
|
|
19
|
+
precomputedPricesByOption: Record<string, PrecomputedPricesByCategory> | null;
|
|
20
|
+
/** Private Shuttle: shared precomputed prices (category -> currency -> price). */
|
|
21
|
+
precomputedPrices?: PrecomputedPricesByCategory | null;
|
|
22
|
+
/** Private Shuttle: resource price by currency. */
|
|
23
|
+
resourcePriceByCurrency?: Record<string, number> | null;
|
|
24
|
+
/** Private Shuttle: resource price by option (optionId -> currency -> price). */
|
|
25
|
+
resourcePriceByOption?: Record<string, Record<string, number>> | null;
|
|
26
|
+
/** Timestamp when this entry was cached (for TTL / stale-while-revalidate). */
|
|
27
|
+
cachedAt: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface AvailabilitiesCacheContextValue {
|
|
31
|
+
/** Get cached data for a product+options+promo. Returns undefined if not cached. */
|
|
32
|
+
get: (cacheKey: string) => CachedAvailabilitiesData | undefined;
|
|
33
|
+
/** Check if cached entry is stale (older than TTL). Use for stale-while-revalidate. */
|
|
34
|
+
isStale: (cached: CachedAvailabilitiesData) => boolean;
|
|
35
|
+
/** Replace or set cache entry. */
|
|
36
|
+
set: (cacheKey: string, data: CachedAvailabilitiesData) => void;
|
|
37
|
+
/** Merge new availabilities into existing cache (or create new entry). */
|
|
38
|
+
merge: (
|
|
39
|
+
cacheKey: string,
|
|
40
|
+
update: {
|
|
41
|
+
fetchedRanges?: Array<{ start: Date; end: Date }>;
|
|
42
|
+
availabilities?: Availability[];
|
|
43
|
+
pricingConfig?: PricingConfig | null;
|
|
44
|
+
precomputedPricesByOption?: Record<string, PrecomputedPricesByCategory> | null;
|
|
45
|
+
precomputedPrices?: PrecomputedPricesByCategory | null;
|
|
46
|
+
resourcePriceByCurrency?: Record<string, number> | null;
|
|
47
|
+
resourcePriceByOption?: Record<string, Record<string, number>> | null;
|
|
48
|
+
}
|
|
49
|
+
) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const AvailabilitiesCacheContext = createContext<AvailabilitiesCacheContextValue | null>(null);
|
|
53
|
+
|
|
54
|
+
export function useAvailabilitiesCache() {
|
|
55
|
+
const ctx = useContext(AvailabilitiesCacheContext);
|
|
56
|
+
return ctx; // May be null if not wrapped
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Build cache key from product + options + promo + optional partner pricing profile. */
|
|
60
|
+
export function buildAvailabilitiesCacheKey(
|
|
61
|
+
productId: string,
|
|
62
|
+
optionIdsKey: string,
|
|
63
|
+
promoCode: string | null,
|
|
64
|
+
pricingProfileId?: string | null
|
|
65
|
+
): string {
|
|
66
|
+
const pp = (pricingProfileId ?? '').trim();
|
|
67
|
+
return `${productId}|${optionIdsKey}|${promoCode ?? ''}|pp:${pp}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function AvailabilitiesCacheProvider({ children }: { children: ReactNode }) {
|
|
71
|
+
const cacheRef = useRef<Map<string, CachedAvailabilitiesData>>(new Map());
|
|
72
|
+
|
|
73
|
+
const get = useCallback((cacheKey: string) => {
|
|
74
|
+
return cacheRef.current.get(cacheKey);
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
const isStale = useCallback((cached: CachedAvailabilitiesData) => {
|
|
78
|
+
const age = Date.now() - (cached.cachedAt ?? 0);
|
|
79
|
+
return age > AVAILABILITIES_CACHE_TTL_MS;
|
|
80
|
+
}, []);
|
|
81
|
+
|
|
82
|
+
const set = useCallback((cacheKey: string, data: CachedAvailabilitiesData) => {
|
|
83
|
+
cacheRef.current.set(cacheKey, data);
|
|
84
|
+
}, []);
|
|
85
|
+
|
|
86
|
+
const merge = useCallback(
|
|
87
|
+
(
|
|
88
|
+
cacheKey: string,
|
|
89
|
+
update: {
|
|
90
|
+
fetchedRanges?: Array<{ start: Date; end: Date }>;
|
|
91
|
+
availabilities?: Availability[];
|
|
92
|
+
pricingConfig?: PricingConfig | null;
|
|
93
|
+
precomputedPricesByOption?: Record<string, PrecomputedPricesByCategory> | null;
|
|
94
|
+
precomputedPrices?: PrecomputedPricesByCategory | null;
|
|
95
|
+
resourcePriceByCurrency?: Record<string, number> | null;
|
|
96
|
+
resourcePriceByOption?: Record<string, Record<string, number>> | null;
|
|
97
|
+
}
|
|
98
|
+
) => {
|
|
99
|
+
const existing = cacheRef.current.get(cacheKey);
|
|
100
|
+
const next: CachedAvailabilitiesData = {
|
|
101
|
+
fetchedRanges: update.fetchedRanges ?? existing?.fetchedRanges ?? [],
|
|
102
|
+
availabilities: update.availabilities ?? existing?.availabilities ?? [],
|
|
103
|
+
pricingConfig: update.pricingConfig !== undefined ? update.pricingConfig : existing?.pricingConfig ?? null,
|
|
104
|
+
precomputedPricesByOption:
|
|
105
|
+
update.precomputedPricesByOption !== undefined
|
|
106
|
+
? update.precomputedPricesByOption
|
|
107
|
+
: existing?.precomputedPricesByOption ?? null,
|
|
108
|
+
precomputedPrices: update.precomputedPrices !== undefined ? update.precomputedPrices : existing?.precomputedPrices ?? null,
|
|
109
|
+
resourcePriceByCurrency: update.resourcePriceByCurrency !== undefined ? update.resourcePriceByCurrency : existing?.resourcePriceByCurrency ?? null,
|
|
110
|
+
resourcePriceByOption: update.resourcePriceByOption !== undefined ? update.resourcePriceByOption : existing?.resourcePriceByOption ?? null,
|
|
111
|
+
cachedAt: Date.now(),
|
|
112
|
+
};
|
|
113
|
+
cacheRef.current.set(cacheKey, next);
|
|
114
|
+
},
|
|
115
|
+
[]
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const value: AvailabilitiesCacheContextValue = { get, set, merge, isStale };
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<AvailabilitiesCacheContext.Provider value={value}>
|
|
122
|
+
{children}
|
|
123
|
+
</AvailabilitiesCacheContext.Provider>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
|
4
|
+
import { getCompany, type Company } from '../lib/booking-api';
|
|
5
|
+
import { ENV } from '../lib/env';
|
|
6
|
+
|
|
7
|
+
interface CompanyContextType {
|
|
8
|
+
company: Company | null;
|
|
9
|
+
loading: boolean;
|
|
10
|
+
error: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const CompanyContext = createContext<CompanyContextType | undefined>(undefined);
|
|
14
|
+
|
|
15
|
+
interface CompanyProviderProps {
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Provider component that fetches company data once and makes it available via context
|
|
21
|
+
*/
|
|
22
|
+
export function CompanyProvider({ children }: CompanyProviderProps) {
|
|
23
|
+
const [company, setCompany] = useState<Company | null>(null);
|
|
24
|
+
const [loading, setLoading] = useState(true);
|
|
25
|
+
const [error, setError] = useState<string | null>(null);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
async function fetchCompany() {
|
|
29
|
+
try {
|
|
30
|
+
const companyData = await getCompany(ENV.COMPANY_ID);
|
|
31
|
+
setCompany(companyData);
|
|
32
|
+
setError(null);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.warn('Failed to fetch company data, using defaults:', err);
|
|
35
|
+
setError(err instanceof Error ? err.message : 'Failed to fetch company');
|
|
36
|
+
// Don't set company to null - let components use defaults
|
|
37
|
+
} finally {
|
|
38
|
+
setLoading(false);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
fetchCompany();
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<CompanyContext.Provider value={{ company, loading, error }}>
|
|
47
|
+
{children}
|
|
48
|
+
</CompanyContext.Provider>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Hook to access company context
|
|
54
|
+
* Returns company data, loading state, and error
|
|
55
|
+
*/
|
|
56
|
+
export function useCompany() {
|
|
57
|
+
const context = useContext(CompanyContext);
|
|
58
|
+
if (context === undefined) {
|
|
59
|
+
throw new Error('useCompany must be used within a CompanyProvider');
|
|
60
|
+
}
|
|
61
|
+
return context;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Hook to get company timezone with fallback
|
|
66
|
+
*/
|
|
67
|
+
export function useCompanyTimezone(): string {
|
|
68
|
+
const { company } = useCompany();
|
|
69
|
+
return company?.settings?.timezone || 'America/Edmonton';
|
|
70
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"paragraphs": [
|
|
3
|
+
"Looking to create memories with your loved ones in one of the most breathtaking places on earth? Let us help you turn it into an unforgettable experience.",
|
|
4
|
+
"Whether it's a joyful family gathering, a meaningful moment with friends, or a romantic couples shoot, we know how to blend into the moment while capturing it beautifully - candid, natural, and full of connection."
|
|
5
|
+
],
|
|
6
|
+
"sections": [
|
|
7
|
+
{
|
|
8
|
+
"title": "What's included",
|
|
9
|
+
"content": [
|
|
10
|
+
{
|
|
11
|
+
"title": "30-minute session",
|
|
12
|
+
"items": [
|
|
13
|
+
"📸 20 to 25 minutes of photography time",
|
|
14
|
+
"📍 2 Locations within walking distance",
|
|
15
|
+
"🖼️ 25 professionally edited photos delivered in a personalized online gallery"
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"title": "60-minute session",
|
|
20
|
+
"items": [
|
|
21
|
+
"📸 50 to 55 minutes of photography time",
|
|
22
|
+
"📍 2-3 Locations within walking distance",
|
|
23
|
+
"🖼️ 50 professionally edited photos delivered in a personalized online gallery"
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"title": "90-minute session",
|
|
28
|
+
"items": [
|
|
29
|
+
"📸 80-90 mins of Photography Time",
|
|
30
|
+
"📍 5 Locations within walking distance",
|
|
31
|
+
"🖼️ 75 professionally edited photos delivered in a personalized online gallery"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"title": "Other details",
|
|
36
|
+
"items": [
|
|
37
|
+
"🖥️ Editing: Basic exposure and lighting adjustments + colour corrections",
|
|
38
|
+
"👭🏽 10 People Max per session"
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"title": "Available add ons",
|
|
45
|
+
"content": [
|
|
46
|
+
"🏃♀️ Express Service - 7 Day turnaround on images - $300",
|
|
47
|
+
"🏃♀️💨 Super Express Service - 48-hour turnaround on images - $900",
|
|
48
|
+
"🔬 Additional Editing: Object Removal / Skin Retouching - $199/item",
|
|
49
|
+
"➕ Additional Images: 15 Additional Images - $99",
|
|
50
|
+
"♾️ The Works - Get ALL additional images taken on the day - $199"
|
|
51
|
+
]
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"title": "Exclusions",
|
|
55
|
+
"content": [
|
|
56
|
+
"💵 Tips or Gratuities",
|
|
57
|
+
"🅿️ Parking Fees"
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"paragraphs": [
|
|
3
|
+
"Planning to elope in one of the most breathtaking places on earth? Let us help you make it truly unforgettable. We know how to blend into the moment while capturing the joy of an intimate celebration beautifully."
|
|
4
|
+
],
|
|
5
|
+
"sections": [
|
|
6
|
+
{
|
|
7
|
+
"title": "What's included",
|
|
8
|
+
"content": [
|
|
9
|
+
{
|
|
10
|
+
"title": "30-minute session",
|
|
11
|
+
"items": [
|
|
12
|
+
"📸 20 to 25 minutes of photography time",
|
|
13
|
+
"📍 2 Locations within walking distance",
|
|
14
|
+
"🖼️ 25 professionally edited photos delivered in a personalized online gallery"
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"title": "60-minute session",
|
|
19
|
+
"items": [
|
|
20
|
+
"📸 50 to 55 minutes of photography time",
|
|
21
|
+
"📍 2-3 Locations within walking distance",
|
|
22
|
+
"🖼️ 50 professionally edited photos delivered in a personalized online gallery"
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"title": "90-minute session",
|
|
27
|
+
"items": [
|
|
28
|
+
"📸 80-90 mins of Photography Time",
|
|
29
|
+
"📍 5 Locations within walking distance",
|
|
30
|
+
"🖼️ 75 professionally edited photos delivered in a personalized online gallery"
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"title": "Other details",
|
|
35
|
+
"items": [
|
|
36
|
+
"🖥️ Editing: Basic exposure and lighting adjustments + colour corrections",
|
|
37
|
+
"👭🏽 2 People Max per session"
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
]
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"title": "Available add ons",
|
|
44
|
+
"content": [
|
|
45
|
+
"🏃♀️ Express Service - 7 Day turnaround on images - $300",
|
|
46
|
+
"🏃♀️💨 Super Express Service - 48-hour turnaround on images - $900",
|
|
47
|
+
"🔬 Additional Editing: Object Removal / Skin Retouching - $199/item",
|
|
48
|
+
"➕ Additional Images: 15 Additional Images - $99",
|
|
49
|
+
"♾️ The Works - Get ALL additional images taken on the day - $199"
|
|
50
|
+
]
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"title": "Exclusions",
|
|
54
|
+
"content": [
|
|
55
|
+
"💵 Tips or Gratuities",
|
|
56
|
+
"🅿️ Parking Fees"
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"paragraphs": [
|
|
3
|
+
"Planning to propose in one of the most breathtaking places on earth? Let us help you make it truly unforgettable. We know how to blend into the moment while capturing the joy of an intimate celebration beautifully."
|
|
4
|
+
],
|
|
5
|
+
"sections": [
|
|
6
|
+
{
|
|
7
|
+
"title": "What's included",
|
|
8
|
+
"content": [
|
|
9
|
+
{
|
|
10
|
+
"title": "30-minute session",
|
|
11
|
+
"items": [
|
|
12
|
+
"📸 20 to 25 minutes of photography time",
|
|
13
|
+
"📍 2 Locations within walking distance",
|
|
14
|
+
"🖼️ 25 professionally edited photos delivered in a personalized online gallery"
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"title": "60-minute session",
|
|
19
|
+
"items": [
|
|
20
|
+
"📸 50 to 55 minutes of photography time",
|
|
21
|
+
"📍 2-3 Locations within walking distance",
|
|
22
|
+
"🖼️ 50 professionally edited photos delivered in a personalized online gallery"
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"title": "90-minute session",
|
|
27
|
+
"items": [
|
|
28
|
+
"📸 80-90 mins of Photography Time",
|
|
29
|
+
"📍 5 Locations within walking distance",
|
|
30
|
+
"🖼️ 75 professionally edited photos delivered in a personalized online gallery"
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"title": "Other details",
|
|
35
|
+
"items": [
|
|
36
|
+
"🖥️ Editing: Basic exposure and lighting adjustments + colour corrections",
|
|
37
|
+
"👭🏽 2 People Max per session"
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
]
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"title": "Available add ons",
|
|
44
|
+
"content": [
|
|
45
|
+
"🏃♀️ Express Service - 7 Day turnaround on images - $300",
|
|
46
|
+
"🏃♀️💨 Super Express Service - 48-hour turnaround on images - $900",
|
|
47
|
+
"🔬 Additional Editing: Object Removal / Skin Retouching - $199/item",
|
|
48
|
+
"➕ Additional Images: 15 Additional Images - $99",
|
|
49
|
+
"♾️ The Works - Get ALL additional images taken on the day - $199"
|
|
50
|
+
]
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"title": "Exclusions",
|
|
54
|
+
"content": [
|
|
55
|
+
"💵 Tips or Gratuities",
|
|
56
|
+
"🅿️ Parking Fees"
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
}
|
|
@@ -5,7 +5,7 @@ import { usePathname } from 'next/navigation';
|
|
|
5
5
|
import {
|
|
6
6
|
buildBookingSourceMetadataFromLocation,
|
|
7
7
|
type BookingSourceMetadata,
|
|
8
|
-
} from '
|
|
8
|
+
} from '../lib/booking/source-metadata';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Re-reads URL-derived booking attribution when the **path** changes. Query-only updates on the
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect } from 'react';
|
|
4
|
-
import { BOOKING_LAUNCH_AT } from '
|
|
4
|
+
import { BOOKING_LAUNCH_AT } from '../lib/booking-constants';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Returns whether the full booking flow is live (past launch time).
|
|
@@ -0,0 +1,51 @@
|
|
|
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
|
+
*/
|
|
6
|
+
export enum KnownBookingSource {
|
|
7
|
+
WEBSITE = 'WEBSITE',
|
|
8
|
+
/** Main-site partner embed (e.g. `/partner/{slug}`); not the dedicated `booking.*` portal app. */
|
|
9
|
+
PUBLIC_PARTNER_WEBSITE = 'PUBLIC_PARTNER_WEBSITE',
|
|
10
|
+
PARTNER_PORTAL = 'PARTNER_PORTAL',
|
|
11
|
+
AFFILIATE = 'AFFILIATE',
|
|
12
|
+
DASHBOARD = 'DASHBOARD',
|
|
13
|
+
GYG = 'GYG',
|
|
14
|
+
VIATOR = 'VIATOR',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Default reserve/checkout `source` when the client does not send a more specific channel. */
|
|
18
|
+
export const DEFAULT_BOOKING_SOURCE = KnownBookingSource.WEBSITE;
|
|
19
|
+
|
|
20
|
+
/** Client `source` when booking through the signed-in partner org / marketing partner flows. */
|
|
21
|
+
export const PARTNER_PORTAL_BOOKING_SOURCE = KnownBookingSource.PARTNER_PORTAL;
|
|
22
|
+
|
|
23
|
+
const PUBLIC_BOOKING_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
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
|
+
*/
|
|
30
|
+
export function mergedMetadataImpliesPartnerPortal(
|
|
31
|
+
merged: Partial<{ partnerId?: string; partnerSlug?: string }>,
|
|
32
|
+
): boolean {
|
|
33
|
+
const pid = typeof merged.partnerId === 'string' ? merged.partnerId.trim() : '';
|
|
34
|
+
if (pid.startsWith('par_')) return true;
|
|
35
|
+
const slug = typeof merged.partnerSlug === 'string' ? merged.partnerSlug.trim().toLowerCase() : '';
|
|
36
|
+
return slug.length > 0 && PUBLIC_BOOKING_SLUG_RE.test(slug);
|
|
37
|
+
}
|
|
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
|
+
*/
|
|
43
|
+
export function inferClientBookingSourceFromProductIds(
|
|
44
|
+
productId: string,
|
|
45
|
+
productOptionId?: string | null,
|
|
46
|
+
): KnownBookingSource {
|
|
47
|
+
const haystack = `${productId} ${productOptionId ?? ''}`.toLowerCase();
|
|
48
|
+
if (haystack.includes('gyg_')) return KnownBookingSource.GYG;
|
|
49
|
+
if (haystack.includes('viator_')) return KnownBookingSource.VIATOR;
|
|
50
|
+
return KnownBookingSource.WEBSITE;
|
|
51
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { CheckoutBreakdown, CheckoutReceiptLine } from '../booking-api';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Round to 2 decimal places. Used so breakdown amounts and totals are stable
|
|
5
|
+
* and pass backend validation (sum of line items ≈ totalAmount within 0.02).
|
|
6
|
+
*/
|
|
7
|
+
export function round2(n: number): number {
|
|
8
|
+
return Math.round(n * 100) / 100;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface CheckoutLineInput {
|
|
12
|
+
label: string;
|
|
13
|
+
amount: number;
|
|
14
|
+
type: string;
|
|
15
|
+
quantity?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Line types for checkout breakdown. ADDITIONAL_HOURS reserved for future private shuttle extra hours. */
|
|
19
|
+
export const CheckoutLineType = {
|
|
20
|
+
TICKET: 'TICKET',
|
|
21
|
+
DEPOSIT: 'DEPOSIT',
|
|
22
|
+
FEE: 'FEE',
|
|
23
|
+
RETURN_OPTION: 'return',
|
|
24
|
+
CANCELLATION_UPGRADE: 'cancellation',
|
|
25
|
+
TAX: 'TAX',
|
|
26
|
+
PROMO_CODE: 'PROMO_CODE',
|
|
27
|
+
ROUNDING: 'ROUNDING',
|
|
28
|
+
ADDITIONAL_HOURS: 'ADDITIONAL_HOURS', // Future: private shuttle extra hours line
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
export interface BuildCheckoutBreakdownParams {
|
|
32
|
+
/** Line items in display order (tickets, return, cancellation, fees, tax, promo, etc.) */
|
|
33
|
+
lines: CheckoutLineInput[];
|
|
34
|
+
/** Total amount to charge (will be rounded to 2 decimals). */
|
|
35
|
+
totalAmount: number;
|
|
36
|
+
currency: string;
|
|
37
|
+
/** Label for rounding line when added (e.g. "Rounding"). */
|
|
38
|
+
roundingLabel: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Builds a CheckoutBreakdown from the given lines and total.
|
|
43
|
+
* Rounds each line amount to 2 decimals; if the sum differs from total by more than 0.02,
|
|
44
|
+
* adds a rounding line so the backend validator accepts it.
|
|
45
|
+
* Reused by BookingFlow and PrivateShuttleBookingFlow so Stripe and /manage match the UI.
|
|
46
|
+
*/
|
|
47
|
+
export function buildCheckoutBreakdown(params: BuildCheckoutBreakdownParams): CheckoutBreakdown {
|
|
48
|
+
const { lines, totalAmount, currency, roundingLabel } = params;
|
|
49
|
+
const totalRounded = round2(totalAmount);
|
|
50
|
+
const lineItems: CheckoutReceiptLine[] = lines.map((line) => ({
|
|
51
|
+
label: line.label,
|
|
52
|
+
amount: round2(line.amount),
|
|
53
|
+
type: line.type,
|
|
54
|
+
quantity: line.quantity,
|
|
55
|
+
}));
|
|
56
|
+
const sumLines = lineItems.reduce((s, l) => s + l.amount, 0);
|
|
57
|
+
if (Math.abs(sumLines - totalRounded) > 0.02) {
|
|
58
|
+
lineItems.push({
|
|
59
|
+
label: roundingLabel,
|
|
60
|
+
amount: round2(totalRounded - sumLines),
|
|
61
|
+
type: 'ROUNDING',
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
lineItems,
|
|
66
|
+
totalAmount: totalRounded,
|
|
67
|
+
currency,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One opaque id per browser tab/session for joining client telemetry → TicketBooth Lambda logs.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const BOOKING_CORRELATION_HEADER = 'X-Correlation-Id';
|
|
6
|
+
|
|
7
|
+
const STORAGE_KEY = 'tb_booking_correlation_id';
|
|
8
|
+
|
|
9
|
+
function newCorrelationId(): string {
|
|
10
|
+
try {
|
|
11
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
12
|
+
return crypto.randomUUID();
|
|
13
|
+
}
|
|
14
|
+
} catch {
|
|
15
|
+
/* fall through */
|
|
16
|
+
}
|
|
17
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2, 12)}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Stable for the lifetime of this tab's sessionStorage (until the tab closes / storage cleared). */
|
|
21
|
+
export function getOrCreateBookingCorrelationId(): string {
|
|
22
|
+
if (typeof window === 'undefined') return '';
|
|
23
|
+
try {
|
|
24
|
+
let id = sessionStorage.getItem(STORAGE_KEY);
|
|
25
|
+
if (!id) {
|
|
26
|
+
id = newCorrelationId();
|
|
27
|
+
sessionStorage.setItem(STORAGE_KEY, id);
|
|
28
|
+
}
|
|
29
|
+
return id;
|
|
30
|
+
} catch {
|
|
31
|
+
return newCorrelationId();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Merge correlation header into outbound API headers (browser only). */
|
|
36
|
+
export function withBookingCorrelationId(
|
|
37
|
+
headers: Record<string, string>
|
|
38
|
+
): Record<string, string> {
|
|
39
|
+
if (typeof window === 'undefined') return headers;
|
|
40
|
+
const id = getOrCreateBookingCorrelationId();
|
|
41
|
+
if (!id) return headers;
|
|
42
|
+
return {
|
|
43
|
+
...headers,
|
|
44
|
+
[BOOKING_CORRELATION_HEADER]: id,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* i18n Configuration
|
|
3
|
+
*
|
|
4
|
+
* This file sets up the internationalization configuration.
|
|
5
|
+
* Currently supports English (en) as default, ready to add more languages.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const locales = ['en', 'fr'] as const;
|
|
9
|
+
export type Locale = (typeof locales)[number];
|
|
10
|
+
|
|
11
|
+
export const defaultLocale: Locale = 'en';
|
|
12
|
+
|
|
13
|
+
// Language display names
|
|
14
|
+
export const languageNames: Record<Locale, string> = {
|
|
15
|
+
en: 'English',
|
|
16
|
+
fr: 'Français',
|
|
17
|
+
// Add more languages here:
|
|
18
|
+
// es: 'Español',
|
|
19
|
+
// de: 'Deutsch',
|
|
20
|
+
};
|
|
21
|
+
|