@ticketboothapp/booking 0.1.11 → 0.1.13

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.
Files changed (255) hide show
  1. package/package.json +2 -1
  2. package/src/app/photo-sessions/photo-packages.ts +75 -0
  3. package/src/assets/icons/partner-logos/getyourguide.svg +8 -0
  4. package/src/assets/icons/plus.svg +3 -0
  5. package/src/colours.css +23 -0
  6. package/src/components/BookingDetails.module.css +1591 -0
  7. package/src/components/BookingDetails.tsx +2072 -354
  8. package/src/components/BookingWidget.tsx +28 -248
  9. package/src/components/JobApplicationDialog.module.css +440 -0
  10. package/src/components/JobApplicationDialog.tsx +620 -0
  11. package/src/components/ManageBookingView.tsx +28 -36
  12. package/src/components/PhoneInputWithCountry.module.css +131 -0
  13. package/src/components/PhoneInputWithCountry.tsx +44 -0
  14. package/src/components/PickupLocationDialog.module.css +360 -0
  15. package/src/components/PickupLocationDialog.tsx +357 -0
  16. package/src/components/PickupLocationMap.tsx +110 -0
  17. package/src/components/PostBookingDependentAddOnUpsell.module.css +174 -0
  18. package/src/components/PostBookingDependentAddOnUpsell.tsx +407 -0
  19. package/src/components/accordion.css +27 -0
  20. package/src/components/accordion.tsx +29 -0
  21. package/src/components/analytics/AnalyticsConsentRestore.tsx +19 -0
  22. package/src/components/analytics/AnalyticsScripts.tsx +106 -0
  23. package/src/components/analytics/CookieConsentBanner.css +86 -0
  24. package/src/components/analytics/CookieConsentBanner.tsx +102 -0
  25. package/src/components/booking/AddOnsSection.module.css +10 -0
  26. package/src/components/booking/AddOnsSection.tsx +184 -0
  27. package/src/components/booking/AdminPaymentChoiceModal.tsx +98 -0
  28. package/src/components/booking/BookingDialog.module.css +643 -0
  29. package/src/components/booking/BookingDialog.tsx +356 -0
  30. package/src/components/booking/BookingFlow.tsx +4385 -0
  31. package/src/components/booking/BookingFlowCollage.module.css +148 -0
  32. package/src/components/booking/BookingFlowCollage.tsx +184 -0
  33. package/src/components/booking/BookingFlowPlaceholder.module.css +27 -0
  34. package/src/components/booking/BookingFlowPlaceholder.tsx +25 -0
  35. package/src/components/booking/BookingFlowPreview.tsx +51 -0
  36. package/src/components/booking/BookingProductGrid.module.css +359 -0
  37. package/src/components/booking/BookingProductGrid.tsx +497 -0
  38. package/src/components/booking/Calendar.module.css +616 -0
  39. package/src/components/{Calendar.tsx → booking/Calendar.tsx} +464 -247
  40. package/src/components/booking/CancellationPolicySelector.module.css +124 -0
  41. package/src/components/booking/CancellationPolicySelector.tsx +142 -0
  42. package/src/components/booking/ChangeBookingDialog.tsx +562 -0
  43. package/src/components/booking/CheckoutForm.module.css +244 -0
  44. package/src/components/booking/CheckoutForm.tsx +364 -0
  45. package/src/components/{CheckoutModal.tsx → booking/CheckoutModal.tsx} +176 -19
  46. package/src/components/booking/DapFlowCollage.tsx +88 -0
  47. package/src/components/booking/DapTourDescription.tsx +35 -0
  48. package/src/components/booking/DependentAddOnBookingDialog.tsx +1350 -0
  49. package/src/components/booking/DependentAddOnPaymentForm.tsx +124 -0
  50. package/src/components/booking/InfoTooltip.tsx +108 -0
  51. package/src/components/booking/ItineraryBox.module.css +258 -0
  52. package/src/components/booking/ItineraryBox.tsx +550 -0
  53. package/src/components/{ItineraryBuilder.tsx → booking/ItineraryBuilder.tsx} +1 -2
  54. package/src/components/booking/ItineraryPlaceholder.module.css +45 -0
  55. package/src/components/booking/ItineraryPlaceholder.tsx +26 -0
  56. package/src/components/{MealDrinkAddOnSelector.tsx → booking/MealDrinkAddOnSelector.tsx} +21 -13
  57. package/src/components/booking/PickupLocationSelector.module.css +124 -0
  58. package/src/components/{PickupLocationSelector.tsx → booking/PickupLocationSelector.tsx} +315 -290
  59. package/src/components/booking/PickupTimeSelector.module.css +134 -0
  60. package/src/components/booking/PickupTimeSelector.tsx +112 -0
  61. package/src/components/{PriceBreakdown.tsx → booking/PriceBreakdown.tsx} +3 -3
  62. package/src/components/{PriceSummary.tsx → booking/PriceSummary.tsx} +51 -28
  63. package/src/components/booking/PrivateShuttleBookingFlow.module.css +357 -0
  64. package/src/components/booking/PrivateShuttleBookingFlow.tsx +2662 -0
  65. package/src/components/booking/PromoCodeInput.module.css +166 -0
  66. package/src/components/booking/PromoCodeInput.tsx +99 -0
  67. package/src/components/booking/ReturnTimeSelector.module.css +173 -0
  68. package/src/components/booking/ReturnTimeSelector.tsx +145 -0
  69. package/src/components/{TermsAcceptance.tsx → booking/TermsAcceptance.tsx} +9 -8
  70. package/src/components/booking/TicketSelector.module.css +164 -0
  71. package/src/components/booking/TicketSelector.tsx +199 -0
  72. package/src/components/booking/TourDescription.module.css +304 -0
  73. package/src/components/booking/TourDescription.tsx +273 -0
  74. package/src/components/booking/booking-flow-ui.ts +15 -1
  75. package/src/components/booking/booking-flow.css +944 -0
  76. package/src/components/bottom-sheet.module.css +78 -0
  77. package/src/components/bottom-sheet.tsx +60 -0
  78. package/src/components/breadcrumb.module.css +40 -0
  79. package/src/components/breadcrumb.tsx +36 -0
  80. package/src/components/button.css +245 -0
  81. package/src/components/button.tsx +152 -0
  82. package/src/components/client-bottom-sheet.tsx +14 -0
  83. package/src/components/colorable-svg.tsx +29 -0
  84. package/src/components/conditional-footer.tsx +27 -0
  85. package/src/components/contact-us.module.css +147 -0
  86. package/src/components/contact-us.tsx +49 -0
  87. package/src/components/email-signup.css +151 -0
  88. package/src/components/email-signup.tsx +63 -0
  89. package/src/components/faq-wrapper.module.css +47 -0
  90. package/src/components/faq-wrapper.tsx +15 -0
  91. package/src/components/footer.css +187 -0
  92. package/src/components/footer.tsx +143 -0
  93. package/src/components/global-simple-modal.tsx +33 -0
  94. package/src/components/google-review-summary.module.css +77 -0
  95. package/src/components/google-review-summary.tsx +50 -0
  96. package/src/components/hero-image.css +13 -0
  97. package/src/components/hero-image.tsx +44 -0
  98. package/src/components/image.css +29 -0
  99. package/src/components/image.tsx +113 -0
  100. package/src/components/language-aware-link.tsx +72 -0
  101. package/src/components/language-switcher.module.css +124 -0
  102. package/src/components/language-switcher.tsx +75 -0
  103. package/src/components/map-section.css +59 -0
  104. package/src/components/map-section.tsx +63 -0
  105. package/src/components/navbar.module.css +152 -0
  106. package/src/components/navbar.tsx +125 -0
  107. package/src/components/parallax-provider.tsx +11 -0
  108. package/src/components/partner/PartnerBookingPage.module.css +130 -0
  109. package/src/components/partner/PartnerBookingPage.tsx +390 -0
  110. package/src/components/partner/PartnerBookingPageWithBrowserMetadata.tsx +19 -35
  111. package/src/components/product-tag.module.css +30 -0
  112. package/src/components/product-tag.tsx +34 -0
  113. package/src/components/product-theme-pages/best-option.module.css +70 -0
  114. package/src/components/product-theme-pages/best-option.tsx +35 -0
  115. package/src/components/product-theme-pages/extended-tour-options.module.css +22 -0
  116. package/src/components/product-theme-pages/extended-tour-options.tsx +11 -0
  117. package/src/components/product-theme-pages/image-modal.tsx +248 -0
  118. package/src/components/product-theme-pages/photo-gallery.module.css +200 -0
  119. package/src/components/product-theme-pages/photo-gallery.tsx +90 -0
  120. package/src/components/product-theme-pages/product-theme-page-layout.module.css +13 -0
  121. package/src/components/product-theme-pages/product-theme-page-layout.tsx +67 -0
  122. package/src/components/product-theme-pages/top-of-fold.module.css +179 -0
  123. package/src/components/product-theme-pages/top-of-fold.tsx +80 -0
  124. package/src/components/product-tile/image-only-product-tile-desktop.module.css +106 -0
  125. package/src/components/product-tile/image-only-product-tile-desktop.tsx +56 -0
  126. package/src/components/product-tile/image-only-product-tile-mobile.module.css +122 -0
  127. package/src/components/product-tile/image-only-product-tile-mobile.tsx +89 -0
  128. package/src/components/product-tile/image-only-product-tile.tsx +44 -0
  129. package/src/components/product-tile/product-tile-card.module.css +84 -0
  130. package/src/components/product-tile/product-tile-card.tsx +61 -0
  131. package/src/components/review-highlights-section.css +85 -0
  132. package/src/components/review-highlights-section.tsx +127 -0
  133. package/src/components/season-closure-overlay.module.css +99 -0
  134. package/src/components/season-closure-overlay.tsx +98 -0
  135. package/src/components/simple-modal.tsx +69 -0
  136. package/src/components/simple-top-of-fold.module.css +76 -0
  137. package/src/components/simple-top-of-fold.tsx +34 -0
  138. package/src/components/spacer.css +41 -0
  139. package/src/components/spacer.tsx +23 -0
  140. package/src/components/star-rating.module.css +74 -0
  141. package/src/components/star-rating.tsx +48 -0
  142. package/src/components/terms/TermsContent.tsx +178 -0
  143. package/src/components/title-subtitle.module.css +10 -0
  144. package/src/components/title-subtitle.tsx +30 -0
  145. package/src/components/translatable-reviews.tsx +75 -0
  146. package/src/components/value-pill.module.css +59 -0
  147. package/src/components/value-pill.tsx +46 -0
  148. package/src/components/value-props.css +185 -0
  149. package/src/components/value-props.tsx +88 -0
  150. package/src/constants/booking-guide-quiz.ts +64 -0
  151. package/src/constants/contact-info.ts +2 -0
  152. package/src/constants/faq.ts +44 -0
  153. package/src/constants/images.ts +556 -0
  154. package/src/constants/json-ld/faq-json-ld.tsx +170 -0
  155. package/src/constants/json-ld/homepage-json-ld.tsx +138 -0
  156. package/src/constants/json-ld/job-posting-json-ld.tsx +92 -0
  157. package/src/constants/json-ld/organization-json-ld.tsx +62 -0
  158. package/src/constants/json-ld/page-json-ld.tsx +6 -0
  159. package/src/constants/json-ld/product-json-ld.tsx +154 -0
  160. package/src/constants/json-ld/review-json-ld.tsx +377 -0
  161. package/src/constants/navigation-links/footer-links.ts +48 -0
  162. package/src/constants/navigation-links/nav-bar-links.ts +41 -0
  163. package/src/constants/navigation-links/navigation-link.ts +6 -0
  164. package/src/constants/pill-values.ts +210 -0
  165. package/src/constants/products.ts +155 -0
  166. package/src/constants/quiz-recommendations.ts +506 -0
  167. package/src/constants/reviews.ts +75 -0
  168. package/src/constants/staff.ts +197 -0
  169. package/src/constants/value-props.ts +58 -0
  170. package/src/data/dap-descriptions/session-couples-families-friends.en.json +61 -0
  171. package/src/data/dap-descriptions/session-elopements.en.json +60 -0
  172. package/src/data/dap-descriptions/session-proposals.en.json +60 -0
  173. package/src/data/product-descriptions/afternoon-delight.en.json +35 -0
  174. package/src/data/product-descriptions/emerald-lake-escape.en.json +68 -0
  175. package/src/data/product-descriptions/lake-louise-adventure.en.json +74 -0
  176. package/src/data/product-descriptions/moraine-lake-adventure.en.json +78 -0
  177. package/src/data/product-descriptions/moraine-lake-sunrise-lake-louise-golden-hour.en.json +65 -0
  178. package/src/data/product-descriptions/moraine-lake-sunrise.en.json +64 -0
  179. package/src/data/product-descriptions/private-tour.en.json +80 -0
  180. package/src/data/product-descriptions/two-lakes-combo.en.json +65 -0
  181. package/src/data/products-config.json +101 -0
  182. package/src/hooks/use-bottom-sheet.tsx +15 -0
  183. package/src/hooks/use-simple-modal.tsx +27 -0
  184. package/src/hooks/useBookingSourceMetadataFromLocation.ts +21 -0
  185. package/src/hooks/useEmailSubscription.tsx +103 -0
  186. package/src/hooks/useEmbeddedInIframe.ts +16 -0
  187. package/src/hooks/useIsBookingLaunchLive.ts +49 -0
  188. package/src/hooks/useQuiz.tsx +210 -0
  189. package/src/index.ts +27 -2
  190. package/src/lib/analytics.ts +197 -0
  191. package/src/lib/booking/booking-source.ts +20 -2
  192. package/src/lib/{checkout-breakdown.ts → booking/checkout-breakdown.ts} +1 -1
  193. package/src/lib/booking/correlation-id.ts +46 -0
  194. package/src/lib/{i18n → booking/i18n}/messages/en.json +48 -4
  195. package/src/lib/{i18n → booking/i18n}/messages/fr.json +48 -4
  196. package/src/lib/booking/itinerary-display.ts +36 -0
  197. package/src/lib/{itinerary-labels.ts → booking/itinerary-labels.ts} +1 -1
  198. package/src/lib/{location-calculations.ts → booking/location-calculations.ts} +4 -4
  199. package/src/lib/{location-utils.ts → booking/location-utils.ts} +26 -0
  200. package/src/lib/{map-utils.ts → booking/map-utils.ts} +3 -3
  201. package/src/lib/booking/normalize-booking-product-id.ts +7 -0
  202. package/src/lib/{pickup-location-types.ts → booking/pickup-location-types.ts} +2 -2
  203. package/src/lib/{pricing.ts → booking/pricing.ts} +2 -2
  204. package/src/lib/booking/product-option-id.ts +35 -0
  205. package/src/lib/booking/source-metadata.ts +72 -7
  206. package/src/lib/booking/sunday-week.ts +14 -0
  207. package/src/lib/booking/trace-context.ts +62 -0
  208. package/src/lib/booking-api.ts +1793 -0
  209. package/src/lib/{constants.ts → booking-constants.ts} +11 -5
  210. package/src/lib/booking-types.ts +36 -0
  211. package/src/lib/currency.ts +38 -45
  212. package/src/lib/dap-descriptions.ts +50 -0
  213. package/src/lib/dap-itinerary-preview.ts +315 -0
  214. package/src/lib/dependent-add-on-api.ts +434 -0
  215. package/src/lib/env.ts +89 -5
  216. package/src/lib/firebase.ts +20 -0
  217. package/src/lib/job-application-api.ts +83 -0
  218. package/src/lib/manage-booking-embed-print.ts +16 -0
  219. package/src/lib/manage-booking-post-checkout.ts +68 -0
  220. package/src/lib/photo-dap-config.ts +228 -0
  221. package/src/lib/pickup/map-utils.ts +56 -0
  222. package/src/lib/pickup/marker-icons.ts +19 -0
  223. package/src/lib/product-descriptions.ts +66 -0
  224. package/src/lib/products-config.ts +73 -0
  225. package/src/providers/booking-dialog-provider.tsx +107 -38
  226. package/src/providers/bottom-sheet-provider.tsx +40 -0
  227. package/src/providers/dependent-add-on-dialog-provider.tsx +105 -0
  228. package/src/radius.css +5 -0
  229. package/src/spacing.css +7 -0
  230. package/src/strings/en.json +1774 -0
  231. package/src/strings/es.json +1573 -0
  232. package/src/strings/fr.json +1573 -0
  233. package/src/strings/index.js +23 -0
  234. package/src/text-style.css +97 -0
  235. package/src/types/fareharbor.d.ts +12 -0
  236. package/src/types/quiz.ts +59 -0
  237. package/src/utils/currency-converter.ts +101 -0
  238. package/src/components/BookingFlow.tsx +0 -2952
  239. package/src/components/LanguageSwitcher.tsx +0 -30
  240. package/src/components/PrivateShuttleBookingFlow.tsx +0 -2290
  241. package/src/components/ProductList.tsx +0 -78
  242. package/src/components/WhatsAppPhoneInput.tsx +0 -224
  243. package/src/components/index.ts +0 -31
  244. package/src/lib/api.ts +0 -801
  245. package/src/lib/booking-api-auth.ts +0 -9
  246. package/src/lib/checkout-breakdown.test.ts +0 -70
  247. package/src/types/google-maps.d.ts +0 -2
  248. /package/src/components/{CurrencySwitcher.tsx → booking/CurrencySwitcher.tsx} +0 -0
  249. /package/src/components/{ErrorBoundary.tsx → booking/ErrorBoundary.tsx} +0 -0
  250. /package/src/lib/{i18n → booking/i18n}/config.ts +0 -0
  251. /package/src/lib/{i18n → booking/i18n}/index.tsx +0 -0
  252. /package/src/lib/{marker-icons.ts → booking/marker-icons.ts} +0 -0
  253. /package/src/lib/{places-api.ts → booking/places-api.ts} +0 -0
  254. /package/src/lib/{theme.ts → booking/theme.ts} +0 -0
  255. /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 { cn } from '@/lib/utils';
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 booked/total capacity (for admin calendar: show "booked/total" on each slot) */
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 booked/total capacity on each time slot */
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 WEEKS_TO_SHOW = 2;
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., "09:00" -> "9:00 AM" or "9:00" in 24h format)
145
- // Memoized to prevent recreation on every render
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 date = new Date();
150
- date.setHours(hours, minutes, 0, 0);
151
- // Use 12-hour format for English, 24-hour format for French
152
- const format = locale === 'fr' ? 'HH:mm' : 'h:mm a';
153
- return formatInTimeZone(date, timezone, format, { locale: dateFnsLocale });
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. No aspect on outer so cell can stretch to row.
160
- const needsTallerCell = (availability?.startTimes?.length ?? 0) > 2;
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
- 'h-full w-full min-h-0',
163
- needsTallerCell ? 'min-w-0 min-h-[7.75rem]' : 'min-h-[6.25rem]',
164
- 'px-2 py-0 border-r border-b border-stone-200 last:border-r-0',
165
- 'transition-all text-left relative',
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
- ? 'bg-stone-50 text-stone-300 cursor-not-allowed'
214
+ ? styles.calendarDayCellDisabled
168
215
  : isSelected
