@ticketboothapp/booking 1.2.24 → 1.2.25-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +29 -2
- package/src/assets/icons/minus.svg +7 -0
- package/src/assets/icons/partner-logos/getyourguide.svg +8 -0
- package/src/assets/icons/plus.svg +3 -0
- package/src/colours.css +23 -0
- package/src/components/BookingDetails.module.css +1591 -0
- package/src/components/BookingDetails.tsx +2264 -0
- package/src/components/BookingWidget.tsx +302 -0
- package/src/components/ManageBookingView.tsx +437 -0
- package/src/components/PhoneInputWithCountry.module.css +131 -0
- package/src/components/PhoneInputWithCountry.tsx +44 -0
- package/src/components/PickupLocationDialog.module.css +360 -0
- package/src/components/PickupLocationDialog.tsx +357 -0
- package/src/components/PostBookingDependentAddOnUpsell.module.css +174 -0
- package/src/components/PostBookingDependentAddOnUpsell.tsx +407 -0
- package/src/components/booking/AddOnsSection.module.css +10 -0
- package/src/components/booking/AddOnsSection.tsx +184 -0
- package/src/components/booking/AdminPaymentChoiceModal.tsx +98 -0
- package/src/components/booking/BookingDialog.module.css +643 -0
- package/src/components/booking/BookingDialog.tsx +356 -0
- package/src/components/booking/BookingFlow.tsx +4385 -0
- package/src/components/booking/BookingFlowCollage.module.css +148 -0
- package/src/components/booking/BookingFlowCollage.tsx +184 -0
- package/src/components/booking/BookingFlowPlaceholder.module.css +27 -0
- package/src/components/booking/BookingFlowPlaceholder.tsx +25 -0
- package/src/components/booking/BookingFlowPreview.tsx +51 -0
- package/src/components/booking/BookingProductGrid.module.css +359 -0
- package/src/components/booking/BookingProductGrid.tsx +497 -0
- package/src/components/booking/Calendar.module.css +616 -0
- package/src/components/booking/Calendar.tsx +1123 -0
- package/src/components/booking/CancellationPolicySelector.module.css +124 -0
- package/src/components/booking/CancellationPolicySelector.tsx +142 -0
- package/src/components/booking/ChangeBookingDialog.tsx +562 -0
- package/src/components/booking/CheckoutForm.module.css +244 -0
- package/src/components/booking/CheckoutForm.tsx +364 -0
- package/src/components/booking/CheckoutModal.tsx +451 -0
- package/src/components/booking/CurrencySwitcher.tsx +81 -0
- package/src/components/booking/DapFlowCollage.tsx +88 -0
- package/src/components/booking/DapTourDescription.tsx +35 -0
- package/src/components/booking/DependentAddOnBookingDialog.tsx +1350 -0
- package/src/components/booking/DependentAddOnPaymentForm.tsx +124 -0
- package/src/components/booking/ErrorBoundary.tsx +63 -0
- package/src/components/booking/InfoTooltip.tsx +108 -0
- package/src/components/booking/ItineraryBox.module.css +258 -0
- package/src/components/booking/ItineraryBox.tsx +550 -0
- package/src/components/booking/ItineraryBuilder.tsx +82 -0
- package/src/components/booking/ItineraryPlaceholder.module.css +45 -0
- package/src/components/booking/ItineraryPlaceholder.tsx +26 -0
- package/src/components/booking/MealDrinkAddOnSelector.tsx +338 -0
- package/src/components/booking/PickupLocationSelector.module.css +124 -0
- package/src/components/booking/PickupLocationSelector.tsx +1566 -0
- package/src/components/booking/PickupTimeSelector.module.css +134 -0
- package/src/components/booking/PickupTimeSelector.tsx +112 -0
- package/src/components/booking/PriceBreakdown.tsx +154 -0
- package/src/components/booking/PriceSummary.tsx +234 -0
- package/src/components/booking/PrivateShuttleBookingFlow.module.css +357 -0
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +2662 -0
- package/src/components/booking/PromoCodeInput.module.css +166 -0
- package/src/components/booking/PromoCodeInput.tsx +99 -0
- package/src/components/booking/ReturnTimeSelector.module.css +173 -0
- package/src/components/booking/ReturnTimeSelector.tsx +145 -0
- package/src/components/booking/TermsAcceptance.tsx +111 -0
- package/src/components/booking/TicketSelector.module.css +164 -0
- package/src/components/booking/TicketSelector.tsx +199 -0
- package/src/components/booking/TourDescription.module.css +304 -0
- package/src/components/booking/TourDescription.tsx +273 -0
- package/src/components/booking/booking-flow-ui.ts +38 -0
- package/src/components/booking/booking-flow.css +944 -0
- package/src/components/button.css +245 -0
- package/src/components/button.tsx +152 -0
- package/src/components/colorable-svg.tsx +29 -0
- package/src/components/image.css +29 -0
- package/src/components/image.tsx +113 -0
- package/src/components/partner/PartnerBookingPage.module.css +130 -0
- package/src/components/partner/PartnerBookingPage.tsx +390 -0
- package/src/components/partner/PartnerBookingPageWithBrowserMetadata.tsx +45 -0
- package/src/components/product-tag.module.css +30 -0
- package/src/components/product-tag.tsx +34 -0
- package/src/components/product-theme-pages/image-modal.tsx +248 -0
- package/src/components/product-theme-pages/photo-gallery.module.css +200 -0
- package/src/components/terms/TermsContent.tsx +178 -0
- package/src/components/value-pill.module.css +59 -0
- package/src/components/value-pill.tsx +46 -0
- package/src/constants/images.ts +556 -0
- package/src/constants/pill-values.ts +210 -0
- package/src/constants/products.ts +155 -0
- package/src/contexts/AvailabilitiesCacheContext.tsx +125 -0
- package/src/contexts/BookingAppContext.tsx +134 -0
- package/src/contexts/CompanyContext.tsx +70 -0
- package/src/data/dap-descriptions/session-couples-families-friends.en.json +61 -0
- package/src/data/dap-descriptions/session-elopements.en.json +60 -0
- package/src/data/dap-descriptions/session-proposals.en.json +60 -0
- package/src/data/product-descriptions/afternoon-delight.en.json +35 -0
- package/src/data/product-descriptions/emerald-lake-escape.en.json +68 -0
- package/src/data/product-descriptions/lake-louise-adventure.en.json +74 -0
- package/src/data/product-descriptions/moraine-lake-adventure.en.json +78 -0
- package/src/data/product-descriptions/moraine-lake-sunrise-lake-louise-golden-hour.en.json +65 -0
- package/src/data/product-descriptions/moraine-lake-sunrise.en.json +64 -0
- package/src/data/product-descriptions/private-tour.en.json +80 -0
- package/src/data/product-descriptions/two-lakes-combo.en.json +65 -0
- package/src/data/products-config.json +101 -0
- package/src/hooks/useBookingSourceMetadataFromLocation.ts +21 -0
- package/src/hooks/useIsBookingLaunchLive.ts +49 -0
- package/src/index.ts +79 -0
- package/src/lib/analytics.ts +197 -0
- package/src/lib/booking/booking-source.ts +51 -0
- package/src/lib/booking/checkout-breakdown.ts +69 -0
- package/src/lib/booking/correlation-id.ts +46 -0
- package/src/lib/booking/i18n/config.ts +21 -0
- package/src/lib/booking/i18n/index.tsx +144 -0
- package/src/lib/booking/i18n/messages/en.json +236 -0
- package/src/lib/booking/i18n/messages/fr.json +236 -0
- package/src/lib/booking/itinerary-display.ts +36 -0
- package/src/lib/booking/itinerary-labels.ts +70 -0
- package/src/lib/booking/location-calculations.ts +43 -0
- package/src/lib/booking/location-utils.ts +165 -0
- package/src/lib/booking/map-utils.ts +153 -0
- package/src/lib/booking/marker-icons.ts +113 -0
- package/src/lib/booking/normalize-booking-product-id.ts +21 -0
- package/src/lib/booking/pickup-location-types.ts +25 -0
- package/src/lib/booking/places-api.ts +154 -0
- package/src/lib/booking/pricing.ts +466 -0
- package/src/lib/booking/product-option-id.ts +35 -0
- package/src/lib/booking/source-metadata.ts +226 -0
- package/src/lib/booking/sunday-week.ts +14 -0
- package/src/lib/booking/theme.ts +83 -0
- package/src/lib/booking/trace-context.ts +62 -0
- package/src/lib/booking/utils.ts +9 -0
- package/src/lib/booking-api.ts +1793 -0
- package/src/lib/booking-constants.ts +23 -0
- package/src/lib/booking-ref.ts +13 -0
- package/src/lib/booking-types.ts +36 -0
- package/src/lib/currency.ts +81 -0
- package/src/lib/dap-descriptions.ts +50 -0
- package/src/lib/dap-itinerary-preview.ts +315 -0
- package/src/lib/dependent-add-on-api.ts +434 -0
- package/src/lib/env.ts +96 -0
- package/src/lib/firebase.ts +20 -0
- package/src/lib/job-application-api.ts +83 -0
- package/src/lib/manage-booking-embed-print.ts +16 -0
- package/src/lib/manage-booking-post-checkout.ts +68 -0
- package/src/lib/photo-dap-config.ts +228 -0
- package/src/lib/photo-packages.ts +75 -0
- package/src/lib/pickup/map-utils.ts +56 -0
- package/src/lib/pickup/marker-icons.ts +19 -0
- package/src/lib/product-descriptions.ts +66 -0
- package/src/lib/products-config.ts +73 -0
- package/src/providers/booking-dialog-provider.tsx +282 -0
- package/src/providers/dependent-add-on-dialog-provider.tsx +105 -0
- package/src/radius.css +5 -0
- package/src/spacing.css +7 -0
- package/src/strings/en.json +1774 -0
- package/src/strings/es.json +1573 -0
- package/src/strings/fr.json +1573 -0
- package/src/strings/index.js +23 -0
- package/src/text-style.css +56 -0
- package/src/utils/currency-converter.ts +101 -0
- package/tsconfig.json +8 -2
|
@@ -0,0 +1,1123 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useMemo, useRef, useEffect, useCallback, memo } from 'react';
|
|
4
|
+
import { createPortal } from 'react-dom';
|
|
5
|
+
import { startOfWeek, addDays, addWeeks, subWeeks, isSameDay, parseISO, startOfMonth, endOfMonth, eachDayOfInterval, addMonths, subMonths } from 'date-fns';
|
|
6
|
+
import { formatInTimeZone, fromZonedTime } from 'date-fns-tz';
|
|
7
|
+
import { enUS, fr } from 'date-fns/locale';
|
|
8
|
+
import type { Availability } from '@/lib/booking-api';
|
|
9
|
+
import { useTranslations, useLocale } from '@/lib/booking/i18n';
|
|
10
|
+
import {
|
|
11
|
+
MINI_CALENDAR_START_MONTH,
|
|
12
|
+
MINI_CALENDAR_END_MONTH,
|
|
13
|
+
VISIBLE_RANGE_BUFFER_WEEKS,
|
|
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';
|
|
18
|
+
|
|
19
|
+
// ============ Types ============
|
|
20
|
+
|
|
21
|
+
export interface DateAvailability {
|
|
22
|
+
date: string; // yyyy-MM-dd format
|
|
23
|
+
availabilityCount: number;
|
|
24
|
+
isSoldOut: boolean;
|
|
25
|
+
minPrice?: number;
|
|
26
|
+
hasDiscount?: boolean;
|
|
27
|
+
discountPercent?: number;
|
|
28
|
+
totalVacancies?: number; // Total available tickets across all availabilities for this date
|
|
29
|
+
startTimes?: string[]; // Array of start times for this date (e.g., ["09:00", "10:00", "14:00"])
|
|
30
|
+
soldOutTimes?: Set<string>; // Set of sold out time strings (e.g., ["09:00", "14:00"])
|
|
31
|
+
totalDiscountPercent?: number; // Total discount percentage from all negative adjustments (deals + dynamic pricing)
|
|
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 }>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface CalendarProps {
|
|
37
|
+
availabilitiesByDate: Record<string, Availability[]>;
|
|
38
|
+
selectedDate: string | null;
|
|
39
|
+
onDateSelect: (date: string) => void;
|
|
40
|
+
timezone: string;
|
|
41
|
+
earliestDate: Date | null;
|
|
42
|
+
onVisibleRangeChange?: (startDate: Date, endDate: Date) => void;
|
|
43
|
+
currency: string; // Currency code (e.g., "CAD", "USD") for discount calculations
|
|
44
|
+
/** When true (admin), show capacity on each time slot (vacancies unless sold out) */
|
|
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;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ============ Constants ============
|
|
63
|
+
|
|
64
|
+
const DAYS_OF_WEEK_KEYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] as const;
|
|
65
|
+
const WEEKS_TO_SHOW_DESKTOP = 2;
|
|
66
|
+
const WEEKS_TO_SHOW_MOBILE = 2;
|
|
67
|
+
|
|
68
|
+
// Date-fns locale map
|
|
69
|
+
const dateFnsLocales = {
|
|
70
|
+
en: enUS,
|
|
71
|
+
fr: fr,
|
|
72
|
+
} as const;
|
|
73
|
+
|
|
74
|
+
// ============ Helper Functions ============
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Get net discount percentage for a date from percentage-type adjustments.
|
|
78
|
+
* - Deals: always discounts (add to total)
|
|
79
|
+
* - Dynamic pricing discounts (negative change): add to total
|
|
80
|
+
* - Dynamic pricing surcharges (positive change): subtract from total
|
|
81
|
+
* Result: discounts minus surcharges. Only shown if net is positive (i.e. net discount).
|
|
82
|
+
*
|
|
83
|
+
* @param availabilities All availabilities for a specific date
|
|
84
|
+
* @param currency Currency code to check changeByCurrency for dynamic pricing
|
|
85
|
+
* @returns Net discount percentage (e.g., 15 for -15% off), or undefined if net <= 0
|
|
86
|
+
*/
|
|
87
|
+
function calculateTotalDiscountPercent(
|
|
88
|
+
availabilities: Availability[],
|
|
89
|
+
currency: string
|
|
90
|
+
): number | undefined {
|
|
91
|
+
// Get the first availability with rates
|
|
92
|
+
const avail = availabilities.find(a => a.rates && a.rates.length > 0);
|
|
93
|
+
if (!avail?.rates) return undefined;
|
|
94
|
+
|
|
95
|
+
// Get ADULT rate adjustments
|
|
96
|
+
const adultRate = avail.rates.find(rate => rate.category === 'ADULT');
|
|
97
|
+
if (!adultRate) return undefined;
|
|
98
|
+
|
|
99
|
+
const adjustments = adultRate.appliedAdjustments || adultRate.applied_adjustments || [];
|
|
100
|
+
|
|
101
|
+
let totalDiscount = 0;
|
|
102
|
+
let totalSurcharge = 0;
|
|
103
|
+
const seenIds = new Set<string>();
|
|
104
|
+
|
|
105
|
+
for (const adj of adjustments) {
|
|
106
|
+
if (seenIds.has(adj.id)) continue;
|
|
107
|
+
if (adj.adjustmentType !== 'percentage' || !adj.adjustmentValue) continue;
|
|
108
|
+
|
|
109
|
+
seenIds.add(adj.id);
|
|
110
|
+
|
|
111
|
+
if (adj.type === 'deal') {
|
|
112
|
+
// Deals are always discounts
|
|
113
|
+
totalDiscount += Math.abs(adj.adjustmentValue);
|
|
114
|
+
} else if (adj.type === 'dynamic') {
|
|
115
|
+
const change = adj.changeByCurrency?.[currency];
|
|
116
|
+
if (change !== undefined) {
|
|
117
|
+
if (change < 0) {
|
|
118
|
+
totalDiscount += Math.abs(adj.adjustmentValue);
|
|
119
|
+
} else {
|
|
120
|
+
totalSurcharge += Math.abs(adj.adjustmentValue);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const net = totalDiscount - totalSurcharge;
|
|
127
|
+
return net > 0 ? Math.round(net) : undefined;
|
|
128
|
+
}
|
|
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
|
+
|
|
144
|
+
// ============ Date Cell Component ============
|
|
145
|
+
|
|
146
|
+
interface DateCellProps {
|
|
147
|
+
date: Date;
|
|
148
|
+
dateStr: string;
|
|
149
|
+
availability: DateAvailability | null;
|
|
150
|
+
isSelected: boolean;
|
|
151
|
+
isToday: boolean;
|
|
152
|
+
showCapacity?: boolean;
|
|
153
|
+
timezone: string;
|
|
154
|
+
displayMode: 'times' | 'status';
|
|
155
|
+
onClick: () => void;
|
|
156
|
+
isMobile?: boolean;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const DateCell = memo(function DateCell({
|
|
160
|
+
date,
|
|
161
|
+
availability,
|
|
162
|
+
isSelected,
|
|
163
|
+
isToday,
|
|
164
|
+
showCapacity,
|
|
165
|
+
timezone,
|
|
166
|
+
displayMode,
|
|
167
|
+
onClick,
|
|
168
|
+
isMobile = false,
|
|
169
|
+
}: DateCellProps) {
|
|
170
|
+
const { t } = useTranslations();
|
|
171
|
+
const { locale } = useLocale();
|
|
172
|
+
const dateFnsLocale = dateFnsLocales[locale] || enUS;
|
|
173
|
+
const dayNumber = formatInTimeZone(date, timezone, 'd');
|
|
174
|
+
// When showCapacity (admin), allow selecting sold-out slots for overbooking
|
|
175
|
+
const hasAvailability = availability !== null && (!availability.isSoldOut || showCapacity);
|
|
176
|
+
const isDisabled = !hasAvailability;
|
|
177
|
+
|
|
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.
|
|
180
|
+
const formatTime = useCallback((timeStr: string) => {
|
|
181
|
+
try {
|
|
182
|
+
const [hours, minutes] = timeStr.split(':').map(Number);
|
|
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 });
|
|
189
|
+
} catch {
|
|
190
|
+
return timeStr;
|
|
191
|
+
}
|
|
192
|
+
}, [locale, timezone, dateFnsLocale]);
|
|
193
|
+
|
|
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';
|
|
201
|
+
const buttonClassName = useMemo(() => cn(
|
|
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
|
+
),
|
|
213
|
+
isDisabled
|
|
214
|
+
? styles.calendarDayCellDisabled
|
|
215
|
+
: isSelected
|
|
216
|
+
? styles.calendarDayCellSelected
|
|
217
|
+
: styles.calendarDayCellAvailable,
|
|
218
|
+
isToday && hasAvailability && !isSelected && styles.calendarDayCellToday
|
|
219
|
+
), [isDisabled, isSelected, isToday, hasAvailability, needsTallerCell, isMobile, mobileNeedsTaller, adminCapacityTallCells]);
|
|
220
|
+
|
|
221
|
+
const ariaLabel = useMemo(() => {
|
|
222
|
+
const parts = [dayNumber];
|
|
223
|
+
if (availability?.isSoldOut) parts.push(' - Sold out');
|
|
224
|
+
if (availability?.hasDiscount) parts.push(` - ${availability.discountPercent}% off`);
|
|
225
|
+
return parts.join('');
|
|
226
|
+
}, [dayNumber, availability?.isSoldOut, availability?.hasDiscount, availability?.discountPercent]);
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<button
|
|
230
|
+
onClick={onClick}
|
|
231
|
+
disabled={isDisabled}
|
|
232
|
+
className={buttonClassName}
|
|
233
|
+
aria-label={ariaLabel}
|
|
234
|
+
>
|
|
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}>
|
|
238
|
+
{dayNumber}
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
{/* Discount Tag - top-right on desktop; below day number on mobile to avoid overlap */}
|
|
242
|
+
{availability?.totalDiscountPercent && availability.totalDiscountPercent > 0 && (
|
|
243
|
+
<div className={cn(styles.calendarDiscountTag, isMobile && styles.calendarDiscountTagMobile)}>
|
|
244
|
+
<svg className="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 20 20">
|
|
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" />
|
|
246
|
+
</svg>
|
|
247
|
+
<span>-{availability.totalDiscountPercent}%</span>
|
|
248
|
+
</div>
|
|
249
|
+
)}
|
|
250
|
+
|
|
251
|
+
{/* Availability Indicators - Centered */}
|
|
252
|
+
{availability && (
|
|
253
|
+
<div className="flex flex-col items-center justify-center space-y-0 w-full px-0.5">
|
|
254
|
+
{/* Sold Out Badge - Centered */}
|
|
255
|
+
{availability.isSoldOut ? (
|
|
256
|
+
<div className={styles.calendarSoldOutBadge}>
|
|
257
|
+
{t('calendar.soldOut')}
|
|
258
|
+
</div>
|
|
259
|
+
) : hasAvailability ? (
|
|
260
|
+
<>
|
|
261
|
+
{/* Mobile: green dot + time (compact). Desktop: full time pills */}
|
|
262
|
+
{displayMode === 'status' ? (
|
|
263
|
+
<div className={styles.calendarTimePill}>
|
|
264
|
+
{t('calendar.available')}
|
|
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
|
+
)
|
|
346
|
+
) : (
|
|
347
|
+
/* Fallback: Low Availability Badge (when no start times) */
|
|
348
|
+
availability.totalVacancies !== undefined && availability.totalVacancies < 5 && (
|
|
349
|
+
<div className={styles.calendarLowAvailabilityBadge}>
|
|
350
|
+
<svg className="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 20 20">
|
|
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" />
|
|
352
|
+
</svg>
|
|
353
|
+
{t('calendar.left', { count: availability.totalVacancies ?? 0 })}
|
|
354
|
+
</div>
|
|
355
|
+
)
|
|
356
|
+
)}
|
|
357
|
+
|
|
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}>
|
|
361
|
+
-{availability.discountPercent}%
|
|
362
|
+
</div>
|
|
363
|
+
)}
|
|
364
|
+
</>
|
|
365
|
+
) : null}
|
|
366
|
+
</div>
|
|
367
|
+
)}
|
|
368
|
+
</div>
|
|
369
|
+
</button>
|
|
370
|
+
);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// ============ Main Calendar Component ============
|
|
374
|
+
|
|
375
|
+
export function Calendar({
|
|
376
|
+
availabilitiesByDate,
|
|
377
|
+
selectedDate,
|
|
378
|
+
onDateSelect,
|
|
379
|
+
timezone,
|
|
380
|
+
earliestDate,
|
|
381
|
+
onVisibleRangeChange,
|
|
382
|
+
currency,
|
|
383
|
+
showCapacity = false,
|
|
384
|
+
isLoading = false,
|
|
385
|
+
displayMode = 'times',
|
|
386
|
+
extraDiscountPercent = 0,
|
|
387
|
+
capDiscountToSelectedDate = false,
|
|
388
|
+
syncVisibleWeekToSelectedDate = false,
|
|
389
|
+
}: CalendarProps) {
|
|
390
|
+
const { t } = useTranslations();
|
|
391
|
+
const { locale } = useLocale();
|
|
392
|
+
const dateFnsLocale = dateFnsLocales[locale] || enUS;
|
|
393
|
+
|
|
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.
|
|
396
|
+
const hasInitializedRef = useRef(false);
|
|
397
|
+
const [currentStartDateStr, setCurrentStartDateStr] = useState(() => {
|
|
398
|
+
hasInitializedRef.current = true;
|
|
399
|
+
if (earliestDate) {
|
|
400
|
+
const dateStr = formatInTimeZone(earliestDate, timezone, 'yyyy-MM-dd');
|
|
401
|
+
return getSundayOfWeek(dateStr, timezone);
|
|
402
|
+
}
|
|
403
|
+
const nowStr = formatInTimeZone(new Date(), timezone, 'yyyy-MM-dd');
|
|
404
|
+
return getSundayOfWeek(nowStr, timezone);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// Only update if earliestDate changes AND we haven't initialized yet
|
|
408
|
+
useEffect(() => {
|
|
409
|
+
if (!hasInitializedRef.current && earliestDate) {
|
|
410
|
+
const dateStr = formatInTimeZone(earliestDate, timezone, 'yyyy-MM-dd');
|
|
411
|
+
setCurrentStartDateStr(getSundayOfWeek(dateStr, timezone));
|
|
412
|
+
hasInitializedRef.current = true;
|
|
413
|
+
}
|
|
414
|
+
}, [earliestDate, timezone]);
|
|
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;
|
|
426
|
+
|
|
427
|
+
// Initialize picker month to match the currently visible month in the main calendar (in company timezone)
|
|
428
|
+
const [pickerMonth, setPickerMonth] = useState(() => {
|
|
429
|
+
const [y, m] = currentStartDateStr.split('-').map(Number);
|
|
430
|
+
return dateStrToNoonInTz(`${y}-${String(m).padStart(2, '0')}-01`, timezone);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// Update picker month when main calendar navigates to keep them in sync
|
|
434
|
+
useEffect(() => {
|
|
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() &&
|
|
438
|
+
currentMonth.getTime() <= MINI_CALENDAR_END_MONTH.getTime()) {
|
|
439
|
+
setPickerMonth(currentMonth);
|
|
440
|
+
}
|
|
441
|
+
}, [currentStartDateStr, timezone]);
|
|
442
|
+
|
|
443
|
+
// Ensure pickerMonth stays within bounds
|
|
444
|
+
useEffect(() => {
|
|
445
|
+
const currentMonth = startOfMonth(pickerMonth);
|
|
446
|
+
if (currentMonth.getTime() < MINI_CALENDAR_START_MONTH.getTime()) {
|
|
447
|
+
setPickerMonth(MINI_CALENDAR_START_MONTH);
|
|
448
|
+
} else if (currentMonth.getTime() > MINI_CALENDAR_END_MONTH.getTime()) {
|
|
449
|
+
setPickerMonth(MINI_CALENDAR_END_MONTH);
|
|
450
|
+
}
|
|
451
|
+
}, [pickerMonth]);
|
|
452
|
+
const datePickerRef = useRef<HTMLDivElement>(null); // Ref for the popup
|
|
453
|
+
const datePickerTriggerRef = useRef<HTMLButtonElement>(null); // Ref for the trigger button
|
|
454
|
+
const [pickerPosition, setPickerPosition] = useState<{ top: number; left: number } | null>(null);
|
|
455
|
+
|
|
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
|
|
479
|
+
const availableDates = useMemo(() => {
|
|
480
|
+
return Object.keys(availabilitiesByDate)
|
|
481
|
+
.filter(dateStr => {
|
|
482
|
+
const availabilities = availabilitiesByDate[dateStr];
|
|
483
|
+
return availabilities && availabilities.length > 0 && !availabilities.every(avail => avail.vacancies === 0);
|
|
484
|
+
})
|
|
485
|
+
.sort()
|
|
486
|
+
.map(dateStr => dateStrToNoonInTz(dateStr, timezone));
|
|
487
|
+
}, [availabilitiesByDate, timezone]);
|
|
488
|
+
|
|
489
|
+
// Check if we can navigate months in mini calendar (hardcoded to June-October 2026)
|
|
490
|
+
const canNavigatePickerMonthBack = useMemo(() => {
|
|
491
|
+
const currentMonthTime = startOfMonth(pickerMonth).getTime();
|
|
492
|
+
const startMonthTime = MINI_CALENDAR_START_MONTH.getTime();
|
|
493
|
+
return currentMonthTime > startMonthTime;
|
|
494
|
+
}, [pickerMonth]);
|
|
495
|
+
|
|
496
|
+
const canNavigatePickerMonthForward = useMemo(() => {
|
|
497
|
+
const currentMonthTime = startOfMonth(pickerMonth).getTime();
|
|
498
|
+
const endMonthTime = MINI_CALENDAR_END_MONTH.getTime();
|
|
499
|
+
return currentMonthTime < endMonthTime;
|
|
500
|
+
}, [pickerMonth]);
|
|
501
|
+
|
|
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)
|
|
510
|
+
const miniCalendarData = useMemo(() => {
|
|
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);
|
|
523
|
+
return { days, monthStart, monthEnd };
|
|
524
|
+
}, [pickerMonth, timezone]);
|
|
525
|
+
|
|
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
|
+
|
|
533
|
+
useEffect(() => {
|
|
534
|
+
if (!isDatePickerOpen) {
|
|
535
|
+
setPickerPosition(null);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Calculate position from trigger button (fixed = viewport coords, no scroll offset)
|
|
540
|
+
if (datePickerTriggerRef.current) {
|
|
541
|
+
const rect = datePickerTriggerRef.current.getBoundingClientRect();
|
|
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 });
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
573
|
+
const target = event.target as Node;
|
|
574
|
+
// Check if click is outside both the popup and the trigger button
|
|
575
|
+
const isOutsidePopup = datePickerRef.current && !datePickerRef.current.contains(target);
|
|
576
|
+
const isOutsideTrigger = datePickerTriggerRef.current && !datePickerTriggerRef.current.contains(target);
|
|
577
|
+
|
|
578
|
+
if (isOutsidePopup && isOutsideTrigger) {
|
|
579
|
+
setIsDatePickerOpen(false);
|
|
580
|
+
setPickerPosition(null);
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
// Use a small delay to avoid immediate closure when opening
|
|
585
|
+
const timeoutId = setTimeout(() => {
|
|
586
|
+
document.addEventListener('click', handleClickOutside, true);
|
|
587
|
+
}, 100);
|
|
588
|
+
|
|
589
|
+
return () => {
|
|
590
|
+
clearTimeout(timeoutId);
|
|
591
|
+
document.removeEventListener('click', handleClickOutside, true);
|
|
592
|
+
};
|
|
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
|
+
);
|
|
600
|
+
|
|
601
|
+
// Check if we can navigate backwards/forwards
|
|
602
|
+
const canNavigateBack = useMemo(() => {
|
|
603
|
+
if (availableDates.length === 0) return false;
|
|
604
|
+
const firstAvailableDate = availableDates[0];
|
|
605
|
+
return firstAvailableDate < currentStartDate;
|
|
606
|
+
}, [availableDates, currentStartDate]);
|
|
607
|
+
|
|
608
|
+
const canNavigateForward = useMemo(() => {
|
|
609
|
+
if (availableDates.length === 0) return false;
|
|
610
|
+
const lastAvailableDate = availableDates[availableDates.length - 1];
|
|
611
|
+
const currentEndDate = addDays(currentStartDate, WEEKS_TO_SHOW * 7 - 1);
|
|
612
|
+
return lastAvailableDate > currentEndDate;
|
|
613
|
+
}, [availableDates, currentStartDate]);
|
|
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
|
+
|
|
619
|
+
const handleDateJump = useCallback((date: Date) => {
|
|
620
|
+
const dateStr = formatInTimeZone(date, timezone, 'yyyy-MM-dd');
|
|
621
|
+
const weekStartStr = getSundayOfWeek(dateStr, timezone);
|
|
622
|
+
const visibleStart = dateStrToNoonInTz(weekStartStr, timezone);
|
|
623
|
+
const visibleEnd = addDays(visibleStart, WEEKS_TO_SHOW * 7 - 1);
|
|
624
|
+
const bufferStart = subWeeks(visibleStart, VISIBLE_RANGE_BUFFER_WEEKS);
|
|
625
|
+
const bufferEnd = addWeeks(visibleEnd, VISIBLE_RANGE_BUFFER_WEEKS);
|
|
626
|
+
|
|
627
|
+
if (debounceTimeoutRef.current) {
|
|
628
|
+
clearTimeout(debounceTimeoutRef.current);
|
|
629
|
+
debounceTimeoutRef.current = null;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
lastReportedRangeRef.current = { start: bufferStart, end: bufferEnd };
|
|
633
|
+
setCurrentStartDateStr(weekStartStr);
|
|
634
|
+
if (onVisibleRangeChange) {
|
|
635
|
+
onVisibleRangeChange(bufferStart, bufferEnd);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
onDateSelect(dateStr);
|
|
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
|
+
]);
|
|
667
|
+
|
|
668
|
+
// Generate calendar days (2 weeks = 14 days)
|
|
669
|
+
const calendarDays = useMemo(() => {
|
|
670
|
+
const days: Date[] = [];
|
|
671
|
+
for (let i = 0; i < WEEKS_TO_SHOW * 7; i++) {
|
|
672
|
+
days.push(addDays(currentStartDate, i));
|
|
673
|
+
}
|
|
674
|
+
return days;
|
|
675
|
+
}, [currentStartDate]);
|
|
676
|
+
|
|
677
|
+
// Notify parent of visible date range changes (with buffer weeks before/after)
|
|
678
|
+
// When dropdown is open, expand range to include the visible month so we fetch availability for it
|
|
679
|
+
useEffect(() => {
|
|
680
|
+
if (!onVisibleRangeChange || calendarDays.length === 0) return;
|
|
681
|
+
|
|
682
|
+
const visibleStart = calendarDays[0];
|
|
683
|
+
const visibleEnd = calendarDays[calendarDays.length - 1];
|
|
684
|
+
// Add buffer weeks before and after
|
|
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
|
+
}
|
|
695
|
+
|
|
696
|
+
// Only report if range actually changed (more than a day difference)
|
|
697
|
+
const currentReported = lastReportedRangeRef.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);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
return () => {
|
|
724
|
+
if (debounceTimeoutRef.current) {
|
|
725
|
+
clearTimeout(debounceTimeoutRef.current);
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
}, [calendarDays, onVisibleRangeChange, isDatePickerOpen, pickerMonth]);
|
|
729
|
+
|
|
730
|
+
// Calculate date availability info
|
|
731
|
+
// Use plain object instead of Map so React can detect changes properly
|
|
732
|
+
const dateAvailabilityMap = useMemo(() => {
|
|
733
|
+
const map: Record<string, DateAvailability> = {};
|
|
734
|
+
|
|
735
|
+
Object.entries(availabilitiesByDate).forEach(([dateStr, availabilities]) => {
|
|
736
|
+
if (availabilities && availabilities.length > 0) {
|
|
737
|
+
// Calculate min price from ADULT prices only (for "from" price display)
|
|
738
|
+
// Priority: pricesByCategory.retailPrices (in cents) > rates.price (in dollars)
|
|
739
|
+
const adultPrices = availabilities.flatMap(avail => {
|
|
740
|
+
// First try pricesByCategory.retailPrices (preferred format, in cents)
|
|
741
|
+
if (avail.pricesByCategory?.retailPrices) {
|
|
742
|
+
const adultPrice = avail.pricesByCategory.retailPrices.find(p => p.category === 'ADULT');
|
|
743
|
+
return adultPrice ? [adultPrice.price / 100] : []; // Convert cents to dollars
|
|
744
|
+
}
|
|
745
|
+
// Fallback to rates.price (already in dollars)
|
|
746
|
+
if (avail.rates) {
|
|
747
|
+
const adultRate = avail.rates.find(rate => rate.category === 'ADULT');
|
|
748
|
+
return adultRate && adultRate.price !== undefined && adultRate.price !== null
|
|
749
|
+
? [adultRate.price]
|
|
750
|
+
: [];
|
|
751
|
+
}
|
|
752
|
+
return [];
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
const minPrice = adultPrices.length > 0 ? Math.min(...adultPrices) : undefined;
|
|
756
|
+
|
|
757
|
+
// Check if sold out (all availabilities have 0 vacancies)
|
|
758
|
+
const isSoldOut = availabilities.every(avail => avail.vacancies === 0);
|
|
759
|
+
|
|
760
|
+
// Calculate total vacancies across all availabilities for this date
|
|
761
|
+
const totalVacancies = availabilities.reduce((sum, avail) => sum + avail.vacancies, 0);
|
|
762
|
+
|
|
763
|
+
// Extract start times from availabilities and track which are sold out
|
|
764
|
+
const timeAvailabilityMap = new Map<string, { isSoldOut: boolean; vacancies: number }>();
|
|
765
|
+
const timeCapacityMap: Record<string, { booked: number; total: number }> = {};
|
|
766
|
+
|
|
767
|
+
availabilities.forEach(avail => {
|
|
768
|
+
let timeStr: string | null = null;
|
|
769
|
+
try {
|
|
770
|
+
// Parse ISO datetime and extract time portion
|
|
771
|
+
const dateTime = parseAvailabilityDateTime(avail.dateTime);
|
|
772
|
+
timeStr = formatInTimeZone(dateTime, timezone, 'HH:mm');
|
|
773
|
+
} catch {
|
|
774
|
+
const timeMatch = avail.dateTime.match(/T(\d{2}:\d{2})/);
|
|
775
|
+
if (timeMatch) timeStr = timeMatch[1];
|
|
776
|
+
}
|
|
777
|
+
if (timeStr) {
|
|
778
|
+
const existing = timeAvailabilityMap.get(timeStr);
|
|
779
|
+
if (existing) {
|
|
780
|
+
timeAvailabilityMap.set(timeStr, {
|
|
781
|
+
isSoldOut: existing.isSoldOut && avail.vacancies === 0,
|
|
782
|
+
vacancies: existing.vacancies + avail.vacancies
|
|
783
|
+
});
|
|
784
|
+
} else {
|
|
785
|
+
timeAvailabilityMap.set(timeStr, {
|
|
786
|
+
isSoldOut: avail.vacancies === 0,
|
|
787
|
+
vacancies: avail.vacancies
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
// Per-time capacity for admin: first availability for this time wins
|
|
791
|
+
const totalCapacity = avail.totalCapacity ?? 0;
|
|
792
|
+
if (totalCapacity > 0 && !timeCapacityMap[timeStr]) {
|
|
793
|
+
const booked = avail.bookedCapacity ?? (totalCapacity - avail.vacancies);
|
|
794
|
+
timeCapacityMap[timeStr] = { booked, total: totalCapacity };
|
|
795
|
+
}
|
|
796
|
+
}
|
|
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
|
+
}
|
|
808
|
+
|
|
809
|
+
const startTimes = Array.from(timeAvailabilityMap.keys()).sort();
|
|
810
|
+
const soldOutTimes = new Set(
|
|
811
|
+
Array.from(timeAvailabilityMap.entries())
|
|
812
|
+
.filter(([, info]) => info.isSoldOut)
|
|
813
|
+
.map(([time]) => time)
|
|
814
|
+
);
|
|
815
|
+
|
|
816
|
+
// Calculate total discount percentage from all negative adjustments
|
|
817
|
+
const dynamicDiscountPercent = calculateTotalDiscountPercent(availabilities, currency) ?? 0;
|
|
818
|
+
const totalDiscountPercent =
|
|
819
|
+
dynamicDiscountPercent + Math.max(0, extraDiscountPercent);
|
|
820
|
+
|
|
821
|
+
map[dateStr] = {
|
|
822
|
+
date: dateStr,
|
|
823
|
+
availabilityCount: availabilities.length,
|
|
824
|
+
isSoldOut,
|
|
825
|
+
minPrice,
|
|
826
|
+
totalVacancies,
|
|
827
|
+
startTimes: startTimes.length > 0 ? startTimes : undefined,
|
|
828
|
+
soldOutTimes: soldOutTimes.size > 0 ? soldOutTimes : undefined,
|
|
829
|
+
totalDiscountPercent: totalDiscountPercent > 0 ? totalDiscountPercent : undefined,
|
|
830
|
+
timeCapacityMap:
|
|
831
|
+
Object.keys(timeCapacityMapWithVacancies).length > 0 ? timeCapacityMapWithVacancies : undefined,
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
});
|
|
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
|
+
|
|
852
|
+
return map;
|
|
853
|
+
}, [availabilitiesByDate, timezone, currency, extraDiscountPercent, capDiscountToSelectedDate, selectedDate]);
|
|
854
|
+
|
|
855
|
+
// Get display range for header
|
|
856
|
+
const displayRange = useMemo(() => {
|
|
857
|
+
const firstDay = calendarDays[0];
|
|
858
|
+
const lastDay = calendarDays[calendarDays.length - 1];
|
|
859
|
+
return {
|
|
860
|
+
start: formatInTimeZone(firstDay, timezone, 'MMM d', { locale: dateFnsLocale }),
|
|
861
|
+
end: formatInTimeZone(lastDay, timezone, 'MMM d, yyyy', { locale: dateFnsLocale }),
|
|
862
|
+
};
|
|
863
|
+
}, [calendarDays, timezone, dateFnsLocale]);
|
|
864
|
+
|
|
865
|
+
// Navigation handlers - add/subtract weeks in company timezone
|
|
866
|
+
const handlePrevious = () => {
|
|
867
|
+
const prevSunday = addDays(currentStartDate, -WEEKS_TO_SHOW * 7);
|
|
868
|
+
setCurrentStartDateStr(formatInTimeZone(prevSunday, timezone, 'yyyy-MM-dd'));
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
const handleNext = () => {
|
|
872
|
+
const nextSunday = addDays(currentStartDate, WEEKS_TO_SHOW * 7);
|
|
873
|
+
setCurrentStartDateStr(formatInTimeZone(nextSunday, timezone, 'yyyy-MM-dd'));
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
const handleDateClick = (date: Date) => {
|
|
877
|
+
const dateStr = formatInTimeZone(date, timezone, 'yyyy-MM-dd');
|
|
878
|
+
const availability = dateAvailabilityMap[dateStr] || null;
|
|
879
|
+
|
|
880
|
+
// Allow selection when there are availabilities; when showCapacity (admin), allow sold-out for overbooking
|
|
881
|
+
if (availability && (!availability.isSoldOut || showCapacity)) {
|
|
882
|
+
onDateSelect(dateStr);
|
|
883
|
+
}
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
return (
|
|
887
|
+
<div className={styles.calendar}>
|
|
888
|
+
{/* Calendar Header with Navigation */}
|
|
889
|
+
<div className={styles.calendarHeader}>
|
|
890
|
+
<button
|
|
891
|
+
onClick={handlePrevious}
|
|
892
|
+
disabled={!canNavigateBack}
|
|
893
|
+
className={styles.calendarNav}
|
|
894
|
+
aria-label={t('calendar.previousWeeks')}
|
|
895
|
+
>
|
|
896
|
+
<svg
|
|
897
|
+
className={styles.calendarNavIcon}
|
|
898
|
+
fill="none"
|
|
899
|
+
stroke="currentColor"
|
|
900
|
+
viewBox="0 0 24 24"
|
|
901
|
+
>
|
|
902
|
+
<path
|
|
903
|
+
strokeLinecap="round"
|
|
904
|
+
strokeLinejoin="round"
|
|
905
|
+
strokeWidth={2}
|
|
906
|
+
d="M15 19l-7-7 7-7"
|
|
907
|
+
/>
|
|
908
|
+
</svg>
|
|
909
|
+
</button>
|
|
910
|
+
<div className="relative">
|
|
911
|
+
<button
|
|
912
|
+
ref={datePickerTriggerRef}
|
|
913
|
+
type="button"
|
|
914
|
+
onClick={(e) => {
|
|
915
|
+
e.stopPropagation();
|
|
916
|
+
setIsDatePickerOpen((prev) => !prev);
|
|
917
|
+
}}
|
|
918
|
+
className={styles.calendarRangeTrigger}
|
|
919
|
+
>
|
|
920
|
+
<span>{displayRange.start} - {displayRange.end}</span>
|
|
921
|
+
<svg
|
|
922
|
+
className={cn('w-3 h-3 transition-transform', isDatePickerOpen && 'rotate-180')}
|
|
923
|
+
fill="none"
|
|
924
|
+
stroke="currentColor"
|
|
925
|
+
viewBox="0 0 24 24"
|
|
926
|
+
>
|
|
927
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
928
|
+
</svg>
|
|
929
|
+
</button>
|
|
930
|
+
{isDatePickerOpen && pickerPosition && typeof document !== 'undefined' && createPortal(
|
|
931
|
+
<div
|
|
932
|
+
ref={datePickerRef}
|
|
933
|
+
className={styles.calendarDropdown}
|
|
934
|
+
style={{
|
|
935
|
+
top: `${pickerPosition.top}px`,
|
|
936
|
+
left: `${pickerPosition.left}px`,
|
|
937
|
+
}}
|
|
938
|
+
onClick={(e) => e.stopPropagation()}
|
|
939
|
+
>
|
|
940
|
+
{/* Mini Calendar Header - always visible so users can change months while loading */}
|
|
941
|
+
<div className={styles.calendarDropdownHeader}>
|
|
942
|
+
<button
|
|
943
|
+
type="button"
|
|
944
|
+
onClick={(e) => {
|
|
945
|
+
e.preventDefault();
|
|
946
|
+
e.stopPropagation();
|
|
947
|
+
if (!canNavigatePickerMonthBack) return;
|
|
948
|
+
const previousMonth = startOfMonth(subMonths(pickerMonth, 1));
|
|
949
|
+
// Clamp to June-October 2026 range
|
|
950
|
+
if (previousMonth.getTime() >= MINI_CALENDAR_START_MONTH.getTime()) {
|
|
951
|
+
setPickerMonth(previousMonth);
|
|
952
|
+
}
|
|
953
|
+
}}
|
|
954
|
+
disabled={!canNavigatePickerMonthBack}
|
|
955
|
+
className={styles.calendarDropdownNav}
|
|
956
|
+
>
|
|
957
|
+
<svg className={styles.calendarDropdownNavIcon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
958
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
959
|
+
</svg>
|
|
960
|
+
</button>
|
|
961
|
+
<div className={styles.calendarDropdownMonth}>
|
|
962
|
+
{formatInTimeZone(pickerMonth, timezone, 'MMMM yyyy', { locale: dateFnsLocale })}
|
|
963
|
+
</div>
|
|
964
|
+
<button
|
|
965
|
+
type="button"
|
|
966
|
+
onClick={(e) => {
|
|
967
|
+
e.preventDefault();
|
|
968
|
+
e.stopPropagation();
|
|
969
|
+
if (!canNavigatePickerMonthForward) return;
|
|
970
|
+
const nextMonth = startOfMonth(addMonths(pickerMonth, 1));
|
|
971
|
+
// Clamp to June-October 2026 range
|
|
972
|
+
if (nextMonth.getTime() <= MINI_CALENDAR_END_MONTH.getTime()) {
|
|
973
|
+
setPickerMonth(nextMonth);
|
|
974
|
+
}
|
|
975
|
+
}}
|
|
976
|
+
disabled={!canNavigatePickerMonthForward}
|
|
977
|
+
className={styles.calendarDropdownNav}
|
|
978
|
+
>
|
|
979
|
+
<svg className={styles.calendarDropdownNavIcon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
980
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
981
|
+
</svg>
|
|
982
|
+
</button>
|
|
983
|
+
</div>
|
|
984
|
+
|
|
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}>
|
|
994
|
+
{DAYS_OF_WEEK_KEYS.map((dayKey) => (
|
|
995
|
+
<div key={dayKey} className={styles.calendarDropdownDow}>
|
|
996
|
+
{t(`calendar.days.${dayKey}`)[0]}
|
|
997
|
+
</div>
|
|
998
|
+
))}
|
|
999
|
+
</div>
|
|
1000
|
+
|
|
1001
|
+
{/* Mini Calendar Grid */}
|
|
1002
|
+
<div className={styles.calendarDropdownDays}>
|
|
1003
|
+
{miniCalendarData.days.map((day) => {
|
|
1004
|
+
const dateStr = formatInTimeZone(day, timezone, 'yyyy-MM-dd');
|
|
1005
|
+
const dayNumber = formatInTimeZone(day, timezone, 'd');
|
|
1006
|
+
const availability = dateAvailabilityMap[dateStr] || null;
|
|
1007
|
+
const isSelected = selectedDate === dateStr;
|
|
1008
|
+
const isCurrentMonth = day >= miniCalendarData.monthStart && day <= miniCalendarData.monthEnd;
|
|
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;
|
|
1011
|
+
|
|
1012
|
+
// Only treat as available if we have actual availability data for this date
|
|
1013
|
+
const hasAvailabilityData = availability !== null;
|
|
1014
|
+
const isSoldOut = availability?.isSoldOut === true;
|
|
1015
|
+
// When showCapacity (admin), sold-out days are still selectable for overbooking
|
|
1016
|
+
const isAvailable = hasAvailabilityData && isInAllowedRange && (!isSoldOut || showCapacity);
|
|
1017
|
+
const isToday = isSameDay(day, new Date());
|
|
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
|
+
|
|
1031
|
+
return (
|
|
1032
|
+
<button
|
|
1033
|
+
key={dateStr}
|
|
1034
|
+
onClick={() => {
|
|
1035
|
+
if (isAvailable) {
|
|
1036
|
+
handleDateJump(day);
|
|
1037
|
+
setIsDatePickerOpen(false);
|
|
1038
|
+
}
|
|
1039
|
+
}}
|
|
1040
|
+
disabled={!isAvailable}
|
|
1041
|
+
className={dayClass}
|
|
1042
|
+
>
|
|
1043
|
+
{dayNumber}
|
|
1044
|
+
</button>
|
|
1045
|
+
);
|
|
1046
|
+
})}
|
|
1047
|
+
</div>
|
|
1048
|
+
</div>
|
|
1049
|
+
</div>,
|
|
1050
|
+
document.body
|
|
1051
|
+
)}
|
|
1052
|
+
</div>
|
|
1053
|
+
<button
|
|
1054
|
+
onClick={handleNext}
|
|
1055
|
+
disabled={!canNavigateForward}
|
|
1056
|
+
className={styles.calendarNav}
|
|
1057
|
+
aria-label={t('calendar.nextWeeks')}
|
|
1058
|
+
>
|
|
1059
|
+
<svg
|
|
1060
|
+
className={styles.calendarNavIcon}
|
|
1061
|
+
fill="none"
|
|
1062
|
+
stroke="currentColor"
|
|
1063
|
+
viewBox="0 0 24 24"
|
|
1064
|
+
>
|
|
1065
|
+
<path
|
|
1066
|
+
strokeLinecap="round"
|
|
1067
|
+
strokeLinejoin="round"
|
|
1068
|
+
strokeWidth={2}
|
|
1069
|
+
d="M9 5l7 7-7 7"
|
|
1070
|
+
/>
|
|
1071
|
+
</svg>
|
|
1072
|
+
</button>
|
|
1073
|
+
</div>
|
|
1074
|
+
|
|
1075
|
+
{/* Calendar Grid */}
|
|
1076
|
+
<div className={styles.calendarGrid}>
|
|
1077
|
+
<div className={styles.calendarGridInner}>
|
|
1078
|
+
{/* Day Headers */}
|
|
1079
|
+
<div className={cn(styles.calendarHeaderRow, 'calendar-header-grid')}>
|
|
1080
|
+
{DAYS_OF_WEEK_KEYS.map((dayKey) => (
|
|
1081
|
+
<div
|
|
1082
|
+
key={dayKey}
|
|
1083
|
+
className={styles.calendarHeaderCell}
|
|
1084
|
+
>
|
|
1085
|
+
{t(`calendar.days.${dayKey}`)}
|
|
1086
|
+
</div>
|
|
1087
|
+
))}
|
|
1088
|
+
</div>
|
|
1089
|
+
|
|
1090
|
+
{/* Calendar Days Grid - row height = tallest cell in row; all cells stretch to that height */}
|
|
1091
|
+
<div className={cn(styles.calendarDaysGrid, 'calendar-days-grid')}>
|
|
1092
|
+
{calendarDays.map((date) => {
|
|
1093
|
+
const dateStr = formatInTimeZone(date, timezone, 'yyyy-MM-dd');
|
|
1094
|
+
const availability = dateAvailabilityMap[dateStr] || null;
|
|
1095
|
+
const isSelected = selectedDate === dateStr;
|
|
1096
|
+
const isToday = isSameDay(date, new Date());
|
|
1097
|
+
|
|
1098
|
+
// Include availability data in key to force re-render when availability changes
|
|
1099
|
+
const availabilityKey = availability
|
|
1100
|
+
? `${availability.availabilityCount}-${availability.isSoldOut}-${availability.startTimes?.join(',') || ''}`
|
|
1101
|
+
: 'none';
|
|
1102
|
+
return (
|
|
1103
|
+
<DateCell
|
|
1104
|
+
key={`${dateStr}-${availabilityKey}`}
|
|
1105
|
+
date={date}
|
|
1106
|
+
dateStr={dateStr}
|
|
1107
|
+
availability={availability}
|
|
1108
|
+
isSelected={isSelected}
|
|
1109
|
+
isToday={isToday}
|
|
1110
|
+
showCapacity={showCapacity}
|
|
1111
|
+
timezone={timezone}
|
|
1112
|
+
displayMode={displayMode}
|
|
1113
|
+
onClick={() => handleDateClick(date)}
|
|
1114
|
+
isMobile={isMobile}
|
|
1115
|
+
/>
|
|
1116
|
+
);
|
|
1117
|
+
})}
|
|
1118
|
+
</div>
|
|
1119
|
+
</div>
|
|
1120
|
+
</div>
|
|
1121
|
+
</div>
|
|
1122
|
+
);
|
|
1123
|
+
}
|