@ticketboothapp/booking 0.1.4 → 0.1.8
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 +21 -1
- package/src/components/BookingDetails.tsx +546 -0
- package/src/components/BookingFlow.tsx +2952 -0
- package/src/components/BookingWidget.tsx +7 -5
- package/src/components/Calendar.tsx +906 -0
- package/src/components/CheckoutModal.tsx +294 -0
- package/src/components/CurrencySwitcher.tsx +81 -0
- package/src/components/ErrorBoundary.tsx +63 -0
- package/src/components/ItineraryBuilder.tsx +83 -0
- package/src/components/LanguageSwitcher.tsx +30 -0
- package/src/components/ManageBookingView.tsx +4 -2
- package/src/components/MealDrinkAddOnSelector.tsx +330 -0
- package/src/components/PickupLocationSelector.tsx +1541 -0
- package/src/components/PriceBreakdown.tsx +154 -0
- package/src/components/PriceSummary.tsx +211 -0
- package/src/components/PrivateShuttleBookingFlow.tsx +2290 -0
- package/src/components/ProductList.tsx +78 -0
- package/src/components/TermsAcceptance.tsx +110 -0
- package/src/components/WhatsAppPhoneInput.tsx +224 -0
- package/src/components/index.ts +31 -0
- package/src/contexts/CompanyContext.tsx +8 -20
- package/src/index.ts +5 -0
- package/src/lib/api.ts +801 -0
- package/src/lib/booking-ref.ts +13 -0
- package/src/lib/checkout-breakdown.test.ts +70 -0
- package/src/lib/checkout-breakdown.ts +69 -0
- package/src/lib/constants.ts +17 -0
- package/src/lib/currency.ts +88 -0
- package/src/lib/env.ts +10 -12
- package/src/lib/i18n/config.ts +21 -0
- package/src/lib/i18n/index.tsx +144 -0
- package/src/lib/i18n/messages/en.json +192 -0
- package/src/lib/i18n/messages/fr.json +192 -0
- package/src/lib/itinerary-labels.ts +70 -0
- package/src/lib/location-calculations.ts +43 -0
- package/src/lib/location-utils.ts +139 -0
- package/src/lib/map-utils.ts +153 -0
- package/src/lib/marker-icons.ts +113 -0
- package/src/lib/pickup-location-types.ts +25 -0
- package/src/lib/places-api.ts +154 -0
- package/src/lib/pricing.ts +466 -0
- package/src/lib/theme.ts +83 -0
- package/src/lib/utils.ts +9 -0
- package/src/types/google-maps.d.ts +2 -0
- package/tsconfig.json +8 -2
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
{
|
|
2
|
+
"common": {
|
|
3
|
+
"back": "Retour",
|
|
4
|
+
"continue": "Continuer",
|
|
5
|
+
"select": "Sélectionner",
|
|
6
|
+
"change": "Changer",
|
|
7
|
+
"cancel": "Annuler",
|
|
8
|
+
"close": "Fermer",
|
|
9
|
+
"loading": "Chargement...",
|
|
10
|
+
"error": "Erreur",
|
|
11
|
+
"total": "Total",
|
|
12
|
+
"email": "Courriel",
|
|
13
|
+
"emailPlaceholder": "votre@courriel.com",
|
|
14
|
+
"emailForConfirmation": "Courriel (pour confirmation)"
|
|
15
|
+
},
|
|
16
|
+
"booking": {
|
|
17
|
+
"selectDate": "Sélectionner la date",
|
|
18
|
+
"selectPickupTime": "Sélectionner l'heure de prise en charge",
|
|
19
|
+
"requestDifferentTime": "Demander une autre heure",
|
|
20
|
+
"preferredPickupTime": "Heure de prise en charge préférée",
|
|
21
|
+
"selectReturnTime": "Sélectionner l'heure de retour",
|
|
22
|
+
"mostPopular": "Le plus populaire",
|
|
23
|
+
"pickupAtTourStart": "<b>Votre heure de prise en charge dépend de votre lieu de prise en charge.</b><br>Votre heure de prise en charge exacte sera confirmée une fois que vous aurez confirmé votre lieu de prise en charge. Les prises en charge commencent à Canmore et se terminent à Lake Louise.",
|
|
24
|
+
"pickupAtTourStartLocation": "lieu de départ du tour",
|
|
25
|
+
"pickupLocationUnknown": "Je ne sais pas",
|
|
26
|
+
"arrivalTimeMessage": "Vous arriverez à {destination} vers <b>{time}</b>.",
|
|
27
|
+
"yourItinerary": "Votre itinéraire",
|
|
28
|
+
"buildYourItinerary": "Construisez votre itinéraire",
|
|
29
|
+
"selectPickupTimeToSeeItinerary": "Sélectionnez une heure de prise en charge ci-dessous pour voir votre horaire.",
|
|
30
|
+
"selectTourOption": "Sélectionner l'option de tour",
|
|
31
|
+
"selectTourOptionToSeeItinerary": "Sélectionnez une option de tour ci-dessous pour voir votre itinéraire.",
|
|
32
|
+
"timesToBeDetermined": "heures à confirmer…",
|
|
33
|
+
"proposedStops": "Arrêts proposés",
|
|
34
|
+
"orderAndTimesToBeConfirmed": "(ordre et horaires à confirmer par notre équipe)",
|
|
35
|
+
"startingAtPerShuttle": "À partir de {price} par navette",
|
|
36
|
+
"hoursTour": "Tour de {hours} h",
|
|
37
|
+
"hoursTourStarting": "Tour commençant avec {hours} heures",
|
|
38
|
+
"startingAtForHours": "À partir de {price} par navette pour {hours}.",
|
|
39
|
+
"hoursUnit": "heures",
|
|
40
|
+
"additionalHoursAvailable": "Heures supplémentaires disponibles moyennant des frais.",
|
|
41
|
+
"pickupAt": "Prise en charge à",
|
|
42
|
+
"pickupAtPrefix": "Prise en charge à ",
|
|
43
|
+
"pickupAtLocation": "Prise en charge à {location}",
|
|
44
|
+
"arriveAt": "Arrivée à",
|
|
45
|
+
"arriveAtPlace": "Arrivée à {place}",
|
|
46
|
+
"departFrom": "Départ de",
|
|
47
|
+
"departFromPlace": "Départ de {place}",
|
|
48
|
+
"dropOffAt": "Retour à {location}",
|
|
49
|
+
"dropOffAtPrefix": "Retour à ",
|
|
50
|
+
"tripEnd": "Fin du voyage",
|
|
51
|
+
"stopAtPlaceTbd": "Arrêt à {place} (heure à confirmer)",
|
|
52
|
+
"placeTheDestination": "le lieu de destination",
|
|
53
|
+
"dropOffAtPickup": "Arrivée à {pickupLocation}",
|
|
54
|
+
"yourPickupLocation": "votre lieu de prise en charge",
|
|
55
|
+
"tickets": "Billets",
|
|
56
|
+
"available": "disponible",
|
|
57
|
+
"soldOut": "Complet",
|
|
58
|
+
"continueToPayment": "Continuer vers le paiement",
|
|
59
|
+
"creatingReservation": "Création de la réservation...",
|
|
60
|
+
"securePayment": "Paiement sécurisé par Stripe",
|
|
61
|
+
"selectTimeAndTickets": "Veuillez sélectionner une heure et au moins un billet",
|
|
62
|
+
"selectPickupLocation": "Veuillez sélectionner un lieu de prise en charge",
|
|
63
|
+
"loadingTimes": "Chargement des heures disponibles...",
|
|
64
|
+
"noAvailability": "Aucune disponibilité trouvée pour les 30 prochains jours. Veuillez réessayer plus tard.",
|
|
65
|
+
"communicationPreference": "Comment souhaitez-vous recevoir les communications futures (confirmation, rappels, etc.)?",
|
|
66
|
+
"communicationEmail": "Courriel",
|
|
67
|
+
"communicationEmailDesc": "Envoyer la confirmation par courriel",
|
|
68
|
+
"communicationWhatsApp": "WhatsApp",
|
|
69
|
+
"communicationWhatsAppDesc": "Envoyer la confirmation par WhatsApp",
|
|
70
|
+
"emailForConfirmation": "Courriel",
|
|
71
|
+
"phoneNumberForConfirmation": "Numéro de téléphone",
|
|
72
|
+
"phoneNumberPlaceholder": "(555) 123-4567",
|
|
73
|
+
"invalidEmail": "Veuillez entrer une adresse courriel valide",
|
|
74
|
+
"invalidPhoneNumber": "Veuillez entrer un numéro de téléphone valide",
|
|
75
|
+
"selectCommunicationPreference": "Veuillez sélectionner comment vous souhaitez recevoir votre confirmation",
|
|
76
|
+
"enterEmail": "Veuillez entrer votre adresse courriel",
|
|
77
|
+
"enterLastName": "Veuillez entrer votre nom de famille",
|
|
78
|
+
"enterPhoneNumber": "Veuillez entrer votre numéro de téléphone",
|
|
79
|
+
"firstName": "Prénom",
|
|
80
|
+
"firstNamePlaceholder": "Jeanne",
|
|
81
|
+
"lastName": "Nom de famille",
|
|
82
|
+
"lastNamePlaceholder": "Dupont",
|
|
83
|
+
"noActiveOption": "Aucune option de produit disponible",
|
|
84
|
+
"people": "personnes",
|
|
85
|
+
"person": "personne",
|
|
86
|
+
"rounding": "Arrondi",
|
|
87
|
+
"deposit": "Acompte",
|
|
88
|
+
"subtotal": "Sous-total",
|
|
89
|
+
"tax": "Taxes et frais",
|
|
90
|
+
"returnOption": "Option de retour",
|
|
91
|
+
"fromPrice": "À partir de {price}",
|
|
92
|
+
"communicationPermission": "En fournissant vos coordonnées, vous autorisez la réception de confirmations de réservation, de rappels et de mises à jour par cette méthode.",
|
|
93
|
+
"communicationPermissionCheckbox": "J'autorise la réception de communications par cette méthode",
|
|
94
|
+
"communicationPermissionRequired": "Veuillez sélectionner au moins une méthode de communication",
|
|
95
|
+
"emailPermissionCheckbox": "Me contacter par courriel (nous ne vous enverrons que les communications pertinentes pour votre réservation)",
|
|
96
|
+
"whatsappPermissionCheckbox": "Me contacter par WhatsApp (nous ne vous enverrons que les communications pertinentes pour votre réservation)",
|
|
97
|
+
"reviewAndPay": "Réviser et payer",
|
|
98
|
+
"payNow": "Payer maintenant",
|
|
99
|
+
"paying": "Paiement en cours...",
|
|
100
|
+
"checkout": "Paiement",
|
|
101
|
+
"paymentNotConfigured": "Le paiement n'est pas configuré. Veuillez utiliser le paiement standard.",
|
|
102
|
+
"loadingPayment": "Chargement du formulaire de paiement...",
|
|
103
|
+
"promoCode": "Code promo",
|
|
104
|
+
"optionalPromoCode": "Promo / bon / carte cadeau",
|
|
105
|
+
"promoCodePlaceholder": "Entrez le code",
|
|
106
|
+
"applyPromo": "Appliquer",
|
|
107
|
+
"removePromo": "Retirer",
|
|
108
|
+
"promoApplied": "Appliqué : {{code}}",
|
|
109
|
+
"invalidPromoCode": "Code promo invalide ou expiré",
|
|
110
|
+
"promoCodesCannotStackWithDiscounts": "Les codes promo ne peuvent pas être cumulés avec les offres",
|
|
111
|
+
"discount": "Réduction",
|
|
112
|
+
"cancellationPolicy": "Politique d'annulation",
|
|
113
|
+
"promoRequiresCancellationPolicy": "Ce code promo exige {{policy}}",
|
|
114
|
+
"promoRequiresThisPolicy": "Exigé par votre code promo",
|
|
115
|
+
"cancellationStandard": "Annulation standard",
|
|
116
|
+
"included": "Inclus",
|
|
117
|
+
"flexibleCancellation": "Annulation flexible",
|
|
118
|
+
"depositPaymentNotice": "Vous payez l'acompte aujourd'hui.",
|
|
119
|
+
"balanceChargeNotice": "Le solde restant sera facturé {days} jours avant votre réservation. Vous pouvez également le payer plus tôt depuis la page Gérer ma réservation.",
|
|
120
|
+
"balancePayEarlier": "Vous pouvez payer le solde restant à tout moment depuis la page Gérer ma réservation.",
|
|
121
|
+
"remainingBalance": "Solde restant"
|
|
122
|
+
},
|
|
123
|
+
"pickup": {
|
|
124
|
+
"title": "Savez-vous où vous souhaitez être pris en charge?",
|
|
125
|
+
"yesAddNow": "Oui, je peux l'ajouter maintenant",
|
|
126
|
+
"dontKnow": "Je ne sais pas encore",
|
|
127
|
+
"pickupLocation": "Lieu de prise en charge",
|
|
128
|
+
"enterAddress": "Entrez votre adresse de prise en charge",
|
|
129
|
+
"searchingLocation": "Recherche de l'emplacement...",
|
|
130
|
+
"locationNotFound": "Impossible de trouver cet emplacement. Veuillez essayer une autre adresse.",
|
|
131
|
+
"chooseNearby": "Choisissez un lieu de prise en charge à proximité",
|
|
132
|
+
"exactMatch": "Correspondance exacte trouvée",
|
|
133
|
+
"locationsInCity": "Points de ramassage à {{city}}",
|
|
134
|
+
"selectThisLocation": "Sélectionner ce lieu",
|
|
135
|
+
"yourLocation": "Votre emplacement",
|
|
136
|
+
"chooseClosest": "Choisissez un lieu de prise en charge ci-dessous pour voir l'option la plus proche.",
|
|
137
|
+
"useThisAddress": "Utiliser cette adresse",
|
|
138
|
+
"away": "de distance",
|
|
139
|
+
"walk": "à pied",
|
|
140
|
+
"drive": "en voiture",
|
|
141
|
+
"switchUnits": "Passer aux unités {unit}",
|
|
142
|
+
"metric": "métriques",
|
|
143
|
+
"imperial": "impériales",
|
|
144
|
+
"dontKnowSubtext": "Nous vous enverrons des rappels pour sélectionner votre lieu de prise en charge.",
|
|
145
|
+
"yesAddNowSubtext": "Vous pourrez modifier cela plus tard.",
|
|
146
|
+
"skipWarningTitle": "Avis important",
|
|
147
|
+
"skipWarningMessage": "Vous n'êtes pas garanti d'être pris en charge si vous ne <b>mettez pas à jour votre réservation avec votre lieu de prise en charge au moins 12 heures à l'avance</b>.",
|
|
148
|
+
"iUnderstand": "Je comprends"
|
|
149
|
+
},
|
|
150
|
+
"calendar": {
|
|
151
|
+
"previousWeeks": "2 semaines précédentes",
|
|
152
|
+
"nextWeeks": "2 semaines suivantes",
|
|
153
|
+
"soldOut": "Complet",
|
|
154
|
+
"left": "{count} restant",
|
|
155
|
+
"days": {
|
|
156
|
+
"sun": "DIM",
|
|
157
|
+
"mon": "LUN",
|
|
158
|
+
"tue": "MAR",
|
|
159
|
+
"wed": "MER",
|
|
160
|
+
"thu": "JEU",
|
|
161
|
+
"fri": "VEN",
|
|
162
|
+
"sat": "SAM"
|
|
163
|
+
},
|
|
164
|
+
"months": {
|
|
165
|
+
"january": "Janvier",
|
|
166
|
+
"february": "Février",
|
|
167
|
+
"march": "Mars",
|
|
168
|
+
"april": "Avril",
|
|
169
|
+
"may": "Mai",
|
|
170
|
+
"june": "Juin",
|
|
171
|
+
"july": "Juillet",
|
|
172
|
+
"august": "Août",
|
|
173
|
+
"september": "Septembre",
|
|
174
|
+
"october": "Octobre",
|
|
175
|
+
"november": "Novembre",
|
|
176
|
+
"december": "Décembre"
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
"products": {
|
|
180
|
+
"backToExperiences": "Retour aux expériences",
|
|
181
|
+
"from": "À partir de",
|
|
182
|
+
"noDescription": "Aucune description disponible"
|
|
183
|
+
},
|
|
184
|
+
"terms": {
|
|
185
|
+
"title": "Conditions générales",
|
|
186
|
+
"viewTerms": "Conditions générales",
|
|
187
|
+
"acceptPrefix": "J'accepte les",
|
|
188
|
+
"acceptAndClose": "J'ai lu et j'accepte",
|
|
189
|
+
"content": "En finalisant cette réservation, vous acceptez les conditions générales suivantes.\n\n1. Réservation et paiement\nVotre réservation est confirmée une fois le paiement traité. Vous recevrez une confirmation par courriel ou par le mode de contact choisi.\n\n2. Annulation et modifications\nLes politiques d'annulation et de modification dépendent de l'option choisie au paiement. Veuillez consulter votre confirmation pour la politique applicable.\n\n3. Participation\nVous êtes responsable d'arriver à l'heure et au lieu indiqués. Un retard peut entraîner la perte de l'expérience sans remboursement.\n\n4. Responsabilité\nL'opérateur n'est pas responsable des pertes ou dommages au-delà de ce qu'exige la loi applicable. La participation est à vos risques lorsque les activités comportent des risques inhérents.\n\n5. Contact\nPour toute question ou modification de réservation, utilisez les coordonnées indiquées dans votre confirmation."
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ItineraryDisplayStep } from './api';
|
|
2
|
+
|
|
3
|
+
export type TranslationFn = (key: string, params?: Record<string, string>) => string;
|
|
4
|
+
|
|
5
|
+
/** Step shape accepted for label building (stepType may be string when from JSON). */
|
|
6
|
+
type StepForLabel = Pick<ItineraryDisplayStep, 'time' | 'place'> & { stepType: string };
|
|
7
|
+
|
|
8
|
+
const EN_PLACE: Record<string, string> = {
|
|
9
|
+
your_pickup_location: 'your pickup location',
|
|
10
|
+
the_destination: 'the destination',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* English-only label (for provider dashboard, email, or when t() is not available).
|
|
15
|
+
*/
|
|
16
|
+
export function getStepLabelEn(step: StepForLabel): string {
|
|
17
|
+
const place = step.place;
|
|
18
|
+
const placeDisplay = place ? (EN_PLACE[place] ?? place) : '';
|
|
19
|
+
|
|
20
|
+
switch (step.stepType) {
|
|
21
|
+
case 'pickup':
|
|
22
|
+
return `Pickup at ${placeDisplay}`;
|
|
23
|
+
case 'drop_off':
|
|
24
|
+
return `Drop off at ${placeDisplay}`;
|
|
25
|
+
case 'arrive':
|
|
26
|
+
return `Arrive at ${place ?? ''}`;
|
|
27
|
+
case 'depart':
|
|
28
|
+
return `Depart ${place ?? ''}`;
|
|
29
|
+
case 'trip_end':
|
|
30
|
+
return 'Trip ends';
|
|
31
|
+
case 'draft':
|
|
32
|
+
return `Stop at ${place ?? ''} (time TBD)`;
|
|
33
|
+
default:
|
|
34
|
+
return place ? `${place}` : step.stepType;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Builds the display label for an itinerary step from stepType + place.
|
|
40
|
+
* All labels are built on the FE so the user's language choice is respected.
|
|
41
|
+
*/
|
|
42
|
+
export function getStepLabel(
|
|
43
|
+
step: StepForLabel,
|
|
44
|
+
t: TranslationFn
|
|
45
|
+
): string {
|
|
46
|
+
const place = step.place;
|
|
47
|
+
const placeDisplay =
|
|
48
|
+
place === 'your_pickup_location'
|
|
49
|
+
? t('booking.yourPickupLocation')
|
|
50
|
+
: place === 'the_destination'
|
|
51
|
+
? t('booking.placeTheDestination')
|
|
52
|
+
: place ?? '';
|
|
53
|
+
|
|
54
|
+
switch (step.stepType) {
|
|
55
|
+
case 'pickup':
|
|
56
|
+
return t('booking.pickupAtLocation', { location: placeDisplay });
|
|
57
|
+
case 'drop_off':
|
|
58
|
+
return t('booking.dropOffAt', { location: placeDisplay });
|
|
59
|
+
case 'arrive':
|
|
60
|
+
return t('booking.arriveAtPlace', { place: place ?? '' });
|
|
61
|
+
case 'depart':
|
|
62
|
+
return t('booking.departFromPlace', { place: place ?? '' });
|
|
63
|
+
case 'trip_end':
|
|
64
|
+
return t('booking.tripEnd');
|
|
65
|
+
case 'draft':
|
|
66
|
+
return t('booking.stopAtPlaceTbd', { place: place ?? '' });
|
|
67
|
+
default:
|
|
68
|
+
return place ? `${place}` : step.stepType;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Location calculation utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { PickupLocation } from './api';
|
|
6
|
+
import type { Coordinates } from './location-utils';
|
|
7
|
+
import type { NearbyLocation } from './pickup-location-types';
|
|
8
|
+
import {
|
|
9
|
+
calculateDistance,
|
|
10
|
+
estimateWalkingTime,
|
|
11
|
+
estimateDrivingTime,
|
|
12
|
+
} from './location-utils';
|
|
13
|
+
|
|
14
|
+
const EXACT_MATCH_THRESHOLD_KM = 0.05; // 50 meters - tighter threshold for exact matches
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Calculate nearby locations with distances and travel times
|
|
18
|
+
*/
|
|
19
|
+
export function calculateNearbyLocations(
|
|
20
|
+
searchCoordinates: Coordinates,
|
|
21
|
+
pickupLocations: PickupLocation[]
|
|
22
|
+
): NearbyLocation[] {
|
|
23
|
+
return pickupLocations
|
|
24
|
+
.filter((loc) => loc.coordinates)
|
|
25
|
+
.map((loc) => {
|
|
26
|
+
const distance = calculateDistance(searchCoordinates, loc.coordinates!);
|
|
27
|
+
return {
|
|
28
|
+
...loc,
|
|
29
|
+
distance,
|
|
30
|
+
walkingTime: estimateWalkingTime(distance),
|
|
31
|
+
drivingTime: estimateDrivingTime(distance),
|
|
32
|
+
};
|
|
33
|
+
})
|
|
34
|
+
.sort((a, b) => a.distance - b.distance);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Check if searched location is very close to a pickup location (exact match)
|
|
39
|
+
*/
|
|
40
|
+
export function isExactMatch(nearbyLocations: NearbyLocation[]): boolean {
|
|
41
|
+
return nearbyLocations.some((loc) => loc.distance < EXACT_MATCH_THRESHOLD_KM);
|
|
42
|
+
}
|
|
43
|
+
|
|
@@ -0,0 +1,139 @@
|
|
|
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
|
+
* Geocode an address using Google Maps Geocoding API
|
|
109
|
+
* Note: This requires a Google Maps API key
|
|
110
|
+
*/
|
|
111
|
+
export async function geocodeAddress(
|
|
112
|
+
address: string,
|
|
113
|
+
apiKey?: string
|
|
114
|
+
): Promise<Coordinates | null> {
|
|
115
|
+
if (!apiKey) {
|
|
116
|
+
// Fallback: try to use browser geolocation or return null
|
|
117
|
+
console.warn('Google Maps API key not provided');
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const response = await fetch(
|
|
123
|
+
`https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(
|
|
124
|
+
address
|
|
125
|
+
)}&key=${apiKey}`
|
|
126
|
+
);
|
|
127
|
+
const data = await response.json();
|
|
128
|
+
|
|
129
|
+
if (data.status === 'OK' && data.results.length > 0) {
|
|
130
|
+
const location = data.results[0].geometry.location;
|
|
131
|
+
return { lat: location.lat, lng: location.lng };
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
} catch (error) {
|
|
135
|
+
console.error('Geocoding error:', error);
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Maps utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Coordinates } from './location-utils';
|
|
6
|
+
import type { PickupLocation } from './api';
|
|
7
|
+
import type { NearbyLocation } from './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
|
+
|