@bailierich/booking-components 2.0.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/README.md +319 -0
- package/TENANT_DATA_INTEGRATION.md +402 -0
- package/TENANT_SETUP.md +316 -0
- package/components/BookingFlow/BookingFlow.tsx +790 -0
- package/components/BookingFlow/index.ts +5 -0
- package/components/BookingFlow/steps/AddonsSelection.tsx +118 -0
- package/components/BookingFlow/steps/Confirmation.tsx +185 -0
- package/components/BookingFlow/steps/ContactForm.tsx +292 -0
- package/components/BookingFlow/steps/CycleAwareDateSelection.tsx +277 -0
- package/components/BookingFlow/steps/DateSelection.tsx +473 -0
- package/components/BookingFlow/steps/ServiceSelection.tsx +315 -0
- package/components/BookingFlow/steps/TimeSelection.tsx +230 -0
- package/components/BookingFlow/steps/index.ts +10 -0
- package/components/BottomSheet/index.tsx +120 -0
- package/components/Forms/FormBlock.tsx +283 -0
- package/components/Forms/FormField.tsx +385 -0
- package/components/Forms/FormRenderer.tsx +216 -0
- package/components/Forms/FormValidation.ts +122 -0
- package/components/Forms/index.ts +4 -0
- package/components/HoldTimer/HoldTimer.tsx +266 -0
- package/components/HoldTimer/index.ts +2 -0
- package/components/SectionRenderer.tsx +558 -0
- package/components/Sections/About.tsx +145 -0
- package/components/Sections/BeforeAfter.tsx +81 -0
- package/components/Sections/BookingSection.tsx +76 -0
- package/components/Sections/Contact.tsx +103 -0
- package/components/Sections/FAQSection.tsx +239 -0
- package/components/Sections/FeatureContent.tsx +113 -0
- package/components/Sections/FeaturedLink.tsx +103 -0
- package/components/Sections/FixedInfoCard.tsx +189 -0
- package/components/Sections/Gallery.tsx +83 -0
- package/components/Sections/Header.tsx +78 -0
- package/components/Sections/Hero.tsx +178 -0
- package/components/Sections/ImageSection.tsx +147 -0
- package/components/Sections/InstagramFeed.tsx +38 -0
- package/components/Sections/LinkList.tsx +76 -0
- package/components/Sections/LocationMap.tsx +202 -0
- package/components/Sections/Logo.tsx +61 -0
- package/components/Sections/MinimalFooter.tsx +78 -0
- package/components/Sections/MinimalHeader.tsx +81 -0
- package/components/Sections/MinimalNavigation.tsx +63 -0
- package/components/Sections/Navbar.tsx +258 -0
- package/components/Sections/PricingTable.tsx +106 -0
- package/components/Sections/ScrollingTextDivider.tsx +138 -0
- package/components/Sections/ScrollingTextDivider.tsx.bak +138 -0
- package/components/Sections/ServicesPreview.tsx +129 -0
- package/components/Sections/SocialBar.tsx +177 -0
- package/components/Sections/Team.tsx +80 -0
- package/components/Sections/Testimonials.tsx +92 -0
- package/components/Sections/TextSection.tsx +116 -0
- package/components/Sections/VideoSection.tsx +178 -0
- package/components/Sections/index.ts +57 -0
- package/components/index.ts +21 -0
- package/dist/index-DAai7Glf.d.mts +474 -0
- package/dist/index-DAai7Glf.d.ts +474 -0
- package/dist/index.d.mts +1075 -0
- package/dist/index.d.ts +1075 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +22 -0
- package/dist/index.mjs.map +1 -0
- package/dist/styles/index.d.mts +1 -0
- package/dist/styles/index.d.ts +1 -0
- package/dist/styles/index.js +2 -0
- package/dist/styles/index.js.map +1 -0
- package/dist/styles/index.mjs +2 -0
- package/dist/styles/index.mjs.map +1 -0
- package/docs/API.md +849 -0
- package/docs/CALLBACKS.md +760 -0
- package/docs/COMPLETE_SESSION_SUMMARY.md +404 -0
- package/docs/DATA_SHAPES.md +684 -0
- package/docs/MIGRATION.md +662 -0
- package/docs/PAYMENT_INTEGRATION.md +766 -0
- package/docs/SESSION_SUMMARY.md +185 -0
- package/docs/STYLING.md +735 -0
- package/index.ts +4 -0
- package/lib/storage.ts +239 -0
- package/package.json +59 -0
- package/styles/animations.ts +210 -0
- package/styles/index.ts +1 -0
- package/tsconfig.json +32 -0
- package/tsup.config.ts +13 -0
- package/types/index.ts +369 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useMemo } from 'react';
|
|
4
|
+
import { motion } from 'framer-motion';
|
|
5
|
+
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
|
6
|
+
import type { DateSelectionSettings } from '../../../types';
|
|
7
|
+
import { createEntranceAnimation } from '../../../styles';
|
|
8
|
+
|
|
9
|
+
interface DateSelectionProps {
|
|
10
|
+
availableDates: string[];
|
|
11
|
+
selectedDate: string | null;
|
|
12
|
+
onDateSelect: (date: string) => void;
|
|
13
|
+
settings: DateSelectionSettings;
|
|
14
|
+
colors: {
|
|
15
|
+
primary: string;
|
|
16
|
+
secondary: string;
|
|
17
|
+
};
|
|
18
|
+
cyclePhases?: Record<string, string>; // Map of date -> phase ('avoid', 'caution', 'optimal', 'neutral')
|
|
19
|
+
selectedService?: any; // Selected service object with name, price, description, duration
|
|
20
|
+
addons?: any[]; // Available addons
|
|
21
|
+
selectedAddons?: string[]; // Selected addon IDs
|
|
22
|
+
onAddonsChange?: (addonIds: string[]) => void; // Callback when addons change
|
|
23
|
+
addonPlacement?: string; // NEW: 'date_selection' | 'separate_step' - controls addon visibility
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Generate mock dates for preview mode
|
|
27
|
+
function generateMockDates(): string[] {
|
|
28
|
+
const dates: string[] = [];
|
|
29
|
+
const today = new Date();
|
|
30
|
+
|
|
31
|
+
// Set to start of today to avoid timezone issues
|
|
32
|
+
today.setHours(0, 0, 0, 0);
|
|
33
|
+
|
|
34
|
+
// Generate dates for current + next 2 months, skip some to show unavailable dates
|
|
35
|
+
for (let monthOffset = 0; monthOffset < 3; monthOffset++) {
|
|
36
|
+
const month = new Date(today.getFullYear(), today.getMonth() + monthOffset, 1);
|
|
37
|
+
const daysInMonth = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate();
|
|
38
|
+
|
|
39
|
+
for (let day = 1; day <= daysInMonth; day++) {
|
|
40
|
+
const dateObj = new Date(month.getFullYear(), month.getMonth(), day);
|
|
41
|
+
|
|
42
|
+
// Skip past dates
|
|
43
|
+
if (dateObj < today) continue;
|
|
44
|
+
|
|
45
|
+
// Skip some dates randomly to show unavailable styling (skip ~40% of dates)
|
|
46
|
+
if (Math.random() > 0.6) continue;
|
|
47
|
+
|
|
48
|
+
const dateStr = `${month.getFullYear()}-${String(month.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
|
49
|
+
dates.push(dateStr);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return dates;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Cycle phase color mapping
|
|
57
|
+
const CYCLE_PHASE_COLORS: Record<string, { bg: string; text: string; label: string }> = {
|
|
58
|
+
avoid: { bg: '#FEE2E2', text: '#991B1B', label: 'Avoid' },
|
|
59
|
+
caution: { bg: '#FEF3C7', text: '#92400E', label: 'Caution' },
|
|
60
|
+
optimal: { bg: '#D1FAE5', text: '#065F46', label: 'Optimal' },
|
|
61
|
+
neutral: { bg: '#F3F4F6', text: '#4B5563', label: 'Neutral' }
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export function DateSelection({
|
|
65
|
+
availableDates,
|
|
66
|
+
selectedDate,
|
|
67
|
+
onDateSelect,
|
|
68
|
+
settings,
|
|
69
|
+
colors,
|
|
70
|
+
cyclePhases,
|
|
71
|
+
selectedService,
|
|
72
|
+
addons = [],
|
|
73
|
+
selectedAddons = [],
|
|
74
|
+
onAddonsChange,
|
|
75
|
+
addonPlacement = 'date_selection' // Default to showing on date selection
|
|
76
|
+
}: DateSelectionProps) {
|
|
77
|
+
// DEBUG: Log addon data to troubleshoot visibility
|
|
78
|
+
console.log('[DateSelection] Addon Debug:', {
|
|
79
|
+
addonsLength: addons.length,
|
|
80
|
+
hasOnAddonsChange: !!onAddonsChange,
|
|
81
|
+
addonPlacement: addonPlacement,
|
|
82
|
+
showAddons: addonPlacement !== 'separate_step' && addons.length > 0 && !!onAddonsChange,
|
|
83
|
+
addons: addons,
|
|
84
|
+
selectedService: selectedService
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const calendarView = settings.calendarView || 'month';
|
|
88
|
+
const headerText = settings.headerContent?.value || 'Select a Date';
|
|
89
|
+
|
|
90
|
+
// Detect preview mode vs live mode
|
|
91
|
+
const isPreviewMode = !availableDates || availableDates.length === 0;
|
|
92
|
+
|
|
93
|
+
// Use mock dates in preview mode, real dates in live mode
|
|
94
|
+
const effectiveDates = useMemo(() => {
|
|
95
|
+
return isPreviewMode ? generateMockDates() : availableDates;
|
|
96
|
+
}, [isPreviewMode, availableDates]);
|
|
97
|
+
|
|
98
|
+
// Determine initial month from first available date (today or later)
|
|
99
|
+
const initialMonth = useMemo(() => {
|
|
100
|
+
const today = new Date();
|
|
101
|
+
today.setHours(0, 0, 0, 0);
|
|
102
|
+
|
|
103
|
+
if (effectiveDates.length > 0) {
|
|
104
|
+
// Find first date that is today or later
|
|
105
|
+
const futureDate = effectiveDates.find(dateStr => {
|
|
106
|
+
const date = new Date(dateStr);
|
|
107
|
+
return date >= today;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (futureDate) {
|
|
111
|
+
const firstDate = new Date(futureDate);
|
|
112
|
+
return `${firstDate.getFullYear()}-${String(firstDate.getMonth() + 1).padStart(2, '0')}`;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Fallback to current month
|
|
117
|
+
return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`;
|
|
118
|
+
}, [effectiveDates]);
|
|
119
|
+
|
|
120
|
+
const [currentMonth, setCurrentMonth] = useState(initialMonth);
|
|
121
|
+
|
|
122
|
+
// Create a Set for O(1) lookup of available dates
|
|
123
|
+
const availableDatesSet = useMemo(() => new Set(effectiveDates), [effectiveDates]);
|
|
124
|
+
|
|
125
|
+
// Parse current month
|
|
126
|
+
const [year, month] = currentMonth.split('-').map(Number);
|
|
127
|
+
|
|
128
|
+
// Get month name
|
|
129
|
+
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
|
|
130
|
+
'July', 'August', 'September', 'October', 'November', 'December'];
|
|
131
|
+
const monthName = monthNames[month - 1];
|
|
132
|
+
|
|
133
|
+
// Calculate calendar grid
|
|
134
|
+
const calendarDays = useMemo(() => {
|
|
135
|
+
const firstDay = new Date(year, month - 1, 1);
|
|
136
|
+
const lastDay = new Date(year, month, 0);
|
|
137
|
+
const startingDayOfWeek = firstDay.getDay();
|
|
138
|
+
const daysInMonth = lastDay.getDate();
|
|
139
|
+
|
|
140
|
+
const days = [];
|
|
141
|
+
|
|
142
|
+
// Add empty cells for days before the first of the month
|
|
143
|
+
for (let i = 0; i < startingDayOfWeek; i++) {
|
|
144
|
+
days.push(null);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Add actual days
|
|
148
|
+
for (let day = 1; day <= daysInMonth; day++) {
|
|
149
|
+
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
|
150
|
+
const dateObj = new Date(dateStr);
|
|
151
|
+
const today = new Date();
|
|
152
|
+
today.setHours(0, 0, 0, 0);
|
|
153
|
+
|
|
154
|
+
days.push({
|
|
155
|
+
day,
|
|
156
|
+
dateStr,
|
|
157
|
+
isAvailable: availableDatesSet.has(dateStr) && dateObj >= today
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return days;
|
|
162
|
+
}, [year, month, availableDatesSet]);
|
|
163
|
+
|
|
164
|
+
// Check if we can navigate to previous/next month
|
|
165
|
+
const canGoPrevious = useMemo(() => {
|
|
166
|
+
const prevMonth = new Date(year, month - 2, 1);
|
|
167
|
+
const prevMonthStr = `${prevMonth.getFullYear()}-${String(prevMonth.getMonth() + 1).padStart(2, '0')}`;
|
|
168
|
+
return effectiveDates.some(date => date.startsWith(prevMonthStr));
|
|
169
|
+
}, [effectiveDates, year, month]);
|
|
170
|
+
|
|
171
|
+
const canGoNext = useMemo(() => {
|
|
172
|
+
const nextMonth = new Date(year, month, 1);
|
|
173
|
+
const nextMonthStr = `${nextMonth.getFullYear()}-${String(nextMonth.getMonth() + 1).padStart(2, '0')}`;
|
|
174
|
+
return effectiveDates.some(date => date.startsWith(nextMonthStr));
|
|
175
|
+
}, [effectiveDates, year, month]);
|
|
176
|
+
|
|
177
|
+
const handlePreviousMonth = () => {
|
|
178
|
+
if (!canGoPrevious) return;
|
|
179
|
+
const prevMonth = new Date(year, month - 2, 1);
|
|
180
|
+
setCurrentMonth(`${prevMonth.getFullYear()}-${String(prevMonth.getMonth() + 1).padStart(2, '0')}`);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const handleNextMonth = () => {
|
|
184
|
+
if (!canGoNext) return;
|
|
185
|
+
const nextMonth = new Date(year, month, 1);
|
|
186
|
+
setCurrentMonth(`${nextMonth.getFullYear()}-${String(nextMonth.getMonth() + 1).padStart(2, '0')}`);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const hasCycleData = cyclePhases && Object.keys(cyclePhases).length > 0;
|
|
190
|
+
|
|
191
|
+
// Calculate total price including addons
|
|
192
|
+
const calculateTotal = () => {
|
|
193
|
+
let total = selectedService?.price || 0;
|
|
194
|
+
|
|
195
|
+
if (selectedAddons.length > 0 && addons.length > 0) {
|
|
196
|
+
selectedAddons.forEach(addonId => {
|
|
197
|
+
const addon = addons.find(a => a.id === addonId);
|
|
198
|
+
if (addon) {
|
|
199
|
+
total += addon.addonPrice || addon.price || 0;
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return total;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// Handle addon toggle
|
|
208
|
+
const handleAddonToggle = (addonId: string) => {
|
|
209
|
+
if (!onAddonsChange) return;
|
|
210
|
+
|
|
211
|
+
const isSelected = selectedAddons.includes(addonId);
|
|
212
|
+
if (isSelected) {
|
|
213
|
+
onAddonsChange(selectedAddons.filter(id => id !== addonId));
|
|
214
|
+
} else {
|
|
215
|
+
onAddonsChange([...selectedAddons, addonId]);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<div className="space-y-6">
|
|
221
|
+
{/* Service Summary Card */}
|
|
222
|
+
{selectedService && (
|
|
223
|
+
<motion.div
|
|
224
|
+
{...createEntranceAnimation(0.05)}
|
|
225
|
+
className="bg-gradient-to-br from-white to-gray-50/30 rounded-xl border border-gray-200/80 p-6 shadow-sm hover:shadow-md transition-shadow"
|
|
226
|
+
>
|
|
227
|
+
<div className="flex items-start justify-between gap-4">
|
|
228
|
+
<div className="flex-1">
|
|
229
|
+
<div className="flex items-center gap-2 mb-3">
|
|
230
|
+
<h3 className="text-sm font-semibold tracking-wide uppercase" style={{ color: colors.primary }}>
|
|
231
|
+
Your Selection
|
|
232
|
+
</h3>
|
|
233
|
+
</div>
|
|
234
|
+
<div className="space-y-2">
|
|
235
|
+
<p className="font-bold text-lg text-gray-900">
|
|
236
|
+
{selectedService.name}
|
|
237
|
+
</p>
|
|
238
|
+
{selectedService.description && (
|
|
239
|
+
<p className="text-sm text-gray-600 leading-relaxed">{selectedService.description}</p>
|
|
240
|
+
)}
|
|
241
|
+
{selectedService.duration && (
|
|
242
|
+
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
|
243
|
+
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
244
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
245
|
+
</svg>
|
|
246
|
+
<span>{selectedService.duration} minutes</span>
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
<div className="text-right">
|
|
252
|
+
<div className="text-xs text-gray-500 mb-1 font-medium">Price</div>
|
|
253
|
+
<div className="text-2xl font-bold" style={{ color: colors.primary }}>
|
|
254
|
+
${selectedService.price}
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
</motion.div>
|
|
259
|
+
)}
|
|
260
|
+
|
|
261
|
+
{/* Addon Selection - Only show if placement is on date_selection (not separate_step) */}
|
|
262
|
+
{addonPlacement !== 'separate_step' && addons.length > 0 && onAddonsChange && (
|
|
263
|
+
<motion.div
|
|
264
|
+
{...createEntranceAnimation(0.1)}
|
|
265
|
+
className="bg-white rounded-xl border border-gray-200/80 p-6 shadow-sm hover:shadow-md transition-shadow"
|
|
266
|
+
>
|
|
267
|
+
<div className="flex items-center gap-2 mb-4">
|
|
268
|
+
<h3 className="text-sm font-semibold tracking-wide uppercase" style={{ color: colors.primary }}>
|
|
269
|
+
Enhance Your Experience
|
|
270
|
+
</h3>
|
|
271
|
+
</div>
|
|
272
|
+
|
|
273
|
+
<div className="space-y-3">
|
|
274
|
+
{addons.map((addon) => {
|
|
275
|
+
const isSelected = selectedAddons.includes(addon.id);
|
|
276
|
+
const addonPrice = addon.addonPrice || addon.price || 0;
|
|
277
|
+
|
|
278
|
+
return (
|
|
279
|
+
<label
|
|
280
|
+
key={addon.id}
|
|
281
|
+
className="flex items-start gap-4 p-4 rounded-xl border-2 cursor-pointer transition-all group"
|
|
282
|
+
style={{
|
|
283
|
+
borderColor: isSelected ? colors.primary : '#E5E7EB',
|
|
284
|
+
backgroundColor: isSelected ? `${colors.primary}08` : 'transparent',
|
|
285
|
+
boxShadow: isSelected ? `0 0 0 3px ${colors.primary}15` : 'none'
|
|
286
|
+
}}
|
|
287
|
+
>
|
|
288
|
+
<div className="flex items-center pt-0.5">
|
|
289
|
+
<input
|
|
290
|
+
type="checkbox"
|
|
291
|
+
checked={isSelected}
|
|
292
|
+
onChange={() => handleAddonToggle(addon.id)}
|
|
293
|
+
className="w-5 h-5 rounded border-2 border-gray-300 cursor-pointer"
|
|
294
|
+
style={{ accentColor: colors.primary }}
|
|
295
|
+
/>
|
|
296
|
+
</div>
|
|
297
|
+
<div className="flex-1 min-w-0">
|
|
298
|
+
<div className="flex items-start justify-between gap-3 mb-1">
|
|
299
|
+
<span className="font-semibold text-base text-gray-900 group-hover:text-gray-700 transition-colors">
|
|
300
|
+
{addon.name}
|
|
301
|
+
</span>
|
|
302
|
+
<span
|
|
303
|
+
className="text-base font-bold whitespace-nowrap flex-shrink-0"
|
|
304
|
+
style={{ color: colors.primary }}
|
|
305
|
+
>
|
|
306
|
+
+${addonPrice}
|
|
307
|
+
</span>
|
|
308
|
+
</div>
|
|
309
|
+
{addon.description && (
|
|
310
|
+
<p className="text-sm text-gray-600 leading-relaxed">{addon.description}</p>
|
|
311
|
+
)}
|
|
312
|
+
</div>
|
|
313
|
+
</label>
|
|
314
|
+
);
|
|
315
|
+
})}
|
|
316
|
+
</div>
|
|
317
|
+
|
|
318
|
+
{/* Updated Total */}
|
|
319
|
+
{selectedAddons.length > 0 && (
|
|
320
|
+
<div className="mt-6 pt-5 border-t-2 border-gray-100">
|
|
321
|
+
<div className="flex items-center justify-between px-2">
|
|
322
|
+
<div>
|
|
323
|
+
<div className="text-xs text-gray-500 mb-1 font-medium uppercase tracking-wide">New Total</div>
|
|
324
|
+
<div className="text-sm text-gray-600">
|
|
325
|
+
Service + {selectedAddons.length} add-on{selectedAddons.length > 1 ? 's' : ''}
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
<div
|
|
329
|
+
className="text-3xl font-bold"
|
|
330
|
+
style={{ color: colors.primary }}
|
|
331
|
+
>
|
|
332
|
+
${calculateTotal()}
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
</div>
|
|
336
|
+
)}
|
|
337
|
+
</motion.div>
|
|
338
|
+
)}
|
|
339
|
+
|
|
340
|
+
<motion.h2
|
|
341
|
+
{...createEntranceAnimation(0.15)}
|
|
342
|
+
className="text-xl sm:text-2xl font-bold text-center sm:text-left"
|
|
343
|
+
style={{ color: colors.primary }}
|
|
344
|
+
>
|
|
345
|
+
{headerText}
|
|
346
|
+
</motion.h2>
|
|
347
|
+
|
|
348
|
+
{/* Cycle Legend */}
|
|
349
|
+
{hasCycleData && (
|
|
350
|
+
<motion.div
|
|
351
|
+
{...createEntranceAnimation(0.1)}
|
|
352
|
+
className="bg-white rounded-lg border border-gray-200 px-4 py-3"
|
|
353
|
+
>
|
|
354
|
+
<div className="flex items-center justify-between flex-wrap gap-2 text-xs">
|
|
355
|
+
<span className="font-medium flex items-center" style={{ color: colors.primary }}>
|
|
356
|
+
Cycle Calendar:
|
|
357
|
+
</span>
|
|
358
|
+
<div className="flex items-center gap-3">
|
|
359
|
+
{Object.entries(CYCLE_PHASE_COLORS).map(([phase, phaseInfo]) => (
|
|
360
|
+
<div key={phase} className="flex items-center space-x-1.5">
|
|
361
|
+
<div
|
|
362
|
+
className="w-3 h-3 rounded border border-gray-300"
|
|
363
|
+
style={{ backgroundColor: phaseInfo.bg }}
|
|
364
|
+
/>
|
|
365
|
+
<span>{phaseInfo.label}</span>
|
|
366
|
+
</div>
|
|
367
|
+
))}
|
|
368
|
+
</div>
|
|
369
|
+
</div>
|
|
370
|
+
</motion.div>
|
|
371
|
+
)}
|
|
372
|
+
|
|
373
|
+
{calendarView === 'month' ? (
|
|
374
|
+
<div className="border border-gray-200 rounded-lg p-4">
|
|
375
|
+
<div className="flex items-center justify-between mb-4">
|
|
376
|
+
<button
|
|
377
|
+
className={`p-2 rounded transition-colors ${
|
|
378
|
+
canGoPrevious ? 'hover:bg-gray-100' : 'opacity-30 cursor-not-allowed'
|
|
379
|
+
}`}
|
|
380
|
+
onClick={handlePreviousMonth}
|
|
381
|
+
disabled={!canGoPrevious}
|
|
382
|
+
>
|
|
383
|
+
<ChevronLeft className="w-4 h-4" />
|
|
384
|
+
</button>
|
|
385
|
+
<p className="font-semibold">{monthName} {year}</p>
|
|
386
|
+
<button
|
|
387
|
+
className={`p-2 rounded transition-colors ${
|
|
388
|
+
canGoNext ? 'hover:bg-gray-100' : 'opacity-30 cursor-not-allowed'
|
|
389
|
+
}`}
|
|
390
|
+
onClick={handleNextMonth}
|
|
391
|
+
disabled={!canGoNext}
|
|
392
|
+
>
|
|
393
|
+
<ChevronRight className="w-4 h-4" />
|
|
394
|
+
</button>
|
|
395
|
+
</div>
|
|
396
|
+
<div className="grid grid-cols-7 gap-2">
|
|
397
|
+
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map(day => (
|
|
398
|
+
<div key={day} className="text-center text-xs font-medium py-2 opacity-60">
|
|
399
|
+
{day}
|
|
400
|
+
</div>
|
|
401
|
+
))}
|
|
402
|
+
{calendarDays.map((dayInfo, index) => {
|
|
403
|
+
if (!dayInfo) {
|
|
404
|
+
return <div key={`empty-${index}`} />;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const { day, dateStr, isAvailable } = dayInfo;
|
|
408
|
+
const isSelected = selectedDate === dateStr;
|
|
409
|
+
const cyclePhase = cyclePhases?.[dateStr];
|
|
410
|
+
const phaseColors = cyclePhase ? CYCLE_PHASE_COLORS[cyclePhase] : null;
|
|
411
|
+
|
|
412
|
+
return (
|
|
413
|
+
<button
|
|
414
|
+
key={dateStr}
|
|
415
|
+
onClick={() => isAvailable && onDateSelect(dateStr)}
|
|
416
|
+
disabled={!isAvailable}
|
|
417
|
+
className={`aspect-square rounded-lg text-sm transition-all ${
|
|
418
|
+
isSelected
|
|
419
|
+
? 'text-white shadow-lg font-semibold'
|
|
420
|
+
: isAvailable
|
|
421
|
+
? 'hover:scale-105 cursor-pointer'
|
|
422
|
+
: 'bg-gray-50 text-gray-300 cursor-not-allowed opacity-40'
|
|
423
|
+
}`}
|
|
424
|
+
style={{
|
|
425
|
+
backgroundColor: isSelected
|
|
426
|
+
? colors.primary
|
|
427
|
+
: phaseColors?.bg || undefined,
|
|
428
|
+
color: isSelected
|
|
429
|
+
? 'white'
|
|
430
|
+
: phaseColors?.text || undefined
|
|
431
|
+
}}
|
|
432
|
+
>
|
|
433
|
+
{day}
|
|
434
|
+
</button>
|
|
435
|
+
);
|
|
436
|
+
})}
|
|
437
|
+
</div>
|
|
438
|
+
</div>
|
|
439
|
+
) : (
|
|
440
|
+
<div className="grid grid-cols-5 gap-3">
|
|
441
|
+
{effectiveDates.slice(0, 5).map(date => {
|
|
442
|
+
const dateObj = new Date(date);
|
|
443
|
+
const day = dateObj.getDate();
|
|
444
|
+
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
445
|
+
const dayName = dayNames[dateObj.getDay()];
|
|
446
|
+
const isSelected = selectedDate === date;
|
|
447
|
+
|
|
448
|
+
return (
|
|
449
|
+
<button
|
|
450
|
+
key={date}
|
|
451
|
+
onClick={() => onDateSelect(date)}
|
|
452
|
+
className={`p-4 rounded-lg border-2 transition-all text-center ${
|
|
453
|
+
isSelected
|
|
454
|
+
? 'border-current text-white'
|
|
455
|
+
: 'border-gray-200 hover:border-gray-300'
|
|
456
|
+
}`}
|
|
457
|
+
style={{
|
|
458
|
+
borderColor: isSelected ? colors.primary : undefined,
|
|
459
|
+
backgroundColor: isSelected ? colors.primary : undefined
|
|
460
|
+
}}
|
|
461
|
+
>
|
|
462
|
+
<p className="text-xs mb-1 opacity-75">{dayName}</p>
|
|
463
|
+
<p className="text-2xl font-semibold">{day}</p>
|
|
464
|
+
</button>
|
|
465
|
+
);
|
|
466
|
+
})}
|
|
467
|
+
</div>
|
|
468
|
+
)}
|
|
469
|
+
</div>
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export default DateSelection;
|