@ticketboothapp/booking 0.1.10 → 0.1.12
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/colours.css +23 -0
- package/src/components/BookingDetails.module.css +1591 -0
- package/src/components/BookingDetails.tsx +2072 -354
- package/src/components/BookingWidget.tsx +28 -248
- package/src/components/JobApplicationDialog.module.css +440 -0
- package/src/components/JobApplicationDialog.tsx +620 -0
- package/src/components/ManageBookingView.tsx +344 -34
- package/src/components/PhoneInputWithCountry.module.css +131 -0
- package/src/components/PhoneInputWithCountry.tsx +44 -0
- package/src/components/PickupLocationDialog.module.css +360 -0
- package/src/components/PickupLocationDialog.tsx +357 -0
- package/src/components/PickupLocationMap.tsx +110 -0
- package/src/components/PostBookingDependentAddOnUpsell.module.css +174 -0
- package/src/components/PostBookingDependentAddOnUpsell.tsx +407 -0
- package/src/components/accordion.css +27 -0
- package/src/components/accordion.tsx +29 -0
- package/src/components/analytics/AnalyticsConsentRestore.tsx +19 -0
- package/src/components/analytics/AnalyticsScripts.tsx +106 -0
- package/src/components/analytics/CookieConsentBanner.css +86 -0
- package/src/components/analytics/CookieConsentBanner.tsx +102 -0
- package/src/components/booking/AddOnsSection.module.css +10 -0
- package/src/components/booking/AddOnsSection.tsx +184 -0
- package/src/components/booking/AdminPaymentChoiceModal.tsx +98 -0
- package/src/components/booking/BookingDialog.module.css +643 -0
- package/src/components/booking/BookingDialog.tsx +356 -0
- package/src/components/booking/BookingFlow.tsx +4385 -0
- package/src/components/booking/BookingFlowCollage.module.css +148 -0
- package/src/components/booking/BookingFlowCollage.tsx +184 -0
- package/src/components/booking/BookingFlowPlaceholder.module.css +27 -0
- package/src/components/booking/BookingFlowPlaceholder.tsx +25 -0
- package/src/components/booking/BookingFlowPreview.tsx +51 -0
- package/src/components/booking/BookingProductGrid.module.css +359 -0
- package/src/components/booking/BookingProductGrid.tsx +497 -0
- package/src/components/booking/Calendar.module.css +616 -0
- package/src/components/{Calendar.tsx → booking/Calendar.tsx} +464 -247
- package/src/components/booking/CancellationPolicySelector.module.css +124 -0
- package/src/components/booking/CancellationPolicySelector.tsx +142 -0
- package/src/components/booking/ChangeBookingDialog.tsx +562 -0
- package/src/components/booking/CheckoutForm.module.css +244 -0
- package/src/components/booking/CheckoutForm.tsx +364 -0
- package/src/components/{CheckoutModal.tsx → booking/CheckoutModal.tsx} +176 -19
- package/src/components/booking/DapFlowCollage.tsx +88 -0
- package/src/components/booking/DapTourDescription.tsx +35 -0
- package/src/components/booking/DependentAddOnBookingDialog.tsx +1350 -0
- package/src/components/booking/DependentAddOnPaymentForm.tsx +124 -0
- package/src/components/booking/InfoTooltip.tsx +108 -0
- package/src/components/booking/ItineraryBox.module.css +258 -0
- package/src/components/booking/ItineraryBox.tsx +550 -0
- package/src/components/{ItineraryBuilder.tsx → booking/ItineraryBuilder.tsx} +1 -2
- package/src/components/booking/ItineraryPlaceholder.module.css +45 -0
- package/src/components/booking/ItineraryPlaceholder.tsx +26 -0
- package/src/components/{MealDrinkAddOnSelector.tsx → booking/MealDrinkAddOnSelector.tsx} +21 -13
- package/src/components/booking/PickupLocationSelector.module.css +124 -0
- package/src/components/{PickupLocationSelector.tsx → booking/PickupLocationSelector.tsx} +315 -290
- package/src/components/booking/PickupTimeSelector.module.css +134 -0
- package/src/components/booking/PickupTimeSelector.tsx +112 -0
- package/src/components/{PriceBreakdown.tsx → booking/PriceBreakdown.tsx} +3 -3
- package/src/components/{PriceSummary.tsx → booking/PriceSummary.tsx} +51 -28
- package/src/components/booking/PrivateShuttleBookingFlow.module.css +357 -0
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +2662 -0
- package/src/components/booking/PromoCodeInput.module.css +166 -0
- package/src/components/booking/PromoCodeInput.tsx +99 -0
- package/src/components/booking/ReturnTimeSelector.module.css +173 -0
- package/src/components/booking/ReturnTimeSelector.tsx +145 -0
- package/src/components/{TermsAcceptance.tsx → booking/TermsAcceptance.tsx} +9 -8
- package/src/components/booking/TicketSelector.module.css +164 -0
- package/src/components/booking/TicketSelector.tsx +199 -0
- package/src/components/booking/TourDescription.module.css +304 -0
- package/src/components/booking/TourDescription.tsx +273 -0
- package/src/components/booking/booking-flow-ui.ts +15 -1
- package/src/components/booking/booking-flow.css +944 -0
- package/src/components/bottom-sheet.module.css +78 -0
- package/src/components/bottom-sheet.tsx +60 -0
- package/src/components/breadcrumb.module.css +40 -0
- package/src/components/breadcrumb.tsx +36 -0
- package/src/components/button.css +245 -0
- package/src/components/button.tsx +152 -0
- package/src/components/client-bottom-sheet.tsx +14 -0
- package/src/components/colorable-svg.tsx +29 -0
- package/src/components/conditional-footer.tsx +27 -0
- package/src/components/contact-us.module.css +147 -0
- package/src/components/contact-us.tsx +49 -0
- package/src/components/email-signup.css +151 -0
- package/src/components/email-signup.tsx +63 -0
- package/src/components/faq-wrapper.module.css +47 -0
- package/src/components/faq-wrapper.tsx +15 -0
- package/src/components/footer.css +187 -0
- package/src/components/footer.tsx +143 -0
- package/src/components/global-simple-modal.tsx +33 -0
- package/src/components/google-review-summary.module.css +77 -0
- package/src/components/google-review-summary.tsx +50 -0
- package/src/components/hero-image.css +13 -0
- package/src/components/hero-image.tsx +44 -0
- package/src/components/image.css +29 -0
- package/src/components/image.tsx +113 -0
- package/src/components/language-aware-link.tsx +72 -0
- package/src/components/language-switcher.module.css +124 -0
- package/src/components/language-switcher.tsx +75 -0
- package/src/components/map-section.css +59 -0
- package/src/components/map-section.tsx +63 -0
- package/src/components/navbar.module.css +152 -0
- package/src/components/navbar.tsx +125 -0
- package/src/components/parallax-provider.tsx +11 -0
- package/src/components/partner/PartnerBookingPage.module.css +130 -0
- package/src/components/partner/PartnerBookingPage.tsx +390 -0
- package/src/components/partner/PartnerBookingPageWithBrowserMetadata.tsx +19 -35
- package/src/components/product-tag.module.css +30 -0
- package/src/components/product-tag.tsx +34 -0
- package/src/components/product-theme-pages/best-option.module.css +70 -0
- package/src/components/product-theme-pages/best-option.tsx +35 -0
- package/src/components/product-theme-pages/extended-tour-options.module.css +22 -0
- package/src/components/product-theme-pages/extended-tour-options.tsx +11 -0
- package/src/components/product-theme-pages/image-modal.tsx +248 -0
- package/src/components/product-theme-pages/photo-gallery.module.css +200 -0
- package/src/components/product-theme-pages/photo-gallery.tsx +90 -0
- package/src/components/product-theme-pages/product-theme-page-layout.module.css +13 -0
- package/src/components/product-theme-pages/product-theme-page-layout.tsx +67 -0
- package/src/components/product-theme-pages/top-of-fold.module.css +179 -0
- package/src/components/product-theme-pages/top-of-fold.tsx +80 -0
- package/src/components/product-tile/image-only-product-tile-desktop.module.css +106 -0
- package/src/components/product-tile/image-only-product-tile-desktop.tsx +56 -0
- package/src/components/product-tile/image-only-product-tile-mobile.module.css +122 -0
- package/src/components/product-tile/image-only-product-tile-mobile.tsx +89 -0
- package/src/components/product-tile/image-only-product-tile.tsx +44 -0
- package/src/components/product-tile/product-tile-card.module.css +84 -0
- package/src/components/product-tile/product-tile-card.tsx +61 -0
- package/src/components/review-highlights-section.css +85 -0
- package/src/components/review-highlights-section.tsx +127 -0
- package/src/components/season-closure-overlay.module.css +99 -0
- package/src/components/season-closure-overlay.tsx +98 -0
- package/src/components/simple-modal.tsx +69 -0
- package/src/components/simple-top-of-fold.module.css +76 -0
- package/src/components/simple-top-of-fold.tsx +34 -0
- package/src/components/spacer.css +41 -0
- package/src/components/spacer.tsx +23 -0
- package/src/components/star-rating.module.css +74 -0
- package/src/components/star-rating.tsx +48 -0
- package/src/components/terms/TermsContent.tsx +178 -0
- package/src/components/title-subtitle.module.css +10 -0
- package/src/components/title-subtitle.tsx +30 -0
- package/src/components/translatable-reviews.tsx +75 -0
- package/src/components/value-pill.module.css +59 -0
- package/src/components/value-pill.tsx +46 -0
- package/src/components/value-props.css +185 -0
- package/src/components/value-props.tsx +88 -0
- package/src/constants/booking-guide-quiz.ts +64 -0
- package/src/constants/contact-info.ts +2 -0
- package/src/constants/faq.ts +44 -0
- package/src/constants/images.ts +556 -0
- package/src/constants/json-ld/faq-json-ld.tsx +170 -0
- package/src/constants/json-ld/homepage-json-ld.tsx +138 -0
- package/src/constants/json-ld/job-posting-json-ld.tsx +92 -0
- package/src/constants/json-ld/organization-json-ld.tsx +62 -0
- package/src/constants/json-ld/page-json-ld.tsx +6 -0
- package/src/constants/json-ld/product-json-ld.tsx +154 -0
- package/src/constants/json-ld/review-json-ld.tsx +377 -0
- package/src/constants/navigation-links/footer-links.ts +48 -0
- package/src/constants/navigation-links/nav-bar-links.ts +41 -0
- package/src/constants/navigation-links/navigation-link.ts +6 -0
- package/src/constants/pill-values.ts +210 -0
- package/src/constants/products.ts +155 -0
- package/src/constants/quiz-recommendations.ts +506 -0
- package/src/constants/reviews.ts +75 -0
- package/src/constants/staff.ts +197 -0
- package/src/constants/value-props.ts +58 -0
- package/src/data/dap-descriptions/session-couples-families-friends.en.json +61 -0
- package/src/data/dap-descriptions/session-elopements.en.json +60 -0
- package/src/data/dap-descriptions/session-proposals.en.json +60 -0
- package/src/data/product-descriptions/afternoon-delight.en.json +35 -0
- package/src/data/product-descriptions/emerald-lake-escape.en.json +68 -0
- package/src/data/product-descriptions/lake-louise-adventure.en.json +74 -0
- package/src/data/product-descriptions/moraine-lake-adventure.en.json +78 -0
- package/src/data/product-descriptions/moraine-lake-sunrise-lake-louise-golden-hour.en.json +65 -0
- package/src/data/product-descriptions/moraine-lake-sunrise.en.json +64 -0
- package/src/data/product-descriptions/private-tour.en.json +80 -0
- package/src/data/product-descriptions/two-lakes-combo.en.json +65 -0
- package/src/data/products-config.json +101 -0
- package/src/hooks/use-bottom-sheet.tsx +15 -0
- package/src/hooks/use-simple-modal.tsx +27 -0
- package/src/hooks/useBookingSourceMetadataFromLocation.ts +21 -0
- package/src/hooks/useEmailSubscription.tsx +103 -0
- package/src/hooks/useEmbeddedInIframe.ts +16 -0
- package/src/hooks/useIsBookingLaunchLive.ts +49 -0
- package/src/hooks/useQuiz.tsx +210 -0
- package/src/index.ts +27 -2
- package/src/lib/analytics.ts +197 -0
- package/src/lib/booking/booking-source.ts +20 -2
- package/src/lib/{checkout-breakdown.ts → booking/checkout-breakdown.ts} +1 -1
- package/src/lib/booking/correlation-id.ts +46 -0
- package/src/lib/{i18n → booking/i18n}/messages/en.json +48 -4
- package/src/lib/{i18n → booking/i18n}/messages/fr.json +48 -4
- package/src/lib/booking/itinerary-display.ts +36 -0
- package/src/lib/{itinerary-labels.ts → booking/itinerary-labels.ts} +1 -1
- package/src/lib/{location-calculations.ts → booking/location-calculations.ts} +4 -4
- package/src/lib/{location-utils.ts → booking/location-utils.ts} +26 -0
- package/src/lib/{map-utils.ts → booking/map-utils.ts} +3 -3
- package/src/lib/booking/normalize-booking-product-id.ts +7 -0
- package/src/lib/{pickup-location-types.ts → booking/pickup-location-types.ts} +2 -2
- package/src/lib/{pricing.ts → booking/pricing.ts} +2 -2
- package/src/lib/booking/product-option-id.ts +35 -0
- package/src/lib/booking/source-metadata.ts +72 -7
- package/src/lib/booking/sunday-week.ts +14 -0
- package/src/lib/booking/trace-context.ts +62 -0
- package/src/lib/booking-api.ts +1793 -0
- package/src/lib/{constants.ts → booking-constants.ts} +11 -5
- package/src/lib/booking-types.ts +36 -0
- package/src/lib/currency.ts +38 -45
- package/src/lib/dap-descriptions.ts +50 -0
- package/src/lib/dap-itinerary-preview.ts +315 -0
- package/src/lib/dependent-add-on-api.ts +434 -0
- package/src/lib/env.ts +89 -5
- package/src/lib/firebase.ts +20 -0
- package/src/lib/job-application-api.ts +83 -0
- package/src/lib/manage-booking-embed-print.ts +16 -0
- package/src/lib/manage-booking-post-checkout.ts +68 -0
- package/src/lib/photo-dap-config.ts +228 -0
- package/src/lib/pickup/map-utils.ts +56 -0
- package/src/lib/pickup/marker-icons.ts +19 -0
- package/src/lib/product-descriptions.ts +66 -0
- package/src/lib/products-config.ts +73 -0
- package/src/providers/booking-dialog-provider.tsx +107 -38
- package/src/providers/bottom-sheet-provider.tsx +40 -0
- package/src/providers/dependent-add-on-dialog-provider.tsx +105 -0
- package/src/radius.css +5 -0
- package/src/spacing.css +7 -0
- package/src/strings/en.json +1774 -0
- package/src/strings/es.json +1573 -0
- package/src/strings/fr.json +1573 -0
- package/src/strings/index.js +23 -0
- package/src/text-style.css +97 -0
- package/src/types/fareharbor.d.ts +12 -0
- package/src/types/quiz.ts +59 -0
- package/src/utils/currency-converter.ts +101 -0
- package/src/components/BookingFlow.tsx +0 -2952
- package/src/components/LanguageSwitcher.tsx +0 -30
- package/src/components/PrivateShuttleBookingFlow.tsx +0 -2290
- package/src/components/ProductList.tsx +0 -78
- package/src/components/WhatsAppPhoneInput.tsx +0 -224
- package/src/components/index.ts +0 -31
- package/src/lib/api.ts +0 -801
- package/src/lib/booking-api-auth.ts +0 -9
- package/src/lib/checkout-breakdown.test.ts +0 -70
- package/src/types/google-maps.d.ts +0 -2
- /package/src/components/{CurrencySwitcher.tsx → booking/CurrencySwitcher.tsx} +0 -0
- /package/src/components/{ErrorBoundary.tsx → booking/ErrorBoundary.tsx} +0 -0
- /package/src/lib/{i18n → booking/i18n}/config.ts +0 -0
- /package/src/lib/{i18n → booking/i18n}/index.tsx +0 -0
- /package/src/lib/{marker-icons.ts → booking/marker-icons.ts} +0 -0
- /package/src/lib/{places-api.ts → booking/places-api.ts} +0 -0
- /package/src/lib/{theme.ts → booking/theme.ts} +0 -0
- /package/src/lib/{utils.ts → booking/utils.ts} +0 -0
|
@@ -2,37 +2,39 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
|
4
4
|
import { GoogleMap, Marker, InfoWindow, useJsApiLoader } from '@react-google-maps/api';
|
|
5
|
-
import type { PickupLocation, Destination } from '@/lib/api';
|
|
5
|
+
import type { PickupLocation, Destination } from '@/lib/booking-api';
|
|
6
6
|
import {
|
|
7
7
|
formatDistance,
|
|
8
8
|
formatTime,
|
|
9
9
|
geocodeAddress,
|
|
10
|
-
|
|
10
|
+
isWithinPrivateShuttleServiceArea,
|
|
11
|
+
} from '@/lib/booking/location-utils';
|
|
11
12
|
import {
|
|
12
13
|
calculateNearbyLocations,
|
|
13
14
|
isExactMatch,
|
|
14
|
-
} from '@/lib/location-calculations';
|
|
15
|
+
} from '@/lib/booking/location-calculations';
|
|
15
16
|
import {
|
|
16
17
|
getAutocompleteSuggestions,
|
|
17
18
|
getPlaceDetails,
|
|
18
19
|
type AutocompleteSuggestion,
|
|
19
|
-
} from '@/lib/places-api';
|
|
20
|
+
} from '@/lib/booking/places-api';
|
|
20
21
|
import {
|
|
21
22
|
createDistanceMarkerIcon,
|
|
22
23
|
createPinMarkerIcon,
|
|
23
24
|
createSearchedLocationPinIcon,
|
|
24
25
|
createDestinationMarkerIcon,
|
|
25
|
-
} from '@/lib/marker-icons';
|
|
26
|
+
} from '@/lib/booking/marker-icons';
|
|
26
27
|
import { ENV } from '@/lib/env';
|
|
27
28
|
import {
|
|
28
29
|
calculateMapCenter,
|
|
29
30
|
calculateMapBounds,
|
|
30
31
|
getMapOptions,
|
|
31
32
|
panToLocationIfNeeded,
|
|
32
|
-
} from '@/lib/map-utils';
|
|
33
|
-
import type { SearchedLocation, NearbyLocation } from '@/lib/pickup-location-types';
|
|
34
|
-
import { useTranslations } from '@/lib/i18n';
|
|
33
|
+
} from '@/lib/booking/map-utils';
|
|
34
|
+
import type { SearchedLocation, NearbyLocation } from '@/lib/booking/pickup-location-types';
|
|
35
|
+
import { useTranslations } from '@/lib/booking/i18n';
|
|
35
36
|
import { useBookingApp } from '@/contexts/BookingAppContext';
|
|
37
|
+
import styles from './PickupLocationSelector.module.css';
|
|
36
38
|
|
|
37
39
|
export interface CustomPickupLocation {
|
|
38
40
|
address: string;
|
|
@@ -45,10 +47,18 @@ interface PickupLocationSelectorProps {
|
|
|
45
47
|
selectedCustomAddress?: string | null;
|
|
46
48
|
/** When true (default), show "Use this address" for custom locations. Only Private Shuttle uses this. */
|
|
47
49
|
allowCustomLocation?: boolean;
|
|
48
|
-
|
|
50
|
+
/** When true with allowCustomLocation, restrict custom addresses to Lake Louise–Kananaskis corridor. */
|
|
51
|
+
restrictCustomLocationToServiceArea?: boolean;
|
|
52
|
+
onLocationSelect: (locationId: string | null, customLocation?: CustomPickupLocation, locationName?: string) => void;
|
|
49
53
|
onSkip?: () => void;
|
|
50
54
|
isSkipped?: boolean;
|
|
51
55
|
destinations?: Destination[]; // Destination locations to show on the map
|
|
56
|
+
/** When true, hide the title (e.g. when embedded in a dialog that has its own title) */
|
|
57
|
+
hideTitle?: boolean;
|
|
58
|
+
/** When true, hide the "I don't know" filter pill and option (e.g. when changing pickup on existing booking) */
|
|
59
|
+
hideSkipOption?: boolean;
|
|
60
|
+
/** Location IDs to prioritize at the top and visually highlight (e.g. partner-preferred pickups). */
|
|
61
|
+
highlightedPickupLocationIds?: string[];
|
|
52
62
|
}
|
|
53
63
|
|
|
54
64
|
// Constants
|
|
@@ -78,6 +88,10 @@ const FILTERS = [
|
|
|
78
88
|
|
|
79
89
|
type FilterId = typeof FILTERS[number]['id'];
|
|
80
90
|
|
|
91
|
+
/** Stable fallback — default `= []` in destructuring would allocate a new array every render. */
|
|
92
|
+
const NO_HIGHLIGHTED_PICKUP_LOCATION_IDS: string[] = [];
|
|
93
|
+
const NO_DESTINATIONS: Destination[] = [];
|
|
94
|
+
|
|
81
95
|
// Helper function to determine which city a location belongs to
|
|
82
96
|
function getLocationCity(location: PickupLocation): FilterId | null {
|
|
83
97
|
const addressLower = location.address.toLowerCase();
|
|
@@ -157,7 +171,7 @@ function FilterPills({ pickupLocations, selectedFilters, onFilterToggle }: Filte
|
|
|
157
171
|
}, [pickupLocations, selectedFilters]);
|
|
158
172
|
|
|
159
173
|
return (
|
|
160
|
-
<div className="
|
|
174
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
161
175
|
{FILTERS.map(filter => {
|
|
162
176
|
const isSelected = selectedFilters.has(filter.id);
|
|
163
177
|
const isCityFilter = ['canmore', 'banff', 'lake-louise'].includes(filter.id);
|
|
@@ -185,7 +199,7 @@ function FilterPills({ pickupLocations, selectedFilters, onFilterToggle }: Filte
|
|
|
185
199
|
key={filter.id}
|
|
186
200
|
type="button"
|
|
187
201
|
onClick={() => onFilterToggle(filter.id)}
|
|
188
|
-
className={`px-4 py-
|
|
202
|
+
className={`px-4 py-3 rounded-full text-[11px] font-medium transition-colors ${
|
|
189
203
|
isSelected
|
|
190
204
|
? 'bg-emerald-600 text-white hover:bg-emerald-700'
|
|
191
205
|
: 'bg-stone-100 text-stone-700 hover:bg-stone-200'
|
|
@@ -209,11 +223,17 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
|
|
|
209
223
|
selectedLocationId,
|
|
210
224
|
selectedCustomAddress = null,
|
|
211
225
|
allowCustomLocation = false,
|
|
226
|
+
restrictCustomLocationToServiceArea = false,
|
|
212
227
|
onLocationSelect,
|
|
213
228
|
onSkip,
|
|
214
229
|
isSkipped = false,
|
|
215
|
-
|
|
230
|
+
hideTitle = false,
|
|
231
|
+
hideSkipOption = false,
|
|
232
|
+
highlightedPickupLocationIds,
|
|
233
|
+
destinations: destinationsProp,
|
|
216
234
|
} = props;
|
|
235
|
+
const highlightIds = highlightedPickupLocationIds ?? NO_HIGHLIGHTED_PICKUP_LOCATION_IDS;
|
|
236
|
+
const destinations = destinationsProp ?? NO_DESTINATIONS;
|
|
217
237
|
const { t } = useTranslations();
|
|
218
238
|
const [searchInput, setSearchInput] = useState('');
|
|
219
239
|
const [searchedLocation, setSearchedLocation] = useState<SearchedLocation | null>(null);
|
|
@@ -230,7 +250,26 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
|
|
|
230
250
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
|
231
251
|
const [mapZoom, setMapZoom] = useState(DEFAULT_MAP_ZOOM);
|
|
232
252
|
const [showSkipWarning, setShowSkipWarning] = useState(false);
|
|
253
|
+
const [dontKnowFilterActive, setDontKnowFilterActive] = useState(false);
|
|
233
254
|
const mapRef = useRef<google.maps.Map | null>(null);
|
|
255
|
+
const highlightedPickupLocationIdSet = useMemo(
|
|
256
|
+
() => new Set(highlightIds),
|
|
257
|
+
[highlightIds]
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const sortHighlightedFirst = useCallback(
|
|
261
|
+
<T extends PickupLocation,>(locations: T[]): T[] => {
|
|
262
|
+
if (highlightedPickupLocationIdSet.size === 0 || locations.length < 2) return locations;
|
|
263
|
+
return [...locations].sort((a, b) => {
|
|
264
|
+
const aHighlighted = highlightedPickupLocationIdSet.has(a.id);
|
|
265
|
+
const bHighlighted = highlightedPickupLocationIdSet.has(b.id);
|
|
266
|
+
if (aHighlighted === bHighlighted) return 0;
|
|
267
|
+
return aHighlighted ? -1 : 1;
|
|
268
|
+
});
|
|
269
|
+
},
|
|
270
|
+
[highlightedPickupLocationIdSet]
|
|
271
|
+
);
|
|
272
|
+
|
|
234
273
|
const suggestionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
235
274
|
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
|
236
275
|
const focusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
@@ -250,8 +289,14 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
|
|
|
250
289
|
|
|
251
290
|
// Apply filter pills to pickup locations
|
|
252
291
|
const filteredPickupLocations = useMemo(() => {
|
|
253
|
-
return filterLocations(pickupLocations, selectedFilters);
|
|
254
|
-
}, [pickupLocations, selectedFilters]);
|
|
292
|
+
return sortHighlightedFirst(filterLocations(pickupLocations, selectedFilters));
|
|
293
|
+
}, [pickupLocations, selectedFilters, sortHighlightedFirst]);
|
|
294
|
+
|
|
295
|
+
// When restrictCustomLocationToServiceArea is true, custom address is only allowed if within Lake Louise–Kananaskis corridor
|
|
296
|
+
const isCustomLocationInServiceArea = useMemo(() => {
|
|
297
|
+
if (!restrictCustomLocationToServiceArea || !searchedLocation?.coordinates) return true;
|
|
298
|
+
return isWithinPrivateShuttleServiceArea(searchedLocation.coordinates);
|
|
299
|
+
}, [restrictCustomLocationToServiceArea, searchedLocation?.coordinates]);
|
|
255
300
|
|
|
256
301
|
// Calculate nearby locations when user searches
|
|
257
302
|
useEffect(() => {
|
|
@@ -276,7 +321,7 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
|
|
|
276
321
|
|
|
277
322
|
// Case 2: City/town filter - show all locations in that city (already filtered by pills)
|
|
278
323
|
if (cityFilter) {
|
|
279
|
-
const cityLocations = filteredPickupLocations
|
|
324
|
+
const cityLocations = sortHighlightedFirst(filteredPickupLocations
|
|
280
325
|
.filter(loc => {
|
|
281
326
|
if (!loc.coordinates) return false;
|
|
282
327
|
const addressLower = loc.address.toLowerCase();
|
|
@@ -287,7 +332,7 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
|
|
|
287
332
|
distance: 0, // No distance calculation for city filter
|
|
288
333
|
walkingTime: 0,
|
|
289
334
|
drivingTime: 0,
|
|
290
|
-
}));
|
|
335
|
+
})));
|
|
291
336
|
setNearbyLocations(cityLocations);
|
|
292
337
|
setIsValidLocation(null);
|
|
293
338
|
return;
|
|
@@ -306,11 +351,11 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
|
|
|
306
351
|
);
|
|
307
352
|
|
|
308
353
|
// Take only the 3 closest locations (already sorted by distance)
|
|
309
|
-
const closestLocations = locationsWithDistance.slice(0, 3);
|
|
354
|
+
const closestLocations = sortHighlightedFirst(locationsWithDistance.slice(0, 3));
|
|
310
355
|
|
|
311
356
|
setNearbyLocations(closestLocations);
|
|
312
357
|
setIsValidLocation(isExactMatch(locationsWithDistance));
|
|
313
|
-
}, [searchedLocation, filteredPickupLocations, exactMatchLocationId, cityFilter]);
|
|
358
|
+
}, [searchedLocation, filteredPickupLocations, exactMatchLocationId, cityFilter, sortHighlightedFirst]);
|
|
314
359
|
|
|
315
360
|
// Reset search state when switching to "I don't know" option
|
|
316
361
|
useEffect(() => {
|
|
@@ -328,10 +373,10 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
|
|
|
328
373
|
}
|
|
329
374
|
}, [isSkipped]);
|
|
330
375
|
|
|
331
|
-
// Clear search state when a location is selected
|
|
376
|
+
// Clear search state when a location is selected from the list (parent updates selectedLocationId/selectedCustomAddress)
|
|
377
|
+
// Only run when selection changes - NOT when user types (searchInput), otherwise typing gets erased
|
|
332
378
|
useEffect(() => {
|
|
333
|
-
if (selectedLocationId && (searchedLocation || searchInput)) {
|
|
334
|
-
// Location was selected externally, clear search state
|
|
379
|
+
if ((selectedLocationId || selectedCustomAddress) && (searchedLocation || searchInput)) {
|
|
335
380
|
setSearchInput('');
|
|
336
381
|
setSearchedLocation(null);
|
|
337
382
|
setIsValidLocation(null);
|
|
@@ -339,7 +384,7 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
|
|
|
339
384
|
setShowSuggestions(false);
|
|
340
385
|
setAutocompleteSuggestions([]);
|
|
341
386
|
}
|
|
342
|
-
}, [selectedLocationId,
|
|
387
|
+
}, [selectedLocationId, selectedCustomAddress]);
|
|
343
388
|
|
|
344
389
|
// Cleanup timeouts on unmount
|
|
345
390
|
useEffect(() => {
|
|
@@ -588,8 +633,8 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
|
|
|
588
633
|
}
|
|
589
634
|
};
|
|
590
635
|
|
|
591
|
-
const handleLocationSelect = (locationId: string) => {
|
|
592
|
-
onLocationSelect(locationId);
|
|
636
|
+
const handleLocationSelect = (locationId: string, locationName?: string) => {
|
|
637
|
+
onLocationSelect(locationId, undefined, locationName);
|
|
593
638
|
// Clear search state when a location is selected
|
|
594
639
|
setSearchInput('');
|
|
595
640
|
setSearchedLocation(null);
|
|
@@ -831,49 +876,26 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
|
|
|
831
876
|
}
|
|
832
877
|
|
|
833
878
|
return (
|
|
834
|
-
<div className="space-y-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
<p className="text-sm text-stone-600 mt-1">
|
|
855
|
-
{t('pickup.yesAddNowSubtext')}
|
|
856
|
-
</p>
|
|
857
|
-
{/* Show search and map interface when "Yes" option is selected (not skipped) */}
|
|
858
|
-
{!isSkipped && (
|
|
859
|
-
<div className="mt-2">
|
|
860
|
-
<div className="relative">
|
|
861
|
-
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
862
|
-
<svg
|
|
863
|
-
className="h-5 w-5 text-stone-400"
|
|
864
|
-
fill="none"
|
|
865
|
-
stroke="currentColor"
|
|
866
|
-
viewBox="0 0 24 24"
|
|
867
|
-
>
|
|
868
|
-
<path
|
|
869
|
-
strokeLinecap="round"
|
|
870
|
-
strokeLinejoin="round"
|
|
871
|
-
strokeWidth={2}
|
|
872
|
-
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
873
|
-
/>
|
|
874
|
-
</svg>
|
|
875
|
-
</div>
|
|
876
|
-
<input
|
|
879
|
+
<div className="space-y-3">
|
|
880
|
+
{!hideTitle && (
|
|
881
|
+
<div className="space-y-0.5">
|
|
882
|
+
<h2 className="text-2xl font-bold text-stone-900">
|
|
883
|
+
{t('pickup.title')}
|
|
884
|
+
</h2>
|
|
885
|
+
<p className="text-sm text-stone-600">
|
|
886
|
+
{t('pickup.yesAddNowSubtext')}
|
|
887
|
+
</p>
|
|
888
|
+
</div>
|
|
889
|
+
)}
|
|
890
|
+
|
|
891
|
+
{/* Input with search icon inside - padding ensures text stays to the right of icon */}
|
|
892
|
+
<div className="relative">
|
|
893
|
+
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-stone-400">
|
|
894
|
+
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
895
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
896
|
+
</svg>
|
|
897
|
+
</div>
|
|
898
|
+
<input
|
|
877
899
|
ref={searchInputRef}
|
|
878
900
|
type="text"
|
|
879
901
|
value={searchInput}
|
|
@@ -916,7 +938,8 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
|
|
|
916
938
|
aria-label={t('pickup.enterAddress')}
|
|
917
939
|
aria-autocomplete="list"
|
|
918
940
|
aria-controls="autocomplete-suggestions"
|
|
919
|
-
|
|
941
|
+
data-pickup-search
|
|
942
|
+
className="w-full rounded-lg border border-stone-300 focus:outline-none focus:border-stone-500 text-stone-900 placeholder:text-stone-400"
|
|
920
943
|
/>
|
|
921
944
|
{/* Autocomplete suggestions dropdown */}
|
|
922
945
|
{showSuggestions && autocompleteSuggestions.length > 0 && (
|
|
@@ -966,42 +989,57 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
|
|
|
966
989
|
))}
|
|
967
990
|
</div>
|
|
968
991
|
)}
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
</div>
|
|
993
|
-
</div>
|
|
994
|
-
)}
|
|
995
|
-
</div>
|
|
996
|
-
)}
|
|
992
|
+
{isGeocoding && (
|
|
993
|
+
<p className="mt-1 text-sm text-stone-500">
|
|
994
|
+
{t('pickup.searchingLocation')}
|
|
995
|
+
</p>
|
|
996
|
+
)}
|
|
997
|
+
{searchedLocation && !searchedLocation.coordinates && !isGeocoding && (
|
|
998
|
+
<div className="mt-2 p-3 bg-red-50 border border-red-200 rounded-lg">
|
|
999
|
+
<div className="flex items-start gap-2">
|
|
1000
|
+
<svg
|
|
1001
|
+
className="w-5 h-5 text-red-600 mt-0.5 flex-shrink-0"
|
|
1002
|
+
fill="currentColor"
|
|
1003
|
+
viewBox="0 0 20 20"
|
|
1004
|
+
>
|
|
1005
|
+
<path
|
|
1006
|
+
fillRule="evenodd"
|
|
1007
|
+
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
|
1008
|
+
clipRule="evenodd"
|
|
1009
|
+
/>
|
|
1010
|
+
</svg>
|
|
1011
|
+
<p className="text-sm text-red-700">
|
|
1012
|
+
{t('pickup.locationNotFound')}
|
|
1013
|
+
</p>
|
|
1014
|
+
</div>
|
|
997
1015
|
</div>
|
|
998
|
-
|
|
1016
|
+
)}
|
|
1017
|
+
{allowCustomLocation && restrictCustomLocationToServiceArea && searchedLocation?.coordinates && !isCustomLocationInServiceArea && (
|
|
1018
|
+
<div className="mt-2 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
|
1019
|
+
<div className="flex items-start gap-2">
|
|
1020
|
+
<svg
|
|
1021
|
+
className="w-5 h-5 text-amber-600 mt-0.5 flex-shrink-0"
|
|
1022
|
+
fill="currentColor"
|
|
1023
|
+
viewBox="0 0 20 20"
|
|
1024
|
+
>
|
|
1025
|
+
<path
|
|
1026
|
+
fillRule="evenodd"
|
|
1027
|
+
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
|
1028
|
+
clipRule="evenodd"
|
|
1029
|
+
/>
|
|
1030
|
+
</svg>
|
|
1031
|
+
<p className="text-sm text-amber-800">
|
|
1032
|
+
{t('pickup.outsideServiceArea')}
|
|
1033
|
+
</p>
|
|
1034
|
+
</div>
|
|
1035
|
+
</div>
|
|
1036
|
+
)}
|
|
1037
|
+
</div>
|
|
999
1038
|
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
<FilterPills
|
|
1039
|
+
{/* Filter Pills + I don't know pill - horizontal scroll on mobile */}
|
|
1040
|
+
{isLoaded && pickupLocations.length > 0 && (
|
|
1041
|
+
<div className={styles.filterPillsScroll}>
|
|
1042
|
+
<FilterPills
|
|
1005
1043
|
pickupLocations={pickupLocations}
|
|
1006
1044
|
selectedFilters={selectedFilters}
|
|
1007
1045
|
onFilterToggle={(filterId) => {
|
|
@@ -1033,6 +1071,11 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
|
|
|
1033
1071
|
|
|
1034
1072
|
setSelectedFilters(newFilters);
|
|
1035
1073
|
|
|
1074
|
+
// When a location filter (city or free-parking) is selected, exit "I don't know" mode
|
|
1075
|
+
if (newFilters.size > 0) {
|
|
1076
|
+
setDontKnowFilterActive(false);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1036
1079
|
// When a city filter is active, also drive the map zoom like a typed city search
|
|
1037
1080
|
const activeCityFilter = Array.from(newFilters).find(f =>
|
|
1038
1081
|
cityFilterIds.includes(f as typeof cityFilterIds[number])
|
|
@@ -1052,23 +1095,147 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
|
|
|
1052
1095
|
}
|
|
1053
1096
|
}}
|
|
1054
1097
|
/>
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1098
|
+
{/* I don't know pill - only show when no filters are selected and not hidden */}
|
|
1099
|
+
{!hideSkipOption && selectedFilters.size === 0 && (
|
|
1100
|
+
<button
|
|
1101
|
+
type="button"
|
|
1102
|
+
onClick={() => setDontKnowFilterActive(!dontKnowFilterActive)}
|
|
1103
|
+
className={`px-4 py-3 rounded-full text-[11px] font-medium transition-colors ${
|
|
1104
|
+
dontKnowFilterActive ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-stone-100 text-stone-700 hover:bg-stone-200'
|
|
1105
|
+
}`}
|
|
1106
|
+
>
|
|
1107
|
+
{t('pickup.dontKnow')}
|
|
1108
|
+
</button>
|
|
1109
|
+
)}
|
|
1110
|
+
</div>
|
|
1111
|
+
)}
|
|
1112
|
+
|
|
1113
|
+
{/* Two-column layout: list left, map right - list height matches map */}
|
|
1114
|
+
{isLoaded && pickupLocations.length > 0 && (
|
|
1115
|
+
<div className={styles.twoColLayout}>
|
|
1116
|
+
{/* Left column: scrollable list of pickup locations + I don't know */}
|
|
1117
|
+
<div className={styles.leftColumn}>
|
|
1118
|
+
<div className={styles.locationList}>
|
|
1119
|
+
{/* When "I don't know" filter is active, show only that option (unless hidden) */}
|
|
1120
|
+
{!hideSkipOption && dontKnowFilterActive ? (
|
|
1121
|
+
<label
|
|
1122
|
+
className="flex items-start gap-2 cursor-pointer p-2.5 rounded-lg hover:bg-stone-50 border border-stone-200"
|
|
1123
|
+
onClick={(e) => {
|
|
1124
|
+
e.preventDefault();
|
|
1125
|
+
handleSkip();
|
|
1126
|
+
}}
|
|
1127
|
+
>
|
|
1128
|
+
<input
|
|
1129
|
+
type="radio"
|
|
1130
|
+
name="pickup-location"
|
|
1131
|
+
checked={isSkipped}
|
|
1132
|
+
readOnly
|
|
1133
|
+
onClick={(e) => {
|
|
1134
|
+
e.preventDefault();
|
|
1135
|
+
handleSkip();
|
|
1136
|
+
}}
|
|
1137
|
+
className="mt-1 w-4 h-4 text-emerald-600 focus:ring-emerald-500 shrink-0"
|
|
1138
|
+
aria-label={t('pickup.dontKnow')}
|
|
1139
|
+
/>
|
|
1140
|
+
<div className="flex-1 min-w-0">
|
|
1141
|
+
<p className="font-medium text-stone-900 text-sm">{t('pickup.dontKnow')}</p>
|
|
1142
|
+
<p className="text-xs text-stone-600">{t('pickup.dontKnowSubtext')}</p>
|
|
1143
|
+
</div>
|
|
1144
|
+
</label>
|
|
1145
|
+
) : (
|
|
1146
|
+
<>
|
|
1147
|
+
{/* Custom address option - show at TOP when user searched (Private Shuttle allows custom), and within service area if restricted */}
|
|
1148
|
+
{allowCustomLocation && searchedLocation && !exactMatchLocationId && !cityFilter && isCustomLocationInServiceArea && (
|
|
1149
|
+
<label className="flex items-start gap-2 cursor-pointer p-3 rounded-lg hover:bg-emerald-50/50 border-2 border-emerald-500 bg-emerald-50/50 shadow-sm" onMouseEnter={() => { setHoveredMarker('searched'); mapRef.current && searchedLocation.coordinates && panToLocationIfNeeded(mapRef.current, searchedLocation.coordinates); }} onMouseLeave={() => setHoveredMarker(null)}>
|
|
1150
|
+
<input type="radio" name="pickup-location" checked={selectedCustomAddress === searchedLocation.address} onChange={() => handleCustomLocationSelect(searchedLocation.address, searchedLocation.coordinates ?? undefined)} className="mt-1 w-4 h-4 text-emerald-600 focus:ring-emerald-500 shrink-0" />
|
|
1151
|
+
<div className="flex-1 min-w-0">
|
|
1152
|
+
<p className="font-semibold text-emerald-800 text-sm">{t('pickup.useThisAddress')}</p>
|
|
1153
|
+
<p className="text-xs text-stone-600 truncate">{searchedLocation.address}</p>
|
|
1154
|
+
</div>
|
|
1155
|
+
</label>
|
|
1156
|
+
)}
|
|
1157
|
+
{/* Locations to show: nearbyLocations when we have search/filter results, else filteredPickupLocations */}
|
|
1158
|
+
{(nearbyLocations.length > 0 ? nearbyLocations : filteredPickupLocations).map((location) => {
|
|
1159
|
+
const isNearby = nearbyLocations.length > 0 && nearbyLocations.some(n => n.id === location.id);
|
|
1160
|
+
const isPartnerHighlighted = highlightedPickupLocationIdSet.has(location.id);
|
|
1161
|
+
return (
|
|
1162
|
+
<label key={location.id} className={`flex items-start gap-2 cursor-pointer p-2.5 rounded-lg border ${
|
|
1163
|
+
isPartnerHighlighted
|
|
1164
|
+
? 'border-emerald-300 bg-emerald-50/40 hover:bg-emerald-50'
|
|
1165
|
+
: 'border-stone-200 hover:bg-stone-50'
|
|
1166
|
+
}`} onMouseEnter={() => { setHoveredMarker(location.id); mapRef.current && location.coordinates && panToLocationIfNeeded(mapRef.current, location.coordinates); }} onMouseLeave={() => setHoveredMarker(null)}>
|
|
1167
|
+
<input type="radio" name="pickup-location" checked={selectedLocationId === location.id} onChange={() => handleLocationSelect(location.id, location.name)} className="mt-1 w-4 h-4 text-emerald-600 focus:ring-emerald-500 shrink-0" />
|
|
1168
|
+
<div className="flex-1 min-w-0">
|
|
1169
|
+
<div className="flex items-center gap-1.5">
|
|
1170
|
+
<p className="font-medium text-stone-900 text-sm">{location.name}</p>
|
|
1171
|
+
{isPartnerHighlighted && (
|
|
1172
|
+
<span
|
|
1173
|
+
className="inline-flex items-center justify-center rounded-full w-4 h-4 bg-emerald-100 text-emerald-700"
|
|
1174
|
+
aria-label="Highlighted pickup location"
|
|
1175
|
+
title="Highlighted pickup location"
|
|
1176
|
+
>
|
|
1177
|
+
<svg
|
|
1178
|
+
className="w-2.5 h-2.5"
|
|
1179
|
+
viewBox="0 0 20 20"
|
|
1180
|
+
fill="currentColor"
|
|
1181
|
+
aria-hidden="true"
|
|
1182
|
+
>
|
|
1183
|
+
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.539 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.783.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.95-.69l1.07-3.292z" />
|
|
1184
|
+
</svg>
|
|
1185
|
+
</span>
|
|
1186
|
+
)}
|
|
1187
|
+
</div>
|
|
1188
|
+
<p className="text-xs text-stone-600 truncate">{location.address}</p>
|
|
1189
|
+
{isNearby && searchedLocation && !exactMatchLocationId && !cityFilter && (
|
|
1190
|
+
<div className="flex items-center gap-2 mt-1 text-xs text-stone-500">
|
|
1191
|
+
<span>📍 {formatDistance((location as NearbyLocation).distance, useImperial)}</span>
|
|
1192
|
+
<span>🚶 {formatTime((location as NearbyLocation).walkingTime)}</span>
|
|
1193
|
+
</div>
|
|
1194
|
+
)}
|
|
1195
|
+
</div>
|
|
1196
|
+
</label>
|
|
1197
|
+
);})}
|
|
1198
|
+
{/* No results message */}
|
|
1199
|
+
{nearbyLocations.length === 0 && filteredPickupLocations.length === 0 && selectedFilters.size > 0 && (
|
|
1200
|
+
<p className="p-3 text-sm text-stone-500 text-center">No locations match. Try adjusting filters.</p>
|
|
1201
|
+
)}
|
|
1202
|
+
{/* I don't know - only at end of list when no filters are selected (unless hidden) */}
|
|
1203
|
+
{!hideSkipOption && selectedFilters.size === 0 && (
|
|
1204
|
+
<label
|
|
1205
|
+
className="flex items-start gap-2 cursor-pointer p-2.5 rounded-lg hover:bg-stone-50 border border-stone-200"
|
|
1206
|
+
onClick={(e) => {
|
|
1207
|
+
e.preventDefault();
|
|
1208
|
+
handleSkip();
|
|
1209
|
+
}}
|
|
1210
|
+
>
|
|
1211
|
+
<input
|
|
1212
|
+
type="radio"
|
|
1213
|
+
name="pickup-location"
|
|
1214
|
+
checked={isSkipped}
|
|
1215
|
+
readOnly
|
|
1216
|
+
onClick={(e) => {
|
|
1217
|
+
e.preventDefault();
|
|
1218
|
+
handleSkip();
|
|
1219
|
+
}}
|
|
1220
|
+
className="mt-1 w-4 h-4 text-emerald-600 focus:ring-emerald-500 shrink-0"
|
|
1221
|
+
aria-label={t('pickup.dontKnow')}
|
|
1222
|
+
/>
|
|
1223
|
+
<div className="flex-1 min-w-0">
|
|
1224
|
+
<p className="font-medium text-stone-900 text-sm">{t('pickup.dontKnow')}</p>
|
|
1225
|
+
<p className="text-xs text-stone-600">{t('pickup.dontKnowSubtext')}</p>
|
|
1226
|
+
</div>
|
|
1227
|
+
</label>
|
|
1228
|
+
)}
|
|
1229
|
+
</>
|
|
1230
|
+
)}
|
|
1067
1231
|
</div>
|
|
1068
|
-
|
|
1069
|
-
|
|
1232
|
+
</div>
|
|
1233
|
+
|
|
1234
|
+
{/* Right column: map */}
|
|
1235
|
+
<div className={styles.rightColumn}>
|
|
1236
|
+
<div className={styles.mapWrapper}>
|
|
1070
1237
|
<GoogleMap
|
|
1071
|
-
mapContainerClassName="w-full h-
|
|
1238
|
+
mapContainerClassName="w-full h-full rounded-lg overflow-hidden"
|
|
1072
1239
|
center={mapCenter}
|
|
1073
1240
|
zoom={mapZoom}
|
|
1074
1241
|
options={mapOptions}
|
|
@@ -1127,7 +1294,7 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
|
|
|
1127
1294
|
</div>
|
|
1128
1295
|
<button
|
|
1129
1296
|
onClick={() => {
|
|
1130
|
-
handleLocationSelect(location.id);
|
|
1297
|
+
handleLocationSelect(location.id, location.name);
|
|
1131
1298
|
setSelectedMarker(null);
|
|
1132
1299
|
}}
|
|
1133
1300
|
className="mt-2 w-full px-3 py-1.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 transition-colors"
|
|
@@ -1178,7 +1345,7 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
|
|
|
1178
1345
|
)}
|
|
1179
1346
|
<button
|
|
1180
1347
|
onClick={() => {
|
|
1181
|
-
handleLocationSelect(location.id);
|
|
1348
|
+
handleLocationSelect(location.id, location.name);
|
|
1182
1349
|
setSelectedMarker(null);
|
|
1183
1350
|
}}
|
|
1184
1351
|
className="mt-2 w-full px-3 py-1.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 transition-colors"
|
|
@@ -1235,7 +1402,7 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
|
|
|
1235
1402
|
)}
|
|
1236
1403
|
<button
|
|
1237
1404
|
onClick={() => {
|
|
1238
|
-
handleLocationSelect(location.id);
|
|
1405
|
+
handleLocationSelect(location.id, location.name);
|
|
1239
1406
|
setSelectedMarker(null);
|
|
1240
1407
|
}}
|
|
1241
1408
|
className="mt-2 w-full px-3 py-1.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 transition-colors"
|
|
@@ -1275,7 +1442,7 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
|
|
|
1275
1442
|
<p className="text-sm text-stone-600 mb-2">
|
|
1276
1443
|
{searchedLocation.address}
|
|
1277
1444
|
</p>
|
|
1278
|
-
{allowCustomLocation && (
|
|
1445
|
+
{allowCustomLocation && isCustomLocationInServiceArea && (
|
|
1279
1446
|
<button
|
|
1280
1447
|
onClick={() => handleCustomLocationSelect(searchedLocation.address, searchedLocation.coordinates ?? undefined)}
|
|
1281
1448
|
className="w-full px-3 py-1.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 transition-colors mb-2"
|
|
@@ -1283,6 +1450,11 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
|
|
|
1283
1450
|
{t('pickup.useThisAddress')}
|
|
1284
1451
|
</button>
|
|
1285
1452
|
)}
|
|
1453
|
+
{allowCustomLocation && !isCustomLocationInServiceArea && restrictCustomLocationToServiceArea && (
|
|
1454
|
+
<p className="text-xs text-amber-700 mb-2">
|
|
1455
|
+
{t('pickup.outsideServiceArea')}
|
|
1456
|
+
</p>
|
|
1457
|
+
)}
|
|
1286
1458
|
<p className="text-xs text-stone-500 mt-2">
|
|
1287
1459
|
{t('pickup.chooseClosest')}
|
|
1288
1460
|
</p>
|
|
@@ -1308,174 +1480,15 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
|
|
|
1308
1480
|
/>
|
|
1309
1481
|
))}
|
|
1310
1482
|
</GoogleMap>
|
|
1311
|
-
</div>
|
|
1312
|
-
)}
|
|
1313
|
-
|
|
1314
|
-
{/* Filtered locations list */}
|
|
1315
|
-
{nearbyLocations.length > 0 && (
|
|
1316
|
-
<div className="ml-8 space-y-4">
|
|
1317
|
-
<h3 className="font-semibold text-stone-900">
|
|
1318
|
-
{exactMatchLocationId
|
|
1319
|
-
? t('pickup.exactMatch')
|
|
1320
|
-
: cityFilter
|
|
1321
|
-
? t('pickup.locationsInCity', { city: cityFilter.charAt(0).toUpperCase() + cityFilter.slice(1) })
|
|
1322
|
-
: t('pickup.chooseNearby')}
|
|
1323
|
-
</h3>
|
|
1324
|
-
<div className="space-y-3">
|
|
1325
|
-
{/* Custom address option - only when user searched for an address (Case 3) and allowCustomLocation is true */}
|
|
1326
|
-
{allowCustomLocation && searchedLocation && !exactMatchLocationId && !cityFilter && (
|
|
1327
|
-
<label
|
|
1328
|
-
className="flex items-start gap-3 cursor-pointer p-3 rounded-lg hover:bg-stone-50 border border-stone-200 bg-emerald-50/30"
|
|
1329
|
-
onMouseEnter={() => {
|
|
1330
|
-
setHoveredMarker('searched');
|
|
1331
|
-
if (mapRef.current && searchedLocation.coordinates) {
|
|
1332
|
-
panToLocationIfNeeded(mapRef.current, searchedLocation.coordinates);
|
|
1333
|
-
}
|
|
1334
|
-
}}
|
|
1335
|
-
onMouseLeave={() => setHoveredMarker(null)}
|
|
1336
|
-
>
|
|
1337
|
-
<input
|
|
1338
|
-
type="radio"
|
|
1339
|
-
name="pickup-location"
|
|
1340
|
-
checked={selectedCustomAddress === searchedLocation.address}
|
|
1341
|
-
onChange={() => handleCustomLocationSelect(searchedLocation.address, searchedLocation.coordinates ?? undefined)}
|
|
1342
|
-
className="mt-1 w-5 h-5 text-emerald-600 focus:ring-emerald-500"
|
|
1343
|
-
/>
|
|
1344
|
-
<div className="flex-1">
|
|
1345
|
-
<p className="font-medium text-stone-900">
|
|
1346
|
-
{t('pickup.useThisAddress')} — {searchedLocation.address}
|
|
1347
|
-
</p>
|
|
1348
|
-
<p className="text-sm text-stone-600 mt-1">
|
|
1349
|
-
{t('pickup.yourLocation')}
|
|
1350
|
-
</p>
|
|
1351
|
-
</div>
|
|
1352
|
-
</label>
|
|
1353
|
-
)}
|
|
1354
|
-
{nearbyLocations.map((location) => (
|
|
1355
|
-
<label
|
|
1356
|
-
key={location.id}
|
|
1357
|
-
className="flex items-start gap-3 cursor-pointer p-3 rounded-lg hover:bg-stone-50 border border-stone-200"
|
|
1358
|
-
onMouseEnter={() => {
|
|
1359
|
-
setHoveredMarker(location.id);
|
|
1360
|
-
if (mapRef.current && location.coordinates) {
|
|
1361
|
-
panToLocationIfNeeded(mapRef.current, location.coordinates);
|
|
1362
|
-
}
|
|
1363
|
-
}}
|
|
1364
|
-
onMouseLeave={() => {
|
|
1365
|
-
setHoveredMarker(null);
|
|
1366
|
-
}}
|
|
1367
|
-
>
|
|
1368
|
-
<input
|
|
1369
|
-
type="radio"
|
|
1370
|
-
name="pickup-location"
|
|
1371
|
-
checked={selectedLocationId === location.id}
|
|
1372
|
-
onChange={() => handleLocationSelect(location.id)}
|
|
1373
|
-
className="mt-1 w-5 h-5 text-emerald-600 focus:ring-emerald-500"
|
|
1374
|
-
/>
|
|
1375
|
-
<div className="flex-1">
|
|
1376
|
-
<p className="font-medium text-stone-900">
|
|
1377
|
-
{location.name}
|
|
1378
|
-
</p>
|
|
1379
|
-
<p className="text-sm text-stone-600 mt-1">
|
|
1380
|
-
{location.address}
|
|
1381
|
-
</p>
|
|
1382
|
-
{/* Only show distance/time for address searches (Case 3) */}
|
|
1383
|
-
{searchedLocation && !exactMatchLocationId && !cityFilter && (
|
|
1384
|
-
<div className="flex items-center gap-4 mt-2 text-sm text-stone-500">
|
|
1385
|
-
<span className="flex items-center gap-1">
|
|
1386
|
-
<svg
|
|
1387
|
-
className="w-4 h-4"
|
|
1388
|
-
fill="none"
|
|
1389
|
-
stroke="currentColor"
|
|
1390
|
-
viewBox="0 0 24 24"
|
|
1391
|
-
>
|
|
1392
|
-
<path
|
|
1393
|
-
strokeLinecap="round"
|
|
1394
|
-
strokeLinejoin="round"
|
|
1395
|
-
strokeWidth={2}
|
|
1396
|
-
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
|
1397
|
-
/>
|
|
1398
|
-
<path
|
|
1399
|
-
strokeLinecap="round"
|
|
1400
|
-
strokeLinejoin="round"
|
|
1401
|
-
strokeWidth={2}
|
|
1402
|
-
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
|
1403
|
-
/>
|
|
1404
|
-
</svg>
|
|
1405
|
-
{formatDistance(location.distance, useImperial)}
|
|
1406
|
-
</span>
|
|
1407
|
-
<span className="flex items-center gap-1">
|
|
1408
|
-
<svg
|
|
1409
|
-
className="w-4 h-4"
|
|
1410
|
-
fill="none"
|
|
1411
|
-
stroke="currentColor"
|
|
1412
|
-
viewBox="0 0 24 24"
|
|
1413
|
-
>
|
|
1414
|
-
<path
|
|
1415
|
-
strokeLinecap="round"
|
|
1416
|
-
strokeLinejoin="round"
|
|
1417
|
-
strokeWidth={2}
|
|
1418
|
-
d="M13 10V3L4 14h7v7l9-11h-7z"
|
|
1419
|
-
/>
|
|
1420
|
-
</svg>
|
|
1421
|
-
{formatTime(location.walkingTime)} {t('pickup.walk')}
|
|
1422
|
-
</span>
|
|
1423
|
-
<span className="flex items-center gap-1">
|
|
1424
|
-
<svg
|
|
1425
|
-
className="w-4 h-4"
|
|
1426
|
-
fill="none"
|
|
1427
|
-
stroke="currentColor"
|
|
1428
|
-
viewBox="0 0 24 24"
|
|
1429
|
-
>
|
|
1430
|
-
<path
|
|
1431
|
-
strokeLinecap="round"
|
|
1432
|
-
strokeLinejoin="round"
|
|
1433
|
-
strokeWidth={2}
|
|
1434
|
-
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"
|
|
1435
|
-
/>
|
|
1436
|
-
</svg>
|
|
1437
|
-
{formatTime(location.drivingTime)} {t('pickup.drive')}
|
|
1438
|
-
</span>
|
|
1439
|
-
</div>
|
|
1440
|
-
)}
|
|
1441
|
-
</div>
|
|
1442
|
-
</label>
|
|
1443
|
-
))}
|
|
1444
1483
|
</div>
|
|
1445
|
-
|
|
1446
|
-
onClick={() => setUseImperial(!useImperial)}
|
|
1447
|
-
className="text-sm text-emerald-600 hover:text-emerald-700 underline"
|
|
1448
|
-
>
|
|
1449
|
-
{t('pickup.switchUnits', { unit: useImperial ? t('pickup.metric') : t('pickup.imperial') })}
|
|
1450
|
-
</button>
|
|
1451
|
-
</div>
|
|
1452
|
-
)}
|
|
1453
|
-
|
|
1454
|
-
{/* Skip option */}
|
|
1455
|
-
<label className="flex items-start gap-3 cursor-pointer">
|
|
1456
|
-
<input
|
|
1457
|
-
type="radio"
|
|
1458
|
-
name="pickup-option"
|
|
1459
|
-
checked={isSkipped && selectedLocationId === null && searchedLocation === null}
|
|
1460
|
-
onChange={handleSkip}
|
|
1461
|
-
className="mt-1 w-5 h-5 text-emerald-600 focus:ring-emerald-500"
|
|
1462
|
-
aria-label={t('pickup.dontKnow')}
|
|
1463
|
-
/>
|
|
1464
|
-
<div className="flex-1">
|
|
1465
|
-
<span className="font-medium text-stone-900">
|
|
1466
|
-
{t('pickup.dontKnow')}
|
|
1467
|
-
</span>
|
|
1468
|
-
<p className="text-sm text-stone-600 mt-1">
|
|
1469
|
-
{t('pickup.dontKnowSubtext')}
|
|
1470
|
-
</p>
|
|
1484
|
+
</div>
|
|
1471
1485
|
</div>
|
|
1472
|
-
|
|
1473
|
-
</div>
|
|
1486
|
+
)}
|
|
1474
1487
|
|
|
1475
|
-
{/* Warning Modal - Only closes when "I understand" is clicked */}
|
|
1488
|
+
{/* Warning Modal - Only closes when "I understand" or "Nevermind" is clicked */}
|
|
1476
1489
|
{showSkipWarning && (
|
|
1477
1490
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
|
|
1478
|
-
<div className="bg-white rounded-lg shadow-xl max-w-
|
|
1491
|
+
<div className="bg-white rounded-lg shadow-xl max-w-xl w-full mx-4 p-6">
|
|
1479
1492
|
<h3 className="text-lg font-semibold text-stone-900 mb-4">
|
|
1480
1493
|
{t('pickup.skipWarningTitle')}
|
|
1481
1494
|
</h3>
|
|
@@ -1483,10 +1496,20 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
|
|
|
1483
1496
|
className="text-stone-700 mb-6"
|
|
1484
1497
|
dangerouslySetInnerHTML={{ __html: t('pickup.skipWarningMessage') }}
|
|
1485
1498
|
/>
|
|
1486
|
-
<div className="flex justify-
|
|
1499
|
+
<div className="flex justify-center gap-3 flex-nowrap">
|
|
1500
|
+
<button
|
|
1501
|
+
type="button"
|
|
1502
|
+
onClick={() => {
|
|
1503
|
+
setShowSkipWarning(false);
|
|
1504
|
+
setDontKnowFilterActive(false);
|
|
1505
|
+
}}
|
|
1506
|
+
className={styles.skipModalNevermindBtn}
|
|
1507
|
+
>
|
|
1508
|
+
{t('pickup.nevermindSelectLocation')}
|
|
1509
|
+
</button>
|
|
1487
1510
|
<button
|
|
1488
1511
|
onClick={handleSkipConfirm}
|
|
1489
|
-
className=
|
|
1512
|
+
className={`${styles.skipModalUnderstandBtn} bg-emerald-600 text-white font-medium rounded-lg hover:bg-emerald-700 transition-colors`}
|
|
1490
1513
|
>
|
|
1491
1514
|
{t('pickup.iUnderstand')}
|
|
1492
1515
|
</button>
|
|
@@ -1515,9 +1538,11 @@ export function PickupLocationSelector(props: PickupLocationSelectorProps) {
|
|
|
1515
1538
|
if (!hasMapsKey) {
|
|
1516
1539
|
return (
|
|
1517
1540
|
<div className="space-y-6">
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1541
|
+
{!props.hideTitle && (
|
|
1542
|
+
<h2 className="text-2xl font-bold text-stone-900">
|
|
1543
|
+
{props.isSkipped ? null : t('pickup.title')}
|
|
1544
|
+
</h2>
|
|
1545
|
+
)}
|
|
1521
1546
|
<div className="p-4 bg-stone-50 border border-stone-200 rounded-lg text-stone-600 text-sm">
|
|
1522
1547
|
Map unavailable (no API key). You can still complete your booking. Add{' '}
|
|
1523
1548
|
<code className="bg-stone-200 px-1 rounded">NEXT_PUBLIC_GOOGLE_MAPS_API_KEY</code> to enable the map.
|
|
@@ -1528,7 +1553,7 @@ export function PickupLocationSelector(props: PickupLocationSelectorProps) {
|
|
|
1528
1553
|
</p>
|
|
1529
1554
|
)}
|
|
1530
1555
|
</div>
|
|
1531
|
-
{!props.isSkipped && (
|
|
1556
|
+
{!props.isSkipped && !props.hideTitle && (
|
|
1532
1557
|
<p className="text-sm text-stone-500">
|
|
1533
1558
|
Select a pickup location from the list, or skip this step.
|
|
1534
1559
|
</p>
|