@ticketboothapp/booking 1.2.24 → 1.2.25
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 +29 -2
- package/src/assets/icons/minus.svg +7 -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 +2264 -0
- package/src/components/BookingWidget.tsx +305 -0
- package/src/components/ManageBookingView.tsx +437 -0
- 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/PostBookingDependentAddOnUpsell.module.css +174 -0
- package/src/components/PostBookingDependentAddOnUpsell.tsx +407 -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/booking/Calendar.tsx +1123 -0
- 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/booking/CheckoutModal.tsx +451 -0
- package/src/components/booking/CurrencySwitcher.tsx +81 -0
- 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/ErrorBoundary.tsx +63 -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/booking/ItineraryBuilder.tsx +82 -0
- package/src/components/booking/ItineraryPlaceholder.module.css +45 -0
- package/src/components/booking/ItineraryPlaceholder.tsx +26 -0
- package/src/components/booking/MealDrinkAddOnSelector.tsx +338 -0
- package/src/components/booking/PickupLocationSelector.module.css +124 -0
- package/src/components/booking/PickupLocationSelector.tsx +1566 -0
- package/src/components/booking/PickupTimeSelector.module.css +134 -0
- package/src/components/booking/PickupTimeSelector.tsx +112 -0
- package/src/components/booking/PriceBreakdown.tsx +154 -0
- package/src/components/booking/PriceSummary.tsx +234 -0
- 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/booking/TermsAcceptance.tsx +111 -0
- 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 +38 -0
- package/src/components/booking/booking-flow.css +944 -0
- package/src/components/button.css +245 -0
- package/src/components/button.tsx +152 -0
- package/src/components/colorable-svg.tsx +29 -0
- package/src/components/image.css +29 -0
- package/src/components/image.tsx +113 -0
- package/src/components/partner/PartnerBookingPage.module.css +130 -0
- package/src/components/partner/PartnerBookingPage.tsx +390 -0
- package/src/components/partner/PartnerBookingPageWithBrowserMetadata.tsx +45 -0
- package/src/components/product-tag.module.css +30 -0
- package/src/components/product-tag.tsx +34 -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/terms/TermsContent.tsx +178 -0
- package/src/components/value-pill.module.css +59 -0
- package/src/components/value-pill.tsx +46 -0
- package/src/constants/images.ts +556 -0
- package/src/constants/pill-values.ts +210 -0
- package/src/constants/products.ts +155 -0
- package/src/contexts/AvailabilitiesCacheContext.tsx +125 -0
- package/src/contexts/BookingAppContext.tsx +134 -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/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/useBookingSourceMetadataFromLocation.ts +21 -0
- package/src/hooks/useIsBookingLaunchLive.ts +49 -0
- package/src/index.ts +79 -0
- package/src/lib/analytics.ts +197 -0
- 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/theme.ts +83 -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 +96 -0
- 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/photo-packages.ts +75 -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 +282 -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 +56 -0
- package/src/utils/currency-converter.ts +101 -0
- package/tsconfig.json +8 -2
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Location utilities for distance calculation and travel time estimation
|
|
2
|
+
|
|
3
|
+
export interface Coordinates {
|
|
4
|
+
lat: number;
|
|
5
|
+
lng: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Calculate the distance between two coordinates using the Haversine formula
|
|
10
|
+
* Returns distance in kilometers
|
|
11
|
+
*/
|
|
12
|
+
export function calculateDistance(
|
|
13
|
+
coord1: Coordinates,
|
|
14
|
+
coord2: Coordinates
|
|
15
|
+
): number {
|
|
16
|
+
const R = 6371; // Earth's radius in kilometers
|
|
17
|
+
const dLat = toRadians(coord2.lat - coord1.lat);
|
|
18
|
+
const dLon = toRadians(coord2.lng - coord1.lng);
|
|
19
|
+
|
|
20
|
+
const a =
|
|
21
|
+
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
|
22
|
+
Math.cos(toRadians(coord1.lat)) *
|
|
23
|
+
Math.cos(toRadians(coord2.lat)) *
|
|
24
|
+
Math.sin(dLon / 2) *
|
|
25
|
+
Math.sin(dLon / 2);
|
|
26
|
+
|
|
27
|
+
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
28
|
+
return R * c;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function toRadians(degrees: number): number {
|
|
32
|
+
return degrees * (Math.PI / 180);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Convert kilometers to miles
|
|
37
|
+
*/
|
|
38
|
+
export function kmToMiles(km: number): number {
|
|
39
|
+
return km * 0.621371;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Convert miles to kilometers
|
|
44
|
+
*/
|
|
45
|
+
export function milesToKm(miles: number): number {
|
|
46
|
+
return miles * 1.60934;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Estimate walking time in minutes
|
|
51
|
+
* Assumes average walking speed of 5 km/h
|
|
52
|
+
*/
|
|
53
|
+
export function estimateWalkingTime(distanceKm: number): number {
|
|
54
|
+
const walkingSpeedKmh = 5;
|
|
55
|
+
return Math.round((distanceKm / walkingSpeedKmh) * 60);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Estimate driving time in minutes
|
|
60
|
+
* Assumes average driving speed of 50 km/h (urban/suburban)
|
|
61
|
+
*/
|
|
62
|
+
export function estimateDrivingTime(distanceKm: number): number {
|
|
63
|
+
const drivingSpeedKmh = 50;
|
|
64
|
+
return Math.round((distanceKm / drivingSpeedKmh) * 60);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Format distance with appropriate unit
|
|
69
|
+
*/
|
|
70
|
+
export function formatDistance(
|
|
71
|
+
distanceKm: number,
|
|
72
|
+
useImperial: boolean = false
|
|
73
|
+
): string {
|
|
74
|
+
if (useImperial) {
|
|
75
|
+
const miles = kmToMiles(distanceKm);
|
|
76
|
+
if (miles < 0.1) {
|
|
77
|
+
return `${Math.round(miles * 5280)} ft`;
|
|
78
|
+
} else if (miles < 1) {
|
|
79
|
+
return `${miles.toFixed(1)} mi`;
|
|
80
|
+
} else {
|
|
81
|
+
return `${miles.toFixed(1)} mi`;
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
if (distanceKm < 0.1) {
|
|
85
|
+
return `${Math.round(distanceKm * 1000)} m`;
|
|
86
|
+
} else if (distanceKm < 1) {
|
|
87
|
+
return `${Math.round(distanceKm * 1000)} m`;
|
|
88
|
+
} else {
|
|
89
|
+
return `${distanceKm.toFixed(1)} km`;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Format time duration
|
|
96
|
+
*/
|
|
97
|
+
export function formatTime(minutes: number): string {
|
|
98
|
+
if (minutes < 60) {
|
|
99
|
+
return `${minutes} min`;
|
|
100
|
+
} else {
|
|
101
|
+
const hours = Math.floor(minutes / 60);
|
|
102
|
+
const mins = minutes % 60;
|
|
103
|
+
return mins > 0 ? `${hours} h ${mins} min` : `${hours} h`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Service area for Private Shuttle custom pickups: Lake Louise to Kananaskis casino corridor.
|
|
109
|
+
* Bounding box encompasses: Lake Louise, Banff, Canmore, Kananaskis.
|
|
110
|
+
* Excludes Calgary and areas east.
|
|
111
|
+
*/
|
|
112
|
+
export const PRIVATE_SHUTTLE_SERVICE_BOUNDS = {
|
|
113
|
+
latMin: 51.0,
|
|
114
|
+
latMax: 51.5,
|
|
115
|
+
lngMin: -116.5,
|
|
116
|
+
lngMax: -114.8,
|
|
117
|
+
} as const;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Check if coordinates are within the Private Shuttle service area
|
|
121
|
+
* (corridor from Lake Louise to Kananaskis casino).
|
|
122
|
+
*/
|
|
123
|
+
export function isWithinPrivateShuttleServiceArea(coords: Coordinates): boolean {
|
|
124
|
+
const { latMin, latMax, lngMin, lngMax } = PRIVATE_SHUTTLE_SERVICE_BOUNDS;
|
|
125
|
+
return (
|
|
126
|
+
coords.lat >= latMin &&
|
|
127
|
+
coords.lat <= latMax &&
|
|
128
|
+
coords.lng >= lngMin &&
|
|
129
|
+
coords.lng <= lngMax
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Geocode an address using Google Maps Geocoding API
|
|
135
|
+
* Note: This requires a Google Maps API key
|
|
136
|
+
*/
|
|
137
|
+
export async function geocodeAddress(
|
|
138
|
+
address: string,
|
|
139
|
+
apiKey?: string
|
|
140
|
+
): Promise<Coordinates | null> {
|
|
141
|
+
if (!apiKey) {
|
|
142
|
+
// Fallback: try to use browser geolocation or return null
|
|
143
|
+
console.warn('Google Maps API key not provided');
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const response = await fetch(
|
|
149
|
+
`https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(
|
|
150
|
+
address
|
|
151
|
+
)}&key=${apiKey}`
|
|
152
|
+
);
|
|
153
|
+
const data = await response.json();
|
|
154
|
+
|
|
155
|
+
if (data.status === 'OK' && data.results.length > 0) {
|
|
156
|
+
const location = data.results[0].geometry.location;
|
|
157
|
+
return { lat: location.lat, lng: location.lng };
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
} catch (error) {
|
|
161
|
+
console.error('Geocoding error:', error);
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Maps utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Coordinates } from '@/lib/booking/location-utils';
|
|
6
|
+
import type { PickupLocation } from '@/lib/booking-api';
|
|
7
|
+
import type { NearbyLocation } from '@/lib/booking/pickup-location-types';
|
|
8
|
+
|
|
9
|
+
export interface MapCenter {
|
|
10
|
+
lat: number;
|
|
11
|
+
lng: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DEFAULT_CENTER: MapCenter = { lat: 51.1784, lng: -115.5708 };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Calculate map center based on available locations
|
|
18
|
+
*/
|
|
19
|
+
export function calculateMapCenter(
|
|
20
|
+
searchedLocation: { coordinates: Coordinates | null } | null,
|
|
21
|
+
nearbyLocations: NearbyLocation[],
|
|
22
|
+
pickupLocations: PickupLocation[]
|
|
23
|
+
): MapCenter {
|
|
24
|
+
// Prioritize searched location - center on it when found
|
|
25
|
+
if (searchedLocation?.coordinates) {
|
|
26
|
+
return searchedLocation.coordinates;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (nearbyLocations.length > 0 && nearbyLocations[0].coordinates) {
|
|
30
|
+
return {
|
|
31
|
+
lat: nearbyLocations[0].coordinates.lat,
|
|
32
|
+
lng: nearbyLocations[0].coordinates.lng,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Default to first pickup location if available
|
|
37
|
+
if (pickupLocations.length > 0 && pickupLocations[0].coordinates) {
|
|
38
|
+
return {
|
|
39
|
+
lat: pickupLocations[0].coordinates.lat,
|
|
40
|
+
lng: pickupLocations[0].coordinates.lng,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return DEFAULT_CENTER;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get map options configuration
|
|
49
|
+
* Note: Returns a plain object that will be typed by Google Maps API
|
|
50
|
+
*/
|
|
51
|
+
export function getMapOptions(): google.maps.MapOptions {
|
|
52
|
+
return {
|
|
53
|
+
disableDefaultUI: false,
|
|
54
|
+
clickableIcons: false,
|
|
55
|
+
scrollwheel: true,
|
|
56
|
+
zoomControl: true,
|
|
57
|
+
streetViewControl: false,
|
|
58
|
+
mapTypeControl: false,
|
|
59
|
+
fullscreenControl: true,
|
|
60
|
+
gestureHandling: 'cooperative',
|
|
61
|
+
panControl: false,
|
|
62
|
+
} as google.maps.MapOptions;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Calculate bounds for all locations to fit on map
|
|
67
|
+
* Note: Requires Google Maps API to be loaded
|
|
68
|
+
*/
|
|
69
|
+
export function calculateMapBounds(
|
|
70
|
+
nearbyLocations: NearbyLocation[],
|
|
71
|
+
searchedLocation: { coordinates: Coordinates | null } | null,
|
|
72
|
+
isValidLocation: boolean | null,
|
|
73
|
+
pickupLocations: PickupLocation[]
|
|
74
|
+
): google.maps.LatLngBounds | null {
|
|
75
|
+
if (typeof google === 'undefined' || !google.maps) {
|
|
76
|
+
console.warn('Google Maps API not loaded');
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const bounds = new google.maps.LatLngBounds();
|
|
82
|
+
let hasLocations = false;
|
|
83
|
+
|
|
84
|
+
// Add nearby locations to bounds (if user searched)
|
|
85
|
+
nearbyLocations.forEach((location) => {
|
|
86
|
+
if (location.coordinates) {
|
|
87
|
+
bounds.extend(location.coordinates);
|
|
88
|
+
hasLocations = true;
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Add searched location to bounds if invalid
|
|
93
|
+
if (searchedLocation?.coordinates && !isValidLocation) {
|
|
94
|
+
bounds.extend(searchedLocation.coordinates);
|
|
95
|
+
hasLocations = true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// If no search results, show all pickup locations
|
|
99
|
+
if (!hasLocations) {
|
|
100
|
+
pickupLocations.forEach((location) => {
|
|
101
|
+
if (location.coordinates) {
|
|
102
|
+
bounds.extend(location.coordinates);
|
|
103
|
+
hasLocations = true;
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return hasLocations ? bounds : null;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
console.error('Error calculating map bounds:', error);
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Pan map to location if it's outside viewport
|
|
117
|
+
* Note: Requires Google Maps API to be loaded
|
|
118
|
+
*/
|
|
119
|
+
export function panToLocationIfNeeded(
|
|
120
|
+
map: google.maps.Map,
|
|
121
|
+
coordinates: Coordinates
|
|
122
|
+
): void {
|
|
123
|
+
if (typeof google === 'undefined' || !google.maps) {
|
|
124
|
+
console.warn('Google Maps API not loaded');
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const latLng = new google.maps.LatLng(coordinates.lat, coordinates.lng);
|
|
130
|
+
const bounds = map.getBounds();
|
|
131
|
+
|
|
132
|
+
if (bounds && !bounds.contains(latLng)) {
|
|
133
|
+
const currentCenter = map.getCenter();
|
|
134
|
+
if (currentCenter) {
|
|
135
|
+
const panBounds = new google.maps.LatLngBounds();
|
|
136
|
+
panBounds.extend(currentCenter);
|
|
137
|
+
panBounds.extend(latLng);
|
|
138
|
+
// Add padding to make the pan more visible
|
|
139
|
+
map.fitBounds(panBounds, {
|
|
140
|
+
top: 50,
|
|
141
|
+
right: 50,
|
|
142
|
+
bottom: 50,
|
|
143
|
+
left: 50,
|
|
144
|
+
});
|
|
145
|
+
} else {
|
|
146
|
+
map.panTo(latLng);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
} catch (error) {
|
|
150
|
+
console.error('Error panning map:', error);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marker icon generation utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface MarkerIconOptions {
|
|
6
|
+
bgColor: string;
|
|
7
|
+
distanceStr?: string;
|
|
8
|
+
walkingTimeStr?: string;
|
|
9
|
+
label?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Escape text for safe use in SVG
|
|
14
|
+
*/
|
|
15
|
+
function escapeSvgText(text: string): string {
|
|
16
|
+
return text
|
|
17
|
+
.replace(/&/g, '&')
|
|
18
|
+
.replace(/</g, '<')
|
|
19
|
+
.replace(/>/g, '>')
|
|
20
|
+
.replace(/"/g, '"')
|
|
21
|
+
.replace(/'/g, ''');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Validate and sanitize color value (hex color)
|
|
26
|
+
*/
|
|
27
|
+
function validateColor(color: string): string {
|
|
28
|
+
// Only allow hex colors (# followed by 3 or 6 hex digits)
|
|
29
|
+
const hexColorRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/;
|
|
30
|
+
if (hexColorRegex.test(color)) {
|
|
31
|
+
return color;
|
|
32
|
+
}
|
|
33
|
+
// Default to a safe color if invalid
|
|
34
|
+
console.warn(`Invalid color provided: ${color}, using default`);
|
|
35
|
+
return '#dc2626';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Truncate text to fit within marker width
|
|
40
|
+
*/
|
|
41
|
+
function truncateText(text: string, maxLength: number = 20): string {
|
|
42
|
+
if (text.length <= maxLength) return text;
|
|
43
|
+
return text.slice(0, maxLength - 3) + '...';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Generate SVG icon for pickup location marker with distance/time
|
|
48
|
+
*/
|
|
49
|
+
export function createDistanceMarkerIcon(options: MarkerIconOptions): string {
|
|
50
|
+
const { bgColor, distanceStr, walkingTimeStr } = options;
|
|
51
|
+
const safeColor = validateColor(bgColor);
|
|
52
|
+
const text = distanceStr && walkingTimeStr
|
|
53
|
+
? truncateText(`${distanceStr} • ${walkingTimeStr}`, 20)
|
|
54
|
+
: '';
|
|
55
|
+
const escapedText = escapeSvgText(text);
|
|
56
|
+
|
|
57
|
+
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(`
|
|
58
|
+
<svg width="120" height="32" viewBox="0 0 120 32" xmlns="http://www.w3.org/2000/svg">
|
|
59
|
+
<rect x="0" y="0" width="120" height="24" rx="4" fill="${safeColor}"/>
|
|
60
|
+
<polygon points="55,24 60,32 65,24" fill="${safeColor}"/>
|
|
61
|
+
<text x="60" y="16" fill="white" font-size="10" font-weight="600" text-anchor="middle" font-family="Arial">${escapedText}</text>
|
|
62
|
+
</svg>
|
|
63
|
+
`)}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Generate SVG icon for simple pickup location pin
|
|
68
|
+
*/
|
|
69
|
+
export function createPinMarkerIcon(color: string): string {
|
|
70
|
+
const safeColor = validateColor(color);
|
|
71
|
+
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(`
|
|
72
|
+
<svg width="32" height="40" viewBox="0 0 32 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
73
|
+
<path d="M16 0C7.163 0 0 7.163 0 16C0 24.837 16 40 16 40C16 40 32 24.837 32 16C32 7.163 24.837 0 16 0Z" fill="#ffffff"/>
|
|
74
|
+
<path d="M16 2C8.268 2 2 8.268 2 16C2 22.177 16 38 16 38C16 38 30 22.177 30 16C30 8.268 23.732 2 16 2Z" fill="${safeColor}"/>
|
|
75
|
+
<circle cx="16" cy="16" r="6" fill="#ffffff"/>
|
|
76
|
+
</svg>
|
|
77
|
+
`)}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Generate SVG icon for user's searched location pin
|
|
82
|
+
*/
|
|
83
|
+
export function createSearchedLocationPinIcon(): string {
|
|
84
|
+
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(`
|
|
85
|
+
<svg width="32" height="40" viewBox="0 0 32 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
86
|
+
<path d="M16 0C7.163 0 0 7.163 0 16C0 24.837 16 40 16 40C16 40 32 24.837 32 16C32 7.163 24.837 0 16 0Z" fill="#ffffff"/>
|
|
87
|
+
<path d="M16 2C8.268 2 2 8.268 2 16C2 22.177 16 38 16 38C16 38 30 22.177 30 16C30 8.268 23.732 2 16 2Z" fill="#3b82f6"/>
|
|
88
|
+
<circle cx="16" cy="16" r="6" fill="#ffffff"/>
|
|
89
|
+
</svg>
|
|
90
|
+
`)}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Generate SVG icon for destination marker pin with text below (yellow)
|
|
95
|
+
*/
|
|
96
|
+
export function createDestinationMarkerIcon(name: string, color: string = '#facc15'): string {
|
|
97
|
+
const safeColor = validateColor(color);
|
|
98
|
+
const escapedName = escapeSvgText(truncateText(name, 20));
|
|
99
|
+
// SVG includes pin (40px height) + text below (20px height) = 60px total
|
|
100
|
+
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(`
|
|
101
|
+
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
102
|
+
<!-- Pin marker (centered horizontally, at top) -->
|
|
103
|
+
<g transform="translate(24, 0)">
|
|
104
|
+
<path d="M16 0C7.163 0 0 7.163 0 16C0 24.837 16 40 16 40C16 40 32 24.837 32 16C32 7.163 24.837 0 16 0Z" fill="#ffffff"/>
|
|
105
|
+
<path d="M16 2C8.268 2 2 8.268 2 16C2 22.177 16 38 16 38C16 38 30 22.177 30 16C30 8.268 23.732 2 16 2Z" fill="${safeColor}"/>
|
|
106
|
+
<circle cx="16" cy="16" r="6" fill="#ffffff"/>
|
|
107
|
+
</g>
|
|
108
|
+
<!-- Text below pin -->
|
|
109
|
+
<text x="40" y="52" fill="#1f2937" font-size="12" font-weight="600" text-anchor="middle" font-family="Arial, sans-serif">${escapedName}</text>
|
|
110
|
+
</svg>
|
|
111
|
+
`)}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize IDs used in TicketBooth query params (product id, product option id).
|
|
3
|
+
* Strips hash fragments, accidental query strings, or concatenated `&…` tails — aligned with
|
|
4
|
+
* defensive parsing in GetAvailabilitiesHandler.
|
|
5
|
+
*/
|
|
6
|
+
export function normalizeBookingProductId(rawId: string): string {
|
|
7
|
+
const trimmed = rawId.trim();
|
|
8
|
+
if (!trimmed) return '';
|
|
9
|
+
const withoutHash = trimmed.split('#', 1)[0] ?? '';
|
|
10
|
+
const withoutQuery = withoutHash.split('?', 1)[0] ?? '';
|
|
11
|
+
const normalized = withoutQuery.split('&', 1)[0] ?? '';
|
|
12
|
+
return normalized.trim();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** True when the raw value clearly contains URL/query junk and should be normalized before use. */
|
|
16
|
+
export function isSuspiciousBookingProductId(value: string): boolean {
|
|
17
|
+
return /[?&=]/.test(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Alias: same normalization for get-availabilities lookup id (product or option). */
|
|
21
|
+
export const normalizeAvailabilityLookupId = normalizeBookingProductId;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for pickup location selector
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { PickupLocation } from '@/lib/booking-api';
|
|
6
|
+
import type { Coordinates } from '@/lib/booking/location-utils';
|
|
7
|
+
|
|
8
|
+
export interface NearbyLocation extends PickupLocation {
|
|
9
|
+
distance: number;
|
|
10
|
+
walkingTime: number;
|
|
11
|
+
drivingTime: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SearchedLocation {
|
|
15
|
+
address: string;
|
|
16
|
+
coordinates: Coordinates | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AutocompleteSuggestion {
|
|
20
|
+
placeId: string;
|
|
21
|
+
mainText: string;
|
|
22
|
+
secondaryText: string;
|
|
23
|
+
description: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Places API (New) utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface AutocompleteSuggestion {
|
|
6
|
+
placeId: string;
|
|
7
|
+
mainText: string;
|
|
8
|
+
secondaryText: string;
|
|
9
|
+
description: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Internal API response types
|
|
13
|
+
interface PlacePrediction {
|
|
14
|
+
placeId: string;
|
|
15
|
+
text?: { text: string };
|
|
16
|
+
structuredFormat?: {
|
|
17
|
+
mainText?: { text: string };
|
|
18
|
+
secondaryText?: { text: string };
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface AutocompleteSuggestionResponse {
|
|
23
|
+
placePrediction?: PlacePrediction;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const GOOGLE_PLACES_API_BASE = 'https://places.googleapis.com/v1';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get autocomplete suggestions using Places API (New)
|
|
30
|
+
*/
|
|
31
|
+
export async function getAutocompleteSuggestions(
|
|
32
|
+
input: string,
|
|
33
|
+
apiKey: string,
|
|
34
|
+
locationBias?: { latitude: number; longitude: number; radius: number }
|
|
35
|
+
): Promise<AutocompleteSuggestion[]> {
|
|
36
|
+
if (!input.trim() || !apiKey) {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const response = await fetch(`${GOOGLE_PLACES_API_BASE}/places:autocomplete`, {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: {
|
|
44
|
+
'Content-Type': 'application/json',
|
|
45
|
+
'X-Goog-Api-Key': apiKey,
|
|
46
|
+
'X-Goog-FieldMask':
|
|
47
|
+
'suggestions.placePrediction.placeId,suggestions.placePrediction.text,suggestions.placePrediction.structuredFormat',
|
|
48
|
+
},
|
|
49
|
+
body: JSON.stringify({
|
|
50
|
+
input,
|
|
51
|
+
includedRegionCodes: ['ca'], // Restrict to Canada
|
|
52
|
+
...(locationBias && {
|
|
53
|
+
locationBias: {
|
|
54
|
+
circle: {
|
|
55
|
+
center: {
|
|
56
|
+
latitude: locationBias.latitude,
|
|
57
|
+
longitude: locationBias.longitude,
|
|
58
|
+
},
|
|
59
|
+
radius: locationBias.radius,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
}),
|
|
63
|
+
}),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
const errorText = await response.text();
|
|
68
|
+
console.error('Autocomplete API error:', response.status, errorText);
|
|
69
|
+
throw new Error(`Autocomplete request failed: ${response.status} ${errorText}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const data = await response.json() as {
|
|
73
|
+
suggestions?: AutocompleteSuggestionResponse[];
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
if (data.suggestions && Array.isArray(data.suggestions)) {
|
|
77
|
+
return data.suggestions
|
|
78
|
+
.filter((s): s is AutocompleteSuggestionResponse & { placePrediction: PlacePrediction } =>
|
|
79
|
+
!!s.placePrediction
|
|
80
|
+
)
|
|
81
|
+
.map((s) => {
|
|
82
|
+
const prediction = s.placePrediction;
|
|
83
|
+
const text = prediction.text?.text || '';
|
|
84
|
+
const structuredFormat = prediction.structuredFormat || {};
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
placeId: prediction.placeId,
|
|
88
|
+
mainText: structuredFormat.mainText?.text || text,
|
|
89
|
+
secondaryText: structuredFormat.secondaryText?.text || '',
|
|
90
|
+
description: text,
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return [];
|
|
96
|
+
} catch (error) {
|
|
97
|
+
console.error('Autocomplete error:', error);
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Get place coordinates and types using Place Details API (New)
|
|
104
|
+
*/
|
|
105
|
+
export async function getPlaceDetails(
|
|
106
|
+
placeId: string,
|
|
107
|
+
apiKey: string
|
|
108
|
+
): Promise<{ lat: number; lng: number; types: string[] } | null> {
|
|
109
|
+
try {
|
|
110
|
+
const response = await fetch(`${GOOGLE_PLACES_API_BASE}/places/${placeId}`, {
|
|
111
|
+
method: 'GET',
|
|
112
|
+
headers: {
|
|
113
|
+
'Content-Type': 'application/json',
|
|
114
|
+
'X-Goog-Api-Key': apiKey,
|
|
115
|
+
'X-Goog-FieldMask': 'location,types',
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
throw new Error('Place details request failed');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const data = await response.json();
|
|
124
|
+
|
|
125
|
+
if (data.location) {
|
|
126
|
+
return {
|
|
127
|
+
lat: data.location.latitude,
|
|
128
|
+
lng: data.location.longitude,
|
|
129
|
+
types: data.types || [],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return null;
|
|
134
|
+
} catch (error) {
|
|
135
|
+
console.error('Error getting place details:', error);
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Get place coordinates using Place Details API (New)
|
|
142
|
+
* @deprecated Use getPlaceDetails instead to also get types
|
|
143
|
+
*/
|
|
144
|
+
export async function getPlaceCoordinates(
|
|
145
|
+
placeId: string,
|
|
146
|
+
apiKey: string
|
|
147
|
+
): Promise<{ lat: number; lng: number } | null> {
|
|
148
|
+
const details = await getPlaceDetails(placeId, apiKey);
|
|
149
|
+
if (details) {
|
|
150
|
+
return { lat: details.lat, lng: details.lng };
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|