169
- ? 'bg-emerald-600 text-white hover:bg-emerald-700 cursor-pointer'
170
- : 'bg-white text-stone-900 hover:bg-stone-50 cursor-pointer',
171
- isToday && hasAvailability && !isSelected && 'ring-1 ring-emerald-500 ring-inset'
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="flex flex-col h-full items-center justify-center">
194
- {/* Day Number */}
195
- <div className={dayNumberClassName}>
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 Icon - Top Right Corner */}
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="absolute top-1 right-1 flex items-center gap-0.5 bg-red-500/90 text-white text-[9px] font-bold px-1 py-0.5 rounded">
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={cn(
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
- {/* Start Times as Pills - Yellow/Orange if < 5 spots, Red if sold out, Green otherwise */}
225
- {availability.startTimes && availability.startTimes.length > 0 ? (
226
- <div className="flex flex-wrap gap-0.5 justify-center">
227
- {availability.startTimes.slice(0, 3).map((time) => {
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={cn(
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={cn(
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
- // Initialize currentStartDate only once on mount, don't reset when earliestDate changes
336
- // Use a ref to track if we've initialized to prevent reset on prop changes
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 [currentStartDate, setCurrentStartDate] = useState(() => {
397
+ const [currentStartDateStr, setCurrentStartDateStr] = useState(() => {
339
398
  hasInitializedRef.current = true;
340
399
  if (earliestDate) {
341
- return startOfWeek(earliestDate, { weekStartsOn: 0 });
400
+ const dateStr = formatInTimeZone(earliestDate, timezone, 'yyyy-MM-dd');
401
+ return getSundayOfWeek(dateStr, timezone);
342
402
  }
343
- return startOfWeek(new Date(), { weekStartsOn: 0 });
403
+ const nowStr = formatInTimeZone(new Date(), timezone, 'yyyy-MM-dd');
404
+ return getSundayOfWeek(nowStr, timezone);
344
405
  });
345
-
346
- // Only update currentStartDate if earliestDate changes AND we haven't initialized yet
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 weekStart = startOfWeek(earliestDate, { weekStartsOn: 0 });
351
- setCurrentStartDate(weekStart);
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
- // Start with the month that contains the current start date
360
- return startOfMonth(currentStartDate);
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 currentMonth = startOfMonth(currentStartDate);
366
- // Only update if the month actually changed and it's within the allowed range
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
- }, [currentStartDate]);
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
- // Get all available dates for dropdown (must be defined before useMemo hooks that use it)
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
- try {
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
- // Generate mini calendar days - memoized to avoid recalculation
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 monthStart = startOfMonth(pickerMonth);
421
- const monthEnd = endOfMonth(pickerMonth);
422
- const calendarStart = startOfWeek(monthStart, { weekStartsOn: 0 });
423
- const calendarEnd = startOfWeek(monthEnd, { weekStartsOn: 0 });
424
- // Show 6 weeks (42 days) to ensure full calendar grid
425
- const days = eachDayOfInterval({ start: calendarStart, end: addDays(calendarEnd, 6) });
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
- setPickerPosition({
440
- top: rect.bottom + window.scrollY + 4,
441
- left: rect.left + window.scrollX,
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
- // Calculate the visible range directly without creating unnecessary array
487
- const visibleStart = weekStart;
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
- // Debounce to avoid excessive API calls during rapid navigation
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
- const bufferStart = subWeeks(visibleStart, VISIBLE_RANGE_BUFFER_WEEKS);
531
- const bufferEnd = addWeeks(visibleEnd, VISIBLE_RANGE_BUFFER_WEEKS);
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
- if (!currentReported ||
536
- Math.abs(bufferStart.getTime() - currentReported.start.getTime()) > 24 * 60 * 60 * 1000 ||
537
- Math.abs(bufferEnd.getTime() - currentReported.end.getTime()) > 24 * 60 * 60 * 1000) {
538
-
539
- // Clear existing timeout
540
- if (debounceTimeoutRef.current) {
541
- clearTimeout(debounceTimeoutRef.current);
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 = parseISO(avail.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 totalDiscountPercent = calculateTotalDiscountPercent(availabilities, currency);
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: Object.keys(timeCapacityMap).length > 0 ? timeCapacityMap : undefined,
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
- setCurrentStartDate(subWeeks(currentStartDate, WEEKS_TO_SHOW));
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
- setCurrentStartDate(addWeeks(currentStartDate, WEEKS_TO_SHOW));
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="w-full max-w-2xl mx-auto relative">
887
+ <div className={styles.calendar}>
685
888
  {/* Calendar Header with Navigation */}
686
- <div className="flex items-center justify-between mb-1.5">
889
+ <div className={styles.calendarHeader}>
687
890
  <button
688
891
  onClick={handlePrevious}
689
892
  disabled={!canNavigateBack}
690
- className="p-1.5 rounded-lg hover:bg-stone-100 transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent"
893
+ className={styles.calendarNav}
691
894
  aria-label={t('calendar.previousWeeks')}
692
895
  >
693
896
  <svg
694
- className="w-4 h-4 text-stone-600"
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
- onClick={() => setIsDatePickerOpen(!isDatePickerOpen)}
711
- className="text-xs font-medium text-stone-700 hover:text-stone-900 px-2 py-1 rounded hover:bg-stone-100 transition-colors cursor-pointer flex items-center gap-1"
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={`w-3 h-3 text-stone-600 transition-transform ${isDatePickerOpen ? 'rotate-180' : ''}`}
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="fixed bg-white border border-stone-300 rounded-lg shadow-lg z-50 p-3 min-w-[280px] max-h-[400px] overflow-y-auto"
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="flex items-center justify-between mb-2">
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="p-1 rounded hover:bg-stone-100 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent transition-colors"
955
+ className={styles.calendarDropdownNav}
749
956
  >
750
- <svg className="w-4 h-4 text-stone-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
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="text-sm font-semibold text-stone-700">
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="p-1 rounded hover:bg-stone-100 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent transition-colors"
977
+ className={styles.calendarDropdownNav}
771
978
  >
772
- <svg className="w-4 h-4 text-stone-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
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
- {/* Mini Calendar Days of Week */}
779
- <div className="grid grid-cols-7 gap-1 mb-1">
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="text-[10px] font-semibold text-stone-500 text-center py-1">
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="grid grid-cols-7 gap-1">
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 >= MINI_CALENDAR_START_DATE && day <= MINI_CALENDAR_END_DATE;
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
- // If we have availability data and it's sold out, show as sold out
799
- // Otherwise, if in allowed range, show as available (even if not fetched yet)
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={cn(
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
- </div>
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="p-1.5 rounded-lg hover:bg-stone-100 transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent"
1056
+ className={styles.calendarNav}
842
1057
  aria-label={t('calendar.nextWeeks')}
843
1058
  >
844
1059
  <svg
845
- className="w-4 h-4 text-stone-600"
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: on mobile scroll horizontally; on sm+ fit container so no scroll to see Saturday */}
861
- <div className="border border-stone-200 rounded-lg overflow-hidden bg-white overflow-x-auto sm:overflow-visible">
862
- <div className="min-w-[700px] sm:min-w-0 sm:w-full pb-3 sm:pb-0">
1075
+ {/* Calendar Grid */}
1076
+ <div className={styles.calendarGrid}>
1077
+ <div className={styles.calendarGridInner}>
863
1078
  {/* Day Headers */}
864
- <div className="grid grid-cols-7 bg-stone-50 border-b border-stone-200">
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="py-1 text-center text-xs font-semibold text-stone-600 uppercase tracking-wide"
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="grid grid-cols-7 items-stretch">
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
  })}