@ticketboothapp/booking 0.1.11 → 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 +28 -36
- 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
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState, useMemo, useRef, useEffect, useCallback, memo } from 'react';
|
|
4
|
+
import { createPortal } from 'react-dom';
|
|
4
5
|
import { startOfWeek, addDays, addWeeks, subWeeks, isSameDay, parseISO, startOfMonth, endOfMonth, eachDayOfInterval, addMonths, subMonths } from 'date-fns';
|
|
5
|
-
import { formatInTimeZone } from 'date-fns-tz';
|
|
6
|
+
import { formatInTimeZone, fromZonedTime } from 'date-fns-tz';
|
|
6
7
|
import { enUS, fr } from 'date-fns/locale';
|
|
7
|
-
import type { Availability } from '@/lib/api';
|
|
8
|
-
import { useTranslations, useLocale } from '@/lib/i18n';
|
|
8
|
+
import type { Availability } from '@/lib/booking-api';
|
|
9
|
+
import { useTranslations, useLocale } from '@/lib/booking/i18n';
|
|
9
10
|
import {
|
|
10
11
|
MINI_CALENDAR_START_MONTH,
|
|
11
12
|
MINI_CALENDAR_END_MONTH,
|
|
12
|
-
MINI_CALENDAR_START_DATE,
|
|
13
|
-
MINI_CALENDAR_END_DATE,
|
|
14
13
|
VISIBLE_RANGE_BUFFER_WEEKS,
|
|
15
|
-
} from '@/lib/constants';
|
|
16
|
-
import {
|
|
14
|
+
} from '@/lib/booking-constants';
|
|
15
|
+
import { getSundayOfWeek } from '@/lib/booking/sunday-week';
|
|
16
|
+
import { cn } from '@/lib/booking/utils';
|
|
17
|
+
import styles from './Calendar.module.css';
|
|
17
18
|
|
|
18
19
|
// ============ Types ============
|
|
19
20
|
|
|
@@ -28,8 +29,8 @@ export interface DateAvailability {
|
|
|
28
29
|
startTimes?: string[]; // Array of start times for this date (e.g., ["09:00", "10:00", "14:00"])
|
|
29
30
|
soldOutTimes?: Set<string>; // Set of sold out time strings (e.g., ["09:00", "14:00"])
|
|
30
31
|
totalDiscountPercent?: number; // Total discount percentage from all negative adjustments (deals + dynamic pricing)
|
|
31
|
-
/** Per-time
|
|
32
|
-
timeCapacityMap?: Record<string, { booked: number; total: number }>;
|
|
32
|
+
/** Per-time capacity (admin): vacancies by default; booked/total only when the slot is sold out */
|
|
33
|
+
timeCapacityMap?: Record<string, { booked: number; total: number; vacancies: number }>;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
interface CalendarProps {
|
|
@@ -40,14 +41,29 @@ interface CalendarProps {
|
|
|
40
41
|
earliestDate: Date | null;
|
|
41
42
|
onVisibleRangeChange?: (startDate: Date, endDate: Date) => void;
|
|
42
43
|
currency: string; // Currency code (e.g., "CAD", "USD") for discount calculations
|
|
43
|
-
/** When true (admin), show
|
|
44
|
+
/** When true (admin), show capacity on each time slot (vacancies unless sold out) */
|
|
44
45
|
showCapacity?: boolean;
|
|
46
|
+
/** When true, show loading spinner in the date dropdown (e.g. when fetching availability for a new month) */
|
|
47
|
+
isLoading?: boolean;
|
|
48
|
+
/** Controls date-cell content style: detailed times vs simple availability status */
|
|
49
|
+
displayMode?: 'times' | 'status';
|
|
50
|
+
/** Optional extra discount percent added to the day-level discount badge. */
|
|
51
|
+
extraDiscountPercent?: number;
|
|
52
|
+
/** When true, cap day discount badges to the currently selected date's discount percent. */
|
|
53
|
+
capDiscountToSelectedDate?: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* When true (e.g. change-booking flow), scroll the visible grid once to the week containing
|
|
56
|
+
* `selectedDate` after the parent sets it from the current booking. Otherwise the grid stays
|
|
57
|
+
* anchored to the earliest available date.
|
|
58
|
+
*/
|
|
59
|
+
syncVisibleWeekToSelectedDate?: boolean;
|
|
45
60
|
}
|
|
46
61
|
|
|
47
62
|
// ============ Constants ============
|
|
48
63
|
|
|
49
64
|
const DAYS_OF_WEEK_KEYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] as const;
|
|
50
|
-
const
|
|
65
|
+
const WEEKS_TO_SHOW_DESKTOP = 2;
|
|
66
|
+
const WEEKS_TO_SHOW_MOBILE = 2;
|
|
51
67
|
|
|
52
68
|
// Date-fns locale map
|
|
53
69
|
const dateFnsLocales = {
|
|
@@ -111,6 +127,20 @@ function calculateTotalDiscountPercent(
|
|
|
111
127
|
return net > 0 ? Math.round(net) : undefined;
|
|
112
128
|
}
|
|
113
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Create a Date representing noon on the given date string in the target timezone.
|
|
132
|
+
* Using noon avoids day-boundary issues when formatting across timezones.
|
|
133
|
+
*/
|
|
134
|
+
function dateStrToNoonInTz(dateStr: string, timezone: string): Date {
|
|
135
|
+
return fromZonedTime(parseISO(`${dateStr}T12:00:00`), timezone);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function parseAvailabilityDateTime(value: string): Date {
|
|
139
|
+
// If API omits timezone offset, treat it as UTC to prevent user-local shifts.
|
|
140
|
+
const hasExplicitOffset = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value);
|
|
141
|
+
return parseISO(hasExplicitOffset ? value : `${value}Z`);
|
|
142
|
+
}
|
|
143
|
+
|
|
114
144
|
// ============ Date Cell Component ============
|
|
115
145
|
|
|
116
146
|
interface DateCellProps {
|
|
@@ -121,7 +151,9 @@ interface DateCellProps {
|
|
|
121
151
|
isToday: boolean;
|
|
122
152
|
showCapacity?: boolean;
|
|
123
153
|
timezone: string;
|
|
154
|
+
displayMode: 'times' | 'status';
|
|
124
155
|
onClick: () => void;
|
|
156
|
+
isMobile?: boolean;
|
|
125
157
|
}
|
|
126
158
|
|
|
127
159
|
const DateCell = memo(function DateCell({
|
|
@@ -131,7 +163,9 @@ const DateCell = memo(function DateCell({
|
|
|
131
163
|
isToday,
|
|
132
164
|
showCapacity,
|
|
133
165
|
timezone,
|
|
166
|
+
displayMode,
|
|
134
167
|
onClick,
|
|
168
|
+
isMobile = false,
|
|
135
169
|
}: DateCellProps) {
|
|
136
170
|
const { t } = useTranslations();
|
|
137
171
|
const { locale } = useLocale();
|
|
@@ -141,40 +175,48 @@ const DateCell = memo(function DateCell({
|
|
|
141
175
|
const hasAvailability = availability !== null && (!availability.isSoldOut || showCapacity);
|
|
142
176
|
const isDisabled = !hasAvailability;
|
|
143
177
|
|
|
144
|
-
// Format time for display (e.g
|
|
145
|
-
//
|
|
178
|
+
// Format time for display. timeStr (e.g. "09:00") is already in company timezone (MDT).
|
|
179
|
+
// Create a Date with those components, interpret as company timezone, then format.
|
|
146
180
|
const formatTime = useCallback((timeStr: string) => {
|
|
147
181
|
try {
|
|
148
182
|
const [hours, minutes] = timeStr.split(':').map(Number);
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
183
|
+
const dateWithTime = new Date(2000, 0, 1, hours, minutes, 0);
|
|
184
|
+
const dateInTz = fromZonedTime(dateWithTime, timezone);
|
|
185
|
+
if (locale === 'fr') {
|
|
186
|
+
return formatInTimeZone(dateInTz, timezone, minutes === 0 ? 'HH' : 'HH:mm', { locale: dateFnsLocale });
|
|
187
|
+
}
|
|
188
|
+
return formatInTimeZone(dateInTz, timezone, minutes === 0 ? 'ha' : 'h:mma', { locale: dateFnsLocale });
|
|
154
189
|
} catch {
|
|
155
190
|
return timeStr;
|
|
156
191
|
}
|
|
157
192
|
}, [locale, timezone, dateFnsLocale]);
|
|
158
193
|
|
|
159
|
-
// Two heights: tall when >2 time slots (discount + times); otherwise short.
|
|
160
|
-
|
|
194
|
+
// Two heights: tall when >2 time slots (discount + times); otherwise short. Mobile uses compact dot+time cells.
|
|
195
|
+
// Admin capacity line under each time pill needs extra vertical room (provider dashboard only).
|
|
196
|
+
const slotCount = availability?.startTimes?.length ?? 0;
|
|
197
|
+
const needsTallerCell = !isMobile && slotCount > 2;
|
|
198
|
+
const mobileNeedsTaller = isMobile && slotCount > 2;
|
|
199
|
+
const adminCapacityTallCells =
|
|
200
|
+
Boolean(showCapacity) && !isMobile && slotCount > 0 && displayMode === 'times';
|
|
161
201
|
const buttonClassName = useMemo(() => cn(
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
202
|
+
styles.calendarDayCell,
|
|
203
|
+
isMobile && (mobileNeedsTaller ? styles.calendarDayCellMobileTall : styles.calendarDayCellMobile),
|
|
204
|
+
!isMobile && (
|
|
205
|
+
adminCapacityTallCells
|
|
206
|
+
? needsTallerCell
|
|
207
|
+
? cn('min-w-0', styles.calendarDayCellWithAdminCapacity, styles.calendarDayCellWithAdminCapacityTall)
|
|
208
|
+
: styles.calendarDayCellWithAdminCapacity
|
|
209
|
+
: needsTallerCell
|
|
210
|
+
? 'min-w-0 min-h-[7rem]'
|
|
211
|
+
: 'min-h-[6rem]'
|
|
212
|
+
),
|
|
166
213
|
isDisabled
|
|
167
|
-
?
|
|
214
|
+
? styles.calendarDayCellDisabled
|
|
168
215
|
: isSelected
|
|
169
|
-
?
|
|
170
|
-
:
|
|
171
|
-
isToday && hasAvailability && !isSelected &&
|
|
172
|
-
), [isDisabled, isSelected, isToday, hasAvailability, needsTallerCell]);
|
|
173
|
-
|
|
174
|
-
const dayNumberClassName = useMemo(() => cn(
|
|
175
|
-
'text-[10px] font-medium absolute top-1 left-1',
|
|
176
|
-
isSelected && hasAvailability ? 'text-white' : 'text-stone-900'
|
|
177
|
-
), [isSelected, hasAvailability]);
|
|
216
|
+
? styles.calendarDayCellSelected
|
|
217
|
+
: styles.calendarDayCellAvailable,
|
|
218
|
+
isToday && hasAvailability && !isSelected && styles.calendarDayCellToday
|
|
219
|
+
), [isDisabled, isSelected, isToday, hasAvailability, needsTallerCell, isMobile, mobileNeedsTaller, adminCapacityTallCells]);
|
|
178
220
|
|
|
179
221
|
const ariaLabel = useMemo(() => {
|
|
180
222
|
const parts = [dayNumber];
|
|
@@ -190,15 +232,15 @@ const DateCell = memo(function DateCell({
|
|
|
190
232
|
className={buttonClassName}
|
|
191
233
|
aria-label={ariaLabel}
|
|
192
234
|
>
|
|
193
|
-
<div className=
|
|
194
|
-
{/* Day Number */}
|
|
195
|
-
<div className={
|
|
235
|
+
<div className={cn(styles.calendarDayCellInner, isMobile && styles.calendarDayCellInnerMobile)}>
|
|
236
|
+
{/* Day Number - on mobile, in a top row with discount below to avoid overlap */}
|
|
237
|
+
<div className={styles.calendarDayNumber}>
|
|
196
238
|
{dayNumber}
|
|
197
239
|
</div>
|
|
198
240
|
|
|
199
|
-
{/* Discount Tag
|
|
241
|
+
{/* Discount Tag - top-right on desktop; below day number on mobile to avoid overlap */}
|
|
200
242
|
{availability?.totalDiscountPercent && availability.totalDiscountPercent > 0 && (
|
|
201
|
-
<div className=
|
|
243
|
+
<div className={cn(styles.calendarDiscountTag, isMobile && styles.calendarDiscountTagMobile)}>
|
|
202
244
|
<svg className="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 20 20">
|
|
203
245
|
<path fillRule="evenodd" d="M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7A.997.997 0 012 10V5a3 3 0 013-3h5c.256 0 .512.098.707.293l7 7zM5 6a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
|
|
204
246
|
</svg>
|
|
@@ -211,83 +253,100 @@ const DateCell = memo(function DateCell({
|
|
|
211
253
|
<div className="flex flex-col items-center justify-center space-y-0 w-full px-0.5">
|
|
212
254
|
{/* Sold Out Badge - Centered */}
|
|
213
255
|
{availability.isSoldOut ? (
|
|
214
|
-
<div className={
|
|
215
|
-
'text-[10px] font-semibold px-2 py-1 rounded flex items-center justify-center',
|
|
216
|
-
isSelected
|
|
217
|
-
? 'bg-red-500/30 text-white border border-red-400/50'
|
|
218
|
-
: 'bg-red-100 text-red-700 border border-red-300'
|
|
219
|
-
)}>
|
|
256
|
+
<div className={styles.calendarSoldOutBadge}>
|
|
220
257
|
{t('calendar.soldOut')}
|
|
221
258
|
</div>
|
|
222
259
|
) : hasAvailability ? (
|
|
223
260
|
<>
|
|
224
|
-
{/*
|
|
225
|
-
{
|
|
226
|
-
<div className=
|
|
227
|
-
{
|
|
228
|
-
const isTimeSoldOut = availability.soldOutTimes?.has(time) || false;
|
|
229
|
-
const isLowAvailability = !isTimeSoldOut && availability.totalVacancies !== undefined && availability.totalVacancies < 5;
|
|
230
|
-
// Check if this is daily availability (midnight/00:00 time)
|
|
231
|
-
const isDailyAvailability = time === '00:00';
|
|
232
|
-
return (
|
|
233
|
-
<div
|
|
234
|
-
key={time}
|
|
235
|
-
className={cn(
|
|
236
|
-
'text-[10px] px-1.5 py-1 rounded font-medium flex flex-col items-center gap-0',
|
|
237
|
-
isTimeSoldOut
|
|
238
|
-
? isSelected
|
|
239
|
-
? 'bg-red-500/30 text-white opacity-60'
|
|
240
|
-
: 'bg-red-400 text-white opacity-60'
|
|
241
|
-
: isLowAvailability
|
|
242
|
-
? isSelected
|
|
243
|
-
? 'bg-amber-600 text-white'
|
|
244
|
-
: 'bg-amber-500 text-white'
|
|
245
|
-
: isSelected
|
|
246
|
-
? 'bg-white/30 text-white'
|
|
247
|
-
: 'bg-emerald-500 text-white'
|
|
248
|
-
)}
|
|
249
|
-
>
|
|
250
|
-
<div>{isDailyAvailability ? 'Available' : formatTime(time)}</div>
|
|
251
|
-
{showCapacity && availability.timeCapacityMap?.[time] && (
|
|
252
|
-
<div className="text-[9px] -mt-0.5 tabular-nums opacity-90">
|
|
253
|
-
{availability.timeCapacityMap[time].booked}/{availability.timeCapacityMap[time].total}
|
|
254
|
-
</div>
|
|
255
|
-
)}
|
|
256
|
-
{isTimeSoldOut ? (
|
|
257
|
-
<div className="text-[9px] -mt-0.5">
|
|
258
|
-
{t('calendar.soldOut')}
|
|
259
|
-
</div>
|
|
260
|
-
) : isLowAvailability && !showCapacity && (
|
|
261
|
-
<div className="flex items-center gap-0.5 text-[9px] -mt-0.5">
|
|
262
|
-
<svg className="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 20 20">
|
|
263
|
-
<path fillRule="evenodd" 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" clipRule="evenodd" />
|
|
264
|
-
</svg>
|
|
265
|
-
<span>{t('calendar.left', { count: availability.totalVacancies ?? 0 })}</span>
|
|
266
|
-
</div>
|
|
267
|
-
)}
|
|
268
|
-
</div>
|
|
269
|
-
);
|
|
270
|
-
})}
|
|
271
|
-
{availability.startTimes.length > 3 && (
|
|
272
|
-
<div className={cn(
|
|
273
|
-
'text-[10px] px-1.5 py-1 rounded font-medium',
|
|
274
|
-
isSelected
|
|
275
|
-
? 'bg-white/30 text-white'
|
|
276
|
-
: 'bg-emerald-500 text-white'
|
|
277
|
-
)}>
|
|
278
|
-
+{availability.startTimes.length - 3}
|
|
279
|
-
</div>
|
|
280
|
-
)}
|
|
261
|
+
{/* Mobile: green dot + time (compact). Desktop: full time pills */}
|
|
262
|
+
{displayMode === 'status' ? (
|
|
263
|
+
<div className={styles.calendarTimePill}>
|
|
264
|
+
{t('calendar.available')}
|
|
281
265
|
</div>
|
|
266
|
+
) : availability.startTimes && availability.startTimes.length > 0 ? (
|
|
267
|
+
isMobile ? (
|
|
268
|
+
<div className={cn(
|
|
269
|
+
styles.calendarMobileTimeList,
|
|
270
|
+
availability.totalDiscountPercent && availability.totalDiscountPercent > 0 && styles.calendarMobileTimeListWithDiscount
|
|
271
|
+
)}>
|
|
272
|
+
{availability.startTimes.slice(0, 3).map((time) => {
|
|
273
|
+
const isTimeSoldOut = availability.soldOutTimes?.has(time) || false;
|
|
274
|
+
const isLowAvailability = !isTimeSoldOut && availability.totalVacancies !== undefined && availability.totalVacancies < 5;
|
|
275
|
+
const isDailyAvailability = time === '00:00';
|
|
276
|
+
return (
|
|
277
|
+
<div
|
|
278
|
+
key={time}
|
|
279
|
+
className={cn(
|
|
280
|
+
styles.calendarMobileTimeDot,
|
|
281
|
+
isTimeSoldOut && styles.calendarMobileTimeDotSoldOut,
|
|
282
|
+
isLowAvailability && !isTimeSoldOut && styles.calendarMobileTimeDotLow
|
|
283
|
+
)}
|
|
284
|
+
>
|
|
285
|
+
<span className={styles.calendarMobileTimeDotBullet} aria-hidden />
|
|
286
|
+
<span>{isDailyAvailability ? t('calendar.available') : formatTime(time)}</span>
|
|
287
|
+
</div>
|
|
288
|
+
);
|
|
289
|
+
})}
|
|
290
|
+
{availability.startTimes.length > 3 && (
|
|
291
|
+
<div className={styles.calendarMobileTimeDot}>
|
|
292
|
+
<span className={styles.calendarMobileTimeDotBullet} aria-hidden />
|
|
293
|
+
<span>+{availability.startTimes.length - 3}</span>
|
|
294
|
+
</div>
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
297
|
+
) : (
|
|
298
|
+
<div className="flex flex-wrap gap-0.5 justify-center">
|
|
299
|
+
{availability.startTimes.slice(0, 3).map((time) => {
|
|
300
|
+
const isTimeSoldOut = availability.soldOutTimes?.has(time) || false;
|
|
301
|
+
const isLowAvailability = !isTimeSoldOut && availability.totalVacancies !== undefined && availability.totalVacancies < 5;
|
|
302
|
+
const isDailyAvailability = time === '00:00';
|
|
303
|
+
return (
|
|
304
|
+
<div
|
|
305
|
+
key={time}
|
|
306
|
+
className={cn(
|
|
307
|
+
styles.calendarTimePill,
|
|
308
|
+
isTimeSoldOut && styles.calendarTimePillSoldOut,
|
|
309
|
+
isLowAvailability && !isTimeSoldOut && styles.calendarTimePillLow
|
|
310
|
+
)}
|
|
311
|
+
>
|
|
312
|
+
<div>{isDailyAvailability ? t('calendar.available') : formatTime(time)}</div>
|
|
313
|
+
{showCapacity && availability.timeCapacityMap?.[time] && (() => {
|
|
314
|
+
const cap = availability.timeCapacityMap[time];
|
|
315
|
+
const vac = cap.vacancies;
|
|
316
|
+
return (
|
|
317
|
+
<div className="text-[9px] -mt-0.5 tabular-nums opacity-90">
|
|
318
|
+
{isTimeSoldOut
|
|
319
|
+
? `${cap.booked}/${cap.total}`
|
|
320
|
+
: t('calendar.spotsAvailable', { count: vac })}
|
|
321
|
+
</div>
|
|
322
|
+
);
|
|
323
|
+
})()}
|
|
324
|
+
{isTimeSoldOut ? (
|
|
325
|
+
<div className="text-[9px] -mt-0.5">
|
|
326
|
+
{t('calendar.soldOut')}
|
|
327
|
+
</div>
|
|
328
|
+
) : isLowAvailability && !showCapacity && (
|
|
329
|
+
<div className="flex items-center gap-0.5 text-[9px] -mt-0.5">
|
|
330
|
+
<svg className="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 20 20">
|
|
331
|
+
<path fillRule="evenodd" 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" clipRule="evenodd" />
|
|
332
|
+
</svg>
|
|
333
|
+
<span>{t('calendar.left', { count: availability.totalVacancies ?? 0 })}</span>
|
|
334
|
+
</div>
|
|
335
|
+
)}
|
|
336
|
+
</div>
|
|
337
|
+
);
|
|
338
|
+
})}
|
|
339
|
+
{availability.startTimes.length > 3 && (
|
|
340
|
+
<div className={styles.calendarTimePill}>
|
|
341
|
+
+{availability.startTimes.length - 3}
|
|
342
|
+
</div>
|
|
343
|
+
)}
|
|
344
|
+
</div>
|
|
345
|
+
)
|
|
282
346
|
) : (
|
|
283
347
|
/* Fallback: Low Availability Badge (when no start times) */
|
|
284
348
|
availability.totalVacancies !== undefined && availability.totalVacancies < 5 && (
|
|
285
|
-
<div className={
|
|
286
|
-
'text-[10px] font-semibold px-2 py-1 rounded flex items-center gap-0.5 justify-center',
|
|
287
|
-
isSelected
|
|
288
|
-
? 'bg-red-500/30 text-white border border-red-400/50'
|
|
289
|
-
: 'bg-red-100 text-red-700 border border-red-300'
|
|
290
|
-
)}>
|
|
349
|
+
<div className={styles.calendarLowAvailabilityBadge}>
|
|
291
350
|
<svg className="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 20 20">
|
|
292
351
|
<path fillRule="evenodd" 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" clipRule="evenodd" />
|
|
293
352
|
</svg>
|
|
@@ -296,14 +355,9 @@ const DateCell = memo(function DateCell({
|
|
|
296
355
|
)
|
|
297
356
|
)}
|
|
298
357
|
|
|
299
|
-
{/* Discount Badge */}
|
|
300
|
-
{availability.hasDiscount && availability.discountPercent && (availability.totalVacancies === undefined || availability.totalVacancies >= 5) && (
|
|
301
|
-
<div className={
|
|
302
|
-
'text-[9px] font-semibold px-1 py-0.5 rounded text-center',
|
|
303
|
-
isSelected
|
|
304
|
-
? 'bg-white/20 text-white'
|
|
305
|
-
: 'bg-red-100 text-red-700'
|
|
306
|
-
)}>
|
|
358
|
+
{/* Discount Badge - desktop only (mobile uses corner tag) */}
|
|
359
|
+
{!isMobile && availability.hasDiscount && availability.discountPercent && (availability.totalVacancies === undefined || availability.totalVacancies >= 5) && (
|
|
360
|
+
<div className={styles.calendarDiscountBadge}>
|
|
307
361
|
-{availability.discountPercent}%
|
|
308
362
|
</div>
|
|
309
363
|
)}
|
|
@@ -327,48 +381,64 @@ export function Calendar({
|
|
|
327
381
|
onVisibleRangeChange,
|
|
328
382
|
currency,
|
|
329
383
|
showCapacity = false,
|
|
384
|
+
isLoading = false,
|
|
385
|
+
displayMode = 'times',
|
|
386
|
+
extraDiscountPercent = 0,
|
|
387
|
+
capDiscountToSelectedDate = false,
|
|
388
|
+
syncVisibleWeekToSelectedDate = false,
|
|
330
389
|
}: CalendarProps) {
|
|
331
390
|
const { t } = useTranslations();
|
|
332
391
|
const { locale } = useLocale();
|
|
333
392
|
const dateFnsLocale = dateFnsLocales[locale] || enUS;
|
|
334
393
|
|
|
335
|
-
//
|
|
336
|
-
//
|
|
394
|
+
// Store the Sunday (week start) as a date string in company timezone.
|
|
395
|
+
// This ensures the calendar grid is always correct regardless of user's timezone.
|
|
337
396
|
const hasInitializedRef = useRef(false);
|
|
338
|
-
const [
|
|
397
|
+
const [currentStartDateStr, setCurrentStartDateStr] = useState(() => {
|
|
339
398
|
hasInitializedRef.current = true;
|
|
340
399
|
if (earliestDate) {
|
|
341
|
-
|
|
400
|
+
const dateStr = formatInTimeZone(earliestDate, timezone, 'yyyy-MM-dd');
|
|
401
|
+
return getSundayOfWeek(dateStr, timezone);
|
|
342
402
|
}
|
|
343
|
-
|
|
403
|
+
const nowStr = formatInTimeZone(new Date(), timezone, 'yyyy-MM-dd');
|
|
404
|
+
return getSundayOfWeek(nowStr, timezone);
|
|
344
405
|
});
|
|
345
|
-
|
|
346
|
-
// Only update
|
|
347
|
-
// This prevents resetting the calendar position when new availabilities are fetched
|
|
406
|
+
|
|
407
|
+
// Only update if earliestDate changes AND we haven't initialized yet
|
|
348
408
|
useEffect(() => {
|
|
349
409
|
if (!hasInitializedRef.current && earliestDate) {
|
|
350
|
-
const
|
|
351
|
-
|
|
410
|
+
const dateStr = formatInTimeZone(earliestDate, timezone, 'yyyy-MM-dd');
|
|
411
|
+
setCurrentStartDateStr(getSundayOfWeek(dateStr, timezone));
|
|
352
412
|
hasInitializedRef.current = true;
|
|
353
413
|
}
|
|
354
|
-
}, [earliestDate]);
|
|
414
|
+
}, [earliestDate, timezone]);
|
|
355
415
|
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false);
|
|
416
|
+
const [isMobile, setIsMobile] = useState(false);
|
|
417
|
+
|
|
418
|
+
useEffect(() => {
|
|
419
|
+
const checkMobile = () => setIsMobile(window.innerWidth < 640);
|
|
420
|
+
checkMobile();
|
|
421
|
+
window.addEventListener('resize', checkMobile);
|
|
422
|
+
return () => window.removeEventListener('resize', checkMobile);
|
|
423
|
+
}, []);
|
|
424
|
+
|
|
425
|
+
const WEEKS_TO_SHOW = isMobile ? WEEKS_TO_SHOW_MOBILE : WEEKS_TO_SHOW_DESKTOP;
|
|
356
426
|
|
|
357
|
-
// Initialize picker month to match the currently visible month in the main calendar
|
|
427
|
+
// Initialize picker month to match the currently visible month in the main calendar (in company timezone)
|
|
358
428
|
const [pickerMonth, setPickerMonth] = useState(() => {
|
|
359
|
-
|
|
360
|
-
return
|
|
429
|
+
const [y, m] = currentStartDateStr.split('-').map(Number);
|
|
430
|
+
return dateStrToNoonInTz(`${y}-${String(m).padStart(2, '0')}-01`, timezone);
|
|
361
431
|
});
|
|
362
|
-
|
|
432
|
+
|
|
363
433
|
// Update picker month when main calendar navigates to keep them in sync
|
|
364
434
|
useEffect(() => {
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
if (currentMonth.getTime() >= MINI_CALENDAR_START_MONTH.getTime() &&
|
|
435
|
+
const [y, m] = currentStartDateStr.split('-').map(Number);
|
|
436
|
+
const currentMonth = dateStrToNoonInTz(`${y}-${String(m).padStart(2, '0')}-01`, timezone);
|
|
437
|
+
if (currentMonth.getTime() >= MINI_CALENDAR_START_MONTH.getTime() &&
|
|
368
438
|
currentMonth.getTime() <= MINI_CALENDAR_END_MONTH.getTime()) {
|
|
369
439
|
setPickerMonth(currentMonth);
|
|
370
440
|
}
|
|
371
|
-
}, [
|
|
441
|
+
}, [currentStartDateStr, timezone]);
|
|
372
442
|
|
|
373
443
|
// Ensure pickerMonth stays within bounds
|
|
374
444
|
useEffect(() => {
|
|
@@ -383,7 +453,29 @@ export function Calendar({
|
|
|
383
453
|
const datePickerTriggerRef = useRef<HTMLButtonElement>(null); // Ref for the trigger button
|
|
384
454
|
const [pickerPosition, setPickerPosition] = useState<{ top: number; left: number } | null>(null);
|
|
385
455
|
|
|
386
|
-
//
|
|
456
|
+
// Capture the month when dropdown opens (already fetched from main calendar - don't show spinner for it)
|
|
457
|
+
const [initialMonthOnOpen, setInitialMonthOnOpen] = useState<Date | null>(null);
|
|
458
|
+
useEffect(() => {
|
|
459
|
+
if (isDatePickerOpen) {
|
|
460
|
+
setInitialMonthOnOpen((prev) => (prev === null ? pickerMonth : prev));
|
|
461
|
+
} else {
|
|
462
|
+
setInitialMonthOnOpen(null);
|
|
463
|
+
}
|
|
464
|
+
}, [isDatePickerOpen, pickerMonth]);
|
|
465
|
+
|
|
466
|
+
// Only show spinner when loading a month they navigated to (not the initial month which is already fetched)
|
|
467
|
+
const [showSpinnerInDropdown, setShowSpinnerInDropdown] = useState(false);
|
|
468
|
+
const isInitialMonth = initialMonthOnOpen === null || startOfMonth(pickerMonth).getTime() === startOfMonth(initialMonthOnOpen).getTime();
|
|
469
|
+
useEffect(() => {
|
|
470
|
+
if (!isLoading || !isDatePickerOpen || isInitialMonth) {
|
|
471
|
+
setShowSpinnerInDropdown(false);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
const timer = setTimeout(() => setShowSpinnerInDropdown(true), 400);
|
|
475
|
+
return () => clearTimeout(timer);
|
|
476
|
+
}, [isLoading, isDatePickerOpen, pickerMonth, isInitialMonth]);
|
|
477
|
+
|
|
478
|
+
// Get all available dates for dropdown - use company timezone for consistent navigation worldwide
|
|
387
479
|
const availableDates = useMemo(() => {
|
|
388
480
|
return Object.keys(availabilitiesByDate)
|
|
389
481
|
.filter(dateStr => {
|
|
@@ -391,16 +483,8 @@ export function Calendar({
|
|
|
391
483
|
return availabilities && availabilities.length > 0 && !availabilities.every(avail => avail.vacancies === 0);
|
|
392
484
|
})
|
|
393
485
|
.sort()
|
|
394
|
-
.map(dateStr =>
|
|
395
|
-
|
|
396
|
-
const [year, month, day] = dateStr.split('-').map(Number);
|
|
397
|
-
return new Date(year, month - 1, day);
|
|
398
|
-
} catch {
|
|
399
|
-
return null;
|
|
400
|
-
}
|
|
401
|
-
})
|
|
402
|
-
.filter((date): date is Date => date !== null);
|
|
403
|
-
}, [availabilitiesByDate]);
|
|
486
|
+
.map(dateStr => dateStrToNoonInTz(dateStr, timezone));
|
|
487
|
+
}, [availabilitiesByDate, timezone]);
|
|
404
488
|
|
|
405
489
|
// Check if we can navigate months in mini calendar (hardcoded to June-October 2026)
|
|
406
490
|
const canNavigatePickerMonthBack = useMemo(() => {
|
|
@@ -415,31 +499,74 @@ export function Calendar({
|
|
|
415
499
|
return currentMonthTime < endMonthTime;
|
|
416
500
|
}, [pickerMonth]);
|
|
417
501
|
|
|
418
|
-
//
|
|
502
|
+
// Mini calendar allowed range in company timezone (June 1 - Oct 12 inclusive)
|
|
503
|
+
// Must use company TZ - constants use local TZ which breaks for users in other timezones
|
|
504
|
+
const miniCalendarBoundsInTz = useMemo(() => ({
|
|
505
|
+
start: fromZonedTime(new Date(2026, 5, 1, 0, 0, 0, 0), timezone),
|
|
506
|
+
end: fromZonedTime(new Date(2026, 9, 12, 23, 59, 59, 999), timezone),
|
|
507
|
+
}), [timezone]);
|
|
508
|
+
|
|
509
|
+
// Generate mini calendar days in company timezone (same fix as main grid - avoids wrong day-of-week for users in other TZs)
|
|
419
510
|
const miniCalendarData = useMemo(() => {
|
|
420
|
-
const
|
|
421
|
-
const
|
|
422
|
-
const
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
const days
|
|
511
|
+
const yearMonth = formatInTimeZone(pickerMonth, timezone, 'yyyy-MM');
|
|
512
|
+
const [y, m] = yearMonth.split('-').map(Number);
|
|
513
|
+
const firstOfMonthStr = `${y}-${String(m).padStart(2, '0')}-01`;
|
|
514
|
+
const sundayStr = getSundayOfWeek(firstOfMonthStr, timezone);
|
|
515
|
+
const sundayDate = dateStrToNoonInTz(sundayStr, timezone);
|
|
516
|
+
const days: Date[] = [];
|
|
517
|
+
for (let i = 0; i < 42; i++) {
|
|
518
|
+
days.push(addDays(sundayDate, i));
|
|
519
|
+
}
|
|
520
|
+
const monthStart = dateStrToNoonInTz(firstOfMonthStr, timezone);
|
|
521
|
+
const lastDay = new Date(y, m, 0).getDate();
|
|
522
|
+
const monthEnd = dateStrToNoonInTz(`${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`, timezone);
|
|
426
523
|
return { days, monthStart, monthEnd };
|
|
427
|
-
}, [pickerMonth]);
|
|
524
|
+
}, [pickerMonth, timezone]);
|
|
428
525
|
|
|
429
526
|
// Calculate position for date picker and close when clicking outside
|
|
527
|
+
const PICKER_MIN_WIDTH = 280;
|
|
528
|
+
const PICKER_MAX_HEIGHT = 400;
|
|
529
|
+
const MOBILE_BREAKPOINT = 640;
|
|
530
|
+
const VIEWPORT_PADDING = 16;
|
|
531
|
+
const VIEWPORT_PADDING_MOBILE = 24;
|
|
532
|
+
|
|
430
533
|
useEffect(() => {
|
|
431
534
|
if (!isDatePickerOpen) {
|
|
432
535
|
setPickerPosition(null);
|
|
433
536
|
return;
|
|
434
537
|
}
|
|
435
538
|
|
|
436
|
-
// Calculate position from trigger button
|
|
539
|
+
// Calculate position from trigger button (fixed = viewport coords, no scroll offset)
|
|
437
540
|
if (datePickerTriggerRef.current) {
|
|
438
541
|
const rect = datePickerTriggerRef.current.getBoundingClientRect();
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
542
|
+
const padding = window.innerWidth < MOBILE_BREAKPOINT ? VIEWPORT_PADDING_MOBILE : VIEWPORT_PADDING;
|
|
543
|
+
let left: number;
|
|
544
|
+
let top = rect.bottom + 4;
|
|
545
|
+
|
|
546
|
+
// On mobile, center the dropdown for better visibility and to avoid going off-screen
|
|
547
|
+
if (window.innerWidth < MOBILE_BREAKPOINT) {
|
|
548
|
+
left = (window.innerWidth - PICKER_MIN_WIDTH) / 2;
|
|
549
|
+
left = Math.max(padding, Math.min(left, window.innerWidth - PICKER_MIN_WIDTH - padding));
|
|
550
|
+
} else {
|
|
551
|
+
left = rect.left;
|
|
552
|
+
// Keep dropdown within viewport horizontally on desktop
|
|
553
|
+
if (left + PICKER_MIN_WIDTH > window.innerWidth - padding) {
|
|
554
|
+
left = window.innerWidth - PICKER_MIN_WIDTH - padding;
|
|
555
|
+
}
|
|
556
|
+
if (left < padding) {
|
|
557
|
+
left = padding;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Keep dropdown within viewport vertically
|
|
562
|
+
if (top + PICKER_MAX_HEIGHT > window.innerHeight - padding) {
|
|
563
|
+
top = Math.max(padding, window.innerHeight - PICKER_MAX_HEIGHT - padding);
|
|
564
|
+
}
|
|
565
|
+
if (top < padding) {
|
|
566
|
+
top = padding;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
setPickerPosition({ top, left });
|
|
443
570
|
}
|
|
444
571
|
|
|
445
572
|
const handleClickOutside = (event: MouseEvent) => {
|
|
@@ -464,6 +591,12 @@ export function Calendar({
|
|
|
464
591
|
document.removeEventListener('click', handleClickOutside, true);
|
|
465
592
|
};
|
|
466
593
|
}, [isDatePickerOpen]);
|
|
594
|
+
|
|
595
|
+
// Derive Date from stored string (for comparisons and addDays) - uses company timezone
|
|
596
|
+
const currentStartDate = useMemo(
|
|
597
|
+
() => dateStrToNoonInTz(currentStartDateStr, timezone),
|
|
598
|
+
[currentStartDateStr, timezone]
|
|
599
|
+
);
|
|
467
600
|
|
|
468
601
|
// Check if we can navigate backwards/forwards
|
|
469
602
|
const canNavigateBack = useMemo(() => {
|
|
@@ -478,34 +611,59 @@ export function Calendar({
|
|
|
478
611
|
const currentEndDate = addDays(currentStartDate, WEEKS_TO_SHOW * 7 - 1);
|
|
479
612
|
return lastAvailableDate > currentEndDate;
|
|
480
613
|
}, [availableDates, currentStartDate]);
|
|
481
|
-
|
|
614
|
+
|
|
615
|
+
const lastReportedRangeRef = useRef<{ start: Date; end: Date } | null>(null);
|
|
616
|
+
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
617
|
+
const hasSyncedChangeFlowWeekRef = useRef(false);
|
|
618
|
+
|
|
482
619
|
const handleDateJump = useCallback((date: Date) => {
|
|
483
|
-
const weekStart = startOfWeek(date, { weekStartsOn: 0 });
|
|
484
620
|
const dateStr = formatInTimeZone(date, timezone, 'yyyy-MM-dd');
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
const
|
|
488
|
-
const visibleEnd = addDays(weekStart, WEEKS_TO_SHOW * 7 - 1);
|
|
621
|
+
const weekStartStr = getSundayOfWeek(dateStr, timezone);
|
|
622
|
+
const visibleStart = dateStrToNoonInTz(weekStartStr, timezone);
|
|
623
|
+
const visibleEnd = addDays(visibleStart, WEEKS_TO_SHOW * 7 - 1);
|
|
489
624
|
const bufferStart = subWeeks(visibleStart, VISIBLE_RANGE_BUFFER_WEEKS);
|
|
490
625
|
const bufferEnd = addWeeks(visibleEnd, VISIBLE_RANGE_BUFFER_WEEKS);
|
|
491
626
|
|
|
492
|
-
// Clear any pending debounced callbacks
|
|
493
627
|
if (debounceTimeoutRef.current) {
|
|
494
628
|
clearTimeout(debounceTimeoutRef.current);
|
|
495
629
|
debounceTimeoutRef.current = null;
|
|
496
630
|
}
|
|
497
631
|
|
|
498
|
-
// Update the ref BEFORE calling the callback to prevent the useEffect from calling it again
|
|
499
632
|
lastReportedRangeRef.current = { start: bufferStart, end: bufferEnd };
|
|
500
|
-
|
|
501
|
-
// Update state and immediately notify parent (bypass debounce for date jumps)
|
|
502
|
-
setCurrentStartDate(weekStart);
|
|
633
|
+
setCurrentStartDateStr(weekStartStr);
|
|
503
634
|
if (onVisibleRangeChange) {
|
|
504
635
|
onVisibleRangeChange(bufferStart, bufferEnd);
|
|
505
636
|
}
|
|
506
637
|
|
|
507
638
|
onDateSelect(dateStr);
|
|
508
|
-
}, [timezone, onVisibleRangeChange, onDateSelect]);
|
|
639
|
+
}, [timezone, onVisibleRangeChange, onDateSelect, WEEKS_TO_SHOW]);
|
|
640
|
+
|
|
641
|
+
useEffect(() => {
|
|
642
|
+
if (!syncVisibleWeekToSelectedDate || !selectedDate || hasSyncedChangeFlowWeekRef.current) {
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
hasSyncedChangeFlowWeekRef.current = true;
|
|
646
|
+
const weekStartStr = getSundayOfWeek(selectedDate, timezone);
|
|
647
|
+
const visibleStart = dateStrToNoonInTz(weekStartStr, timezone);
|
|
648
|
+
const visibleEnd = addDays(visibleStart, WEEKS_TO_SHOW * 7 - 1);
|
|
649
|
+
const bufferStart = subWeeks(visibleStart, VISIBLE_RANGE_BUFFER_WEEKS);
|
|
650
|
+
const bufferEnd = addWeeks(visibleEnd, VISIBLE_RANGE_BUFFER_WEEKS);
|
|
651
|
+
if (debounceTimeoutRef.current) {
|
|
652
|
+
clearTimeout(debounceTimeoutRef.current);
|
|
653
|
+
debounceTimeoutRef.current = null;
|
|
654
|
+
}
|
|
655
|
+
lastReportedRangeRef.current = { start: bufferStart, end: bufferEnd };
|
|
656
|
+
setCurrentStartDateStr(weekStartStr);
|
|
657
|
+
if (onVisibleRangeChange) {
|
|
658
|
+
onVisibleRangeChange(bufferStart, bufferEnd);
|
|
659
|
+
}
|
|
660
|
+
}, [
|
|
661
|
+
syncVisibleWeekToSelectedDate,
|
|
662
|
+
selectedDate,
|
|
663
|
+
timezone,
|
|
664
|
+
WEEKS_TO_SHOW,
|
|
665
|
+
onVisibleRangeChange,
|
|
666
|
+
]);
|
|
509
667
|
|
|
510
668
|
// Generate calendar days (2 weeks = 14 days)
|
|
511
669
|
const calendarDays = useMemo(() => {
|
|
@@ -517,35 +675,49 @@ export function Calendar({
|
|
|
517
675
|
}, [currentStartDate]);
|
|
518
676
|
|
|
519
677
|
// Notify parent of visible date range changes (with buffer weeks before/after)
|
|
520
|
-
//
|
|
521
|
-
const lastReportedRangeRef = useRef<{ start: Date; end: Date } | null>(null);
|
|
522
|
-
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
523
|
-
|
|
678
|
+
// When dropdown is open, expand range to include the visible month so we fetch availability for it
|
|
524
679
|
useEffect(() => {
|
|
525
680
|
if (!onVisibleRangeChange || calendarDays.length === 0) return;
|
|
526
681
|
|
|
527
682
|
const visibleStart = calendarDays[0];
|
|
528
683
|
const visibleEnd = calendarDays[calendarDays.length - 1];
|
|
529
684
|
// Add buffer weeks before and after
|
|
530
|
-
|
|
531
|
-
|
|
685
|
+
let reportStart = subWeeks(visibleStart, VISIBLE_RANGE_BUFFER_WEEKS);
|
|
686
|
+
let reportEnd = addWeeks(visibleEnd, VISIBLE_RANGE_BUFFER_WEEKS);
|
|
687
|
+
|
|
688
|
+
// When dropdown is open, expand range to include the picker month so we fetch availability for future months
|
|
689
|
+
if (isDatePickerOpen) {
|
|
690
|
+
const pickerMonthStart = startOfMonth(pickerMonth);
|
|
691
|
+
const pickerMonthEnd = endOfMonth(pickerMonth);
|
|
692
|
+
reportStart = reportStart < pickerMonthStart ? reportStart : pickerMonthStart;
|
|
693
|
+
reportEnd = reportEnd > pickerMonthEnd ? reportEnd : pickerMonthEnd;
|
|
694
|
+
}
|
|
532
695
|
|
|
533
696
|
// Only report if range actually changed (more than a day difference)
|
|
534
697
|
const currentReported = lastReportedRangeRef.current;
|
|
535
|
-
|
|
536
|
-
Math.abs(
|
|
537
|
-
Math.abs(
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
if (
|
|
541
|
-
|
|
698
|
+
const rangeChanged = !currentReported ||
|
|
699
|
+
Math.abs(reportStart.getTime() - currentReported.start.getTime()) > 24 * 60 * 60 * 1000 ||
|
|
700
|
+
Math.abs(reportEnd.getTime() - currentReported.end.getTime()) > 24 * 60 * 60 * 1000;
|
|
701
|
+
|
|
702
|
+
if (rangeChanged) {
|
|
703
|
+
if (isDatePickerOpen) {
|
|
704
|
+
// Report immediately when dropdown is open - user may close before debounce fires
|
|
705
|
+
if (debounceTimeoutRef.current) {
|
|
706
|
+
clearTimeout(debounceTimeoutRef.current);
|
|
707
|
+
debounceTimeoutRef.current = null;
|
|
708
|
+
}
|
|
709
|
+
lastReportedRangeRef.current = { start: reportStart, end: reportEnd };
|
|
710
|
+
onVisibleRangeChange(reportStart, reportEnd);
|
|
711
|
+
} else {
|
|
712
|
+
// Debounce when main calendar navigates
|
|
713
|
+
if (debounceTimeoutRef.current) {
|
|
714
|
+
clearTimeout(debounceTimeoutRef.current);
|
|
715
|
+
}
|
|
716
|
+
debounceTimeoutRef.current = setTimeout(() => {
|
|
717
|
+
lastReportedRangeRef.current = { start: reportStart, end: reportEnd };
|
|
718
|
+
onVisibleRangeChange(reportStart, reportEnd);
|
|
719
|
+
}, 300);
|
|
542
720
|
}
|
|
543
|
-
|
|
544
|
-
// Debounce the callback
|
|
545
|
-
debounceTimeoutRef.current = setTimeout(() => {
|
|
546
|
-
lastReportedRangeRef.current = { start: bufferStart, end: bufferEnd };
|
|
547
|
-
onVisibleRangeChange(bufferStart, bufferEnd);
|
|
548
|
-
}, 300); // 300ms debounce
|
|
549
721
|
}
|
|
550
722
|
|
|
551
723
|
return () => {
|
|
@@ -553,7 +725,7 @@ export function Calendar({
|
|
|
553
725
|
clearTimeout(debounceTimeoutRef.current);
|
|
554
726
|
}
|
|
555
727
|
};
|
|
556
|
-
}, [calendarDays, onVisibleRangeChange]);
|
|
728
|
+
}, [calendarDays, onVisibleRangeChange, isDatePickerOpen, pickerMonth]);
|
|
557
729
|
|
|
558
730
|
// Calculate date availability info
|
|
559
731
|
// Use plain object instead of Map so React can detect changes properly
|
|
@@ -596,7 +768,7 @@ export function Calendar({
|
|
|
596
768
|
let timeStr: string | null = null;
|
|
597
769
|
try {
|
|
598
770
|
// Parse ISO datetime and extract time portion
|
|
599
|
-
const dateTime =
|
|
771
|
+
const dateTime = parseAvailabilityDateTime(avail.dateTime);
|
|
600
772
|
timeStr = formatInTimeZone(dateTime, timezone, 'HH:mm');
|
|
601
773
|
} catch {
|
|
602
774
|
const timeMatch = avail.dateTime.match(/T(\d{2}:\d{2})/);
|
|
@@ -623,6 +795,16 @@ export function Calendar({
|
|
|
623
795
|
}
|
|
624
796
|
}
|
|
625
797
|
});
|
|
798
|
+
|
|
799
|
+
const timeCapacityMapWithVacancies: Record<string, { booked: number; total: number; vacancies: number }> = {};
|
|
800
|
+
for (const [ts, cap] of Object.entries(timeCapacityMap)) {
|
|
801
|
+
const agg = timeAvailabilityMap.get(ts);
|
|
802
|
+
timeCapacityMapWithVacancies[ts] = {
|
|
803
|
+
booked: cap.booked,
|
|
804
|
+
total: cap.total,
|
|
805
|
+
vacancies: agg?.vacancies ?? Math.max(0, cap.total - cap.booked),
|
|
806
|
+
};
|
|
807
|
+
}
|
|
626
808
|
|
|
627
809
|
const startTimes = Array.from(timeAvailabilityMap.keys()).sort();
|
|
628
810
|
const soldOutTimes = new Set(
|
|
@@ -632,7 +814,9 @@ export function Calendar({
|
|
|
632
814
|
);
|
|
633
815
|
|
|
634
816
|
// Calculate total discount percentage from all negative adjustments
|
|
635
|
-
const
|
|
817
|
+
const dynamicDiscountPercent = calculateTotalDiscountPercent(availabilities, currency) ?? 0;
|
|
818
|
+
const totalDiscountPercent =
|
|
819
|
+
dynamicDiscountPercent + Math.max(0, extraDiscountPercent);
|
|
636
820
|
|
|
637
821
|
map[dateStr] = {
|
|
638
822
|
date: dateStr,
|
|
@@ -642,14 +826,31 @@ export function Calendar({
|
|
|
642
826
|
totalVacancies,
|
|
643
827
|
startTimes: startTimes.length > 0 ? startTimes : undefined,
|
|
644
828
|
soldOutTimes: soldOutTimes.size > 0 ? soldOutTimes : undefined,
|
|
645
|
-
totalDiscountPercent,
|
|
646
|
-
timeCapacityMap:
|
|
829
|
+
totalDiscountPercent: totalDiscountPercent > 0 ? totalDiscountPercent : undefined,
|
|
830
|
+
timeCapacityMap:
|
|
831
|
+
Object.keys(timeCapacityMapWithVacancies).length > 0 ? timeCapacityMapWithVacancies : undefined,
|
|
647
832
|
};
|
|
648
833
|
}
|
|
649
834
|
});
|
|
650
835
|
|
|
836
|
+
if (capDiscountToSelectedDate && selectedDate) {
|
|
837
|
+
const selectedDiscount = map[selectedDate]?.totalDiscountPercent;
|
|
838
|
+
if (selectedDiscount != null && selectedDiscount > 0) {
|
|
839
|
+
const capped: Record<string, DateAvailability> = {};
|
|
840
|
+
for (const [dateStr, availability] of Object.entries(map)) {
|
|
841
|
+
const raw = availability.totalDiscountPercent;
|
|
842
|
+
const adjusted = raw != null ? Math.min(raw, selectedDiscount) : undefined;
|
|
843
|
+
capped[dateStr] = {
|
|
844
|
+
...availability,
|
|
845
|
+
totalDiscountPercent: adjusted,
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
return capped;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
651
852
|
return map;
|
|
652
|
-
}, [availabilitiesByDate, timezone, currency]);
|
|
853
|
+
}, [availabilitiesByDate, timezone, currency, extraDiscountPercent, capDiscountToSelectedDate, selectedDate]);
|
|
653
854
|
|
|
654
855
|
// Get display range for header
|
|
655
856
|
const displayRange = useMemo(() => {
|
|
@@ -661,13 +862,15 @@ export function Calendar({
|
|
|
661
862
|
};
|
|
662
863
|
}, [calendarDays, timezone, dateFnsLocale]);
|
|
663
864
|
|
|
664
|
-
// Navigation handlers
|
|
865
|
+
// Navigation handlers - add/subtract weeks in company timezone
|
|
665
866
|
const handlePrevious = () => {
|
|
666
|
-
|
|
867
|
+
const prevSunday = addDays(currentStartDate, -WEEKS_TO_SHOW * 7);
|
|
868
|
+
setCurrentStartDateStr(formatInTimeZone(prevSunday, timezone, 'yyyy-MM-dd'));
|
|
667
869
|
};
|
|
668
870
|
|
|
669
871
|
const handleNext = () => {
|
|
670
|
-
|
|
872
|
+
const nextSunday = addDays(currentStartDate, WEEKS_TO_SHOW * 7);
|
|
873
|
+
setCurrentStartDateStr(formatInTimeZone(nextSunday, timezone, 'yyyy-MM-dd'));
|
|
671
874
|
};
|
|
672
875
|
|
|
673
876
|
const handleDateClick = (date: Date) => {
|
|
@@ -681,17 +884,17 @@ export function Calendar({
|
|
|
681
884
|
};
|
|
682
885
|
|
|
683
886
|
return (
|
|
684
|
-
<div className=
|
|
887
|
+
<div className={styles.calendar}>
|
|
685
888
|
{/* Calendar Header with Navigation */}
|
|
686
|
-
<div className=
|
|
889
|
+
<div className={styles.calendarHeader}>
|
|
687
890
|
<button
|
|
688
891
|
onClick={handlePrevious}
|
|
689
892
|
disabled={!canNavigateBack}
|
|
690
|
-
className=
|
|
893
|
+
className={styles.calendarNav}
|
|
691
894
|
aria-label={t('calendar.previousWeeks')}
|
|
692
895
|
>
|
|
693
896
|
<svg
|
|
694
|
-
className=
|
|
897
|
+
className={styles.calendarNavIcon}
|
|
695
898
|
fill="none"
|
|
696
899
|
stroke="currentColor"
|
|
697
900
|
viewBox="0 0 24 24"
|
|
@@ -707,12 +910,16 @@ export function Calendar({
|
|
|
707
910
|
<div className="relative">
|
|
708
911
|
<button
|
|
709
912
|
ref={datePickerTriggerRef}
|
|
710
|
-
|
|
711
|
-
|
|
913
|
+
type="button"
|
|
914
|
+
onClick={(e) => {
|
|
915
|
+
e.stopPropagation();
|
|
916
|
+
setIsDatePickerOpen((prev) => !prev);
|
|
917
|
+
}}
|
|
918
|
+
className={styles.calendarRangeTrigger}
|
|
712
919
|
>
|
|
713
920
|
<span>{displayRange.start} - {displayRange.end}</span>
|
|
714
921
|
<svg
|
|
715
|
-
className={
|
|
922
|
+
className={cn('w-3 h-3 transition-transform', isDatePickerOpen && 'rotate-180')}
|
|
716
923
|
fill="none"
|
|
717
924
|
stroke="currentColor"
|
|
718
925
|
viewBox="0 0 24 24"
|
|
@@ -720,18 +927,18 @@ export function Calendar({
|
|
|
720
927
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
721
928
|
</svg>
|
|
722
929
|
</button>
|
|
723
|
-
{isDatePickerOpen && pickerPosition && (
|
|
930
|
+
{isDatePickerOpen && pickerPosition && typeof document !== 'undefined' && createPortal(
|
|
724
931
|
<div
|
|
725
932
|
ref={datePickerRef}
|
|
726
|
-
className=
|
|
933
|
+
className={styles.calendarDropdown}
|
|
727
934
|
style={{
|
|
728
935
|
top: `${pickerPosition.top}px`,
|
|
729
936
|
left: `${pickerPosition.left}px`,
|
|
730
937
|
}}
|
|
731
938
|
onClick={(e) => e.stopPropagation()}
|
|
732
939
|
>
|
|
733
|
-
{/* Mini Calendar Header */}
|
|
734
|
-
<div className=
|
|
940
|
+
{/* Mini Calendar Header - always visible so users can change months while loading */}
|
|
941
|
+
<div className={styles.calendarDropdownHeader}>
|
|
735
942
|
<button
|
|
736
943
|
type="button"
|
|
737
944
|
onClick={(e) => {
|
|
@@ -745,13 +952,13 @@ export function Calendar({
|
|
|
745
952
|
}
|
|
746
953
|
}}
|
|
747
954
|
disabled={!canNavigatePickerMonthBack}
|
|
748
|
-
className=
|
|
955
|
+
className={styles.calendarDropdownNav}
|
|
749
956
|
>
|
|
750
|
-
<svg className=
|
|
957
|
+
<svg className={styles.calendarDropdownNavIcon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
751
958
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
752
959
|
</svg>
|
|
753
960
|
</button>
|
|
754
|
-
<div className=
|
|
961
|
+
<div className={styles.calendarDropdownMonth}>
|
|
755
962
|
{formatInTimeZone(pickerMonth, timezone, 'MMMM yyyy', { locale: dateFnsLocale })}
|
|
756
963
|
</div>
|
|
757
964
|
<button
|
|
@@ -767,41 +974,60 @@ export function Calendar({
|
|
|
767
974
|
}
|
|
768
975
|
}}
|
|
769
976
|
disabled={!canNavigatePickerMonthForward}
|
|
770
|
-
className=
|
|
977
|
+
className={styles.calendarDropdownNav}
|
|
771
978
|
>
|
|
772
|
-
<svg className=
|
|
979
|
+
<svg className={styles.calendarDropdownNavIcon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
773
980
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
774
981
|
</svg>
|
|
775
982
|
</button>
|
|
776
983
|
</div>
|
|
777
984
|
|
|
778
|
-
{/*
|
|
779
|
-
<div className=
|
|
985
|
+
{/* Grid area - overlay only covers this, not the header */}
|
|
986
|
+
<div className={styles.calendarDropdownGridWrapper}>
|
|
987
|
+
{showSpinnerInDropdown && (
|
|
988
|
+
<div className={styles.calendarDropdownLoading} aria-hidden>
|
|
989
|
+
<div className="booking-calendar-dropdown-spinner" />
|
|
990
|
+
</div>
|
|
991
|
+
)}
|
|
992
|
+
{/* Mini Calendar Days of Week */}
|
|
993
|
+
<div className={styles.calendarDropdownDaysOfWeek}>
|
|
780
994
|
{DAYS_OF_WEEK_KEYS.map((dayKey) => (
|
|
781
|
-
<div key={dayKey} className=
|
|
995
|
+
<div key={dayKey} className={styles.calendarDropdownDow}>
|
|
782
996
|
{t(`calendar.days.${dayKey}`)[0]}
|
|
783
997
|
</div>
|
|
784
998
|
))}
|
|
785
999
|
</div>
|
|
786
1000
|
|
|
787
1001
|
{/* Mini Calendar Grid */}
|
|
788
|
-
<div className=
|
|
1002
|
+
<div className={styles.calendarDropdownDays}>
|
|
789
1003
|
{miniCalendarData.days.map((day) => {
|
|
790
1004
|
const dateStr = formatInTimeZone(day, timezone, 'yyyy-MM-dd');
|
|
791
1005
|
const dayNumber = formatInTimeZone(day, timezone, 'd');
|
|
792
1006
|
const availability = dateAvailabilityMap[dateStr] || null;
|
|
793
1007
|
const isSelected = selectedDate === dateStr;
|
|
794
1008
|
const isCurrentMonth = day >= miniCalendarData.monthStart && day <= miniCalendarData.monthEnd;
|
|
795
|
-
// Check if day is within allowed range (June 1 - October 12, 2026)
|
|
796
|
-
const isInAllowedRange = day >=
|
|
1009
|
+
// Check if day is within allowed range (June 1 - October 12, 2026) in company timezone
|
|
1010
|
+
const isInAllowedRange = day >= miniCalendarBoundsInTz.start && day <= miniCalendarBoundsInTz.end;
|
|
797
1011
|
|
|
798
|
-
//
|
|
799
|
-
|
|
1012
|
+
// Only treat as available if we have actual availability data for this date
|
|
1013
|
+
const hasAvailabilityData = availability !== null;
|
|
800
1014
|
const isSoldOut = availability?.isSoldOut === true;
|
|
801
1015
|
// When showCapacity (admin), sold-out days are still selectable for overbooking
|
|
802
|
-
const isAvailable = isInAllowedRange && (!isSoldOut || showCapacity);
|
|
1016
|
+
const isAvailable = hasAvailabilityData && isInAllowedRange && (!isSoldOut || showCapacity);
|
|
803
1017
|
const isToday = isSameDay(day, new Date());
|
|
804
1018
|
|
|
1019
|
+
const dayClass = cn(
|
|
1020
|
+
styles.calendarDropdownDay,
|
|
1021
|
+
!isCurrentMonth && styles.calendarDropdownDayMuted,
|
|
1022
|
+
isSoldOut && !showCapacity && styles.calendarDropdownDaySoldOut,
|
|
1023
|
+
isSoldOut && showCapacity && styles.calendarDropdownDaySoldOutAdmin,
|
|
1024
|
+
!isSoldOut && !isInAllowedRange && styles.calendarDropdownDayMuted,
|
|
1025
|
+
!hasAvailabilityData && isInAllowedRange && styles.calendarDropdownDayMuted,
|
|
1026
|
+
isAvailable && isSelected && styles.calendarDropdownDaySelected,
|
|
1027
|
+
isAvailable && !isSelected && styles.calendarDropdownDayAvailable,
|
|
1028
|
+
isToday && isAvailable && !isSelected && styles.calendarDropdownDayToday
|
|
1029
|
+
);
|
|
1030
|
+
|
|
805
1031
|
return (
|
|
806
1032
|
<button
|
|
807
1033
|
key={dateStr}
|
|
@@ -812,37 +1038,26 @@ export function Calendar({
|
|
|
812
1038
|
}
|
|
813
1039
|
}}
|
|
814
1040
|
disabled={!isAvailable}
|
|
815
|
-
className={
|
|
816
|
-
'aspect-square text-xs rounded transition-colors',
|
|
817
|
-
!isCurrentMonth && 'text-stone-300',
|
|
818
|
-
isSoldOut && !showCapacity
|
|
819
|
-
? 'bg-red-400 text-white cursor-not-allowed'
|
|
820
|
-
: isSoldOut && showCapacity
|
|
821
|
-
? 'bg-red-400 text-white cursor-pointer hover:bg-red-500'
|
|
822
|
-
: !isInAllowedRange
|
|
823
|
-
? 'text-stone-300 cursor-not-allowed'
|
|
824
|
-
: isSelected
|
|
825
|
-
? 'bg-emerald-600 text-white hover:bg-emerald-700'
|
|
826
|
-
: 'bg-emerald-50 text-stone-900 hover:bg-emerald-100 cursor-pointer',
|
|
827
|
-
isToday && isAvailable && !isSelected && 'ring-1 ring-emerald-500'
|
|
828
|
-
)}
|
|
1041
|
+
className={dayClass}
|
|
829
1042
|
>
|
|
830
1043
|
{dayNumber}
|
|
831
1044
|
</button>
|
|
832
1045
|
);
|
|
833
1046
|
})}
|
|
834
1047
|
</div>
|
|
835
|
-
|
|
1048
|
+
</div>
|
|
1049
|
+
</div>,
|
|
1050
|
+
document.body
|
|
836
1051
|
)}
|
|
837
1052
|
</div>
|
|
838
1053
|
<button
|
|
839
1054
|
onClick={handleNext}
|
|
840
1055
|
disabled={!canNavigateForward}
|
|
841
|
-
className=
|
|
1056
|
+
className={styles.calendarNav}
|
|
842
1057
|
aria-label={t('calendar.nextWeeks')}
|
|
843
1058
|
>
|
|
844
1059
|
<svg
|
|
845
|
-
className=
|
|
1060
|
+
className={styles.calendarNavIcon}
|
|
846
1061
|
fill="none"
|
|
847
1062
|
stroke="currentColor"
|
|
848
1063
|
viewBox="0 0 24 24"
|
|
@@ -857,15 +1072,15 @@ export function Calendar({
|
|
|
857
1072
|
</button>
|
|
858
1073
|
</div>
|
|
859
1074
|
|
|
860
|
-
{/* Calendar Grid
|
|
861
|
-
<div className=
|
|
862
|
-
<div className=
|
|
1075
|
+
{/* Calendar Grid */}
|
|
1076
|
+
<div className={styles.calendarGrid}>
|
|
1077
|
+
<div className={styles.calendarGridInner}>
|
|
863
1078
|
{/* Day Headers */}
|
|
864
|
-
<div className=
|
|
1079
|
+
<div className={cn(styles.calendarHeaderRow, 'calendar-header-grid')}>
|
|
865
1080
|
{DAYS_OF_WEEK_KEYS.map((dayKey) => (
|
|
866
1081
|
<div
|
|
867
1082
|
key={dayKey}
|
|
868
|
-
className=
|
|
1083
|
+
className={styles.calendarHeaderCell}
|
|
869
1084
|
>
|
|
870
1085
|
{t(`calendar.days.${dayKey}`)}
|
|
871
1086
|
</div>
|
|
@@ -873,7 +1088,7 @@ export function Calendar({
|
|
|
873
1088
|
</div>
|
|
874
1089
|
|
|
875
1090
|
{/* Calendar Days Grid - row height = tallest cell in row; all cells stretch to that height */}
|
|
876
|
-
<div className=
|
|
1091
|
+
<div className={cn(styles.calendarDaysGrid, 'calendar-days-grid')}>
|
|
877
1092
|
{calendarDays.map((date) => {
|
|
878
1093
|
const dateStr = formatInTimeZone(date, timezone, 'yyyy-MM-dd');
|
|
879
1094
|
const availability = dateAvailabilityMap[dateStr] || null;
|
|
@@ -894,7 +1109,9 @@ export function Calendar({
|
|
|
894
1109
|
isToday={isToday}
|
|
895
1110
|
showCapacity={showCapacity}
|
|
896
1111
|
timezone={timezone}
|
|
1112
|
+
displayMode={displayMode}
|
|
897
1113
|
onClick={() => handleDateClick(date)}
|
|
1114
|
+
isMobile={isMobile}
|
|
898
1115
|
/>
|
|
899
1116
|
);
|
|
900
1117
|
})}
|