@ossy/booking 1.13.0 → 1.13.2

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.
@@ -0,0 +1,739 @@
1
+ import React, { useState, useEffect, useCallback } from 'react'
2
+ import { useRouter } from '@ossy/router-react'
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Inline design tokens
6
+ // ---------------------------------------------------------------------------
7
+ const COLOR = {
8
+ bg: '#f5f5f5',
9
+ surface: '#ffffff',
10
+ surfaceHover: '#f8f8f8',
11
+ border: '#e2e2e2',
12
+ primary: '#111111',
13
+ primaryText: '#ffffff',
14
+ secondary: '#555555',
15
+ muted: '#999999',
16
+ success: '#16a34a',
17
+ successBg: '#f0fdf4',
18
+ slotAvailable: '#111111',
19
+ slotAvailableBg: '#f0fdf4',
20
+ slotSelected: '#111111',
21
+ slotSelectedBg: '#dcfce7',
22
+ error: '#dc2626',
23
+ }
24
+
25
+ const FONT = 'system-ui, -apple-system, sans-serif'
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Swedish locale helpers
29
+ // ---------------------------------------------------------------------------
30
+ const SV_MONTHS = [
31
+ 'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni',
32
+ 'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December',
33
+ ]
34
+ const SV_WEEKDAYS_SHORT = ['Sön', 'Mån', 'Tis', 'Ons', 'Tor', 'Fre', 'Lör']
35
+ const SV_WEEKDAYS_LONG = ['Söndag', 'Måndag', 'Tisdag', 'Onsdag', 'Torsdag', 'Fredag', 'Lördag']
36
+
37
+ function formatDate(isoString) {
38
+ const d = new Date(isoString)
39
+ const weekday = SV_WEEKDAYS_LONG[d.getDay()]
40
+ const day = d.getDate()
41
+ const month = SV_MONTHS[d.getMonth()]
42
+ const hours = String(d.getHours()).padStart(2, '0')
43
+ const minutes = String(d.getMinutes()).padStart(2, '0')
44
+ return `${weekday} ${day} ${month} kl. ${hours}:${minutes}`
45
+ }
46
+
47
+ function formatTime(isoString) {
48
+ const d = new Date(isoString)
49
+ return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
50
+ }
51
+
52
+ function getTimezoneLabel(isoString) {
53
+ try {
54
+ return new Intl.DateTimeFormat('sv-SE', { timeZoneName: 'short', hour: 'numeric' })
55
+ .formatToParts(new Date(isoString))
56
+ .find(p => p.type === 'timeZoneName')?.value ?? ''
57
+ } catch {
58
+ return ''
59
+ }
60
+ }
61
+
62
+ function formatSlotRange(startIso, durationMinutes) {
63
+ const start = new Date(startIso)
64
+ const end = new Date(start.getTime() + durationMinutes * 60 * 1000)
65
+ const startStr = formatTime(startIso)
66
+ const endStr = formatTime(end.toISOString())
67
+ const tz = getTimezoneLabel(startIso)
68
+ return tz ? `${startStr}–${endStr} (${tz})` : `${startStr}–${endStr}`
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Group slots by local date key "YYYY-MM-DD"
73
+ // ---------------------------------------------------------------------------
74
+ function groupSlotsByDate(slots) {
75
+ const groups = {}
76
+ for (const slot of slots) {
77
+ const d = new Date(slot.startAt)
78
+ const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
79
+ if (!groups[key]) groups[key] = []
80
+ groups[key].push(slot)
81
+ }
82
+ return groups
83
+ }
84
+
85
+ function getDaysInMonth(year, month) {
86
+ return new Date(year, month + 1, 0).getDate()
87
+ }
88
+
89
+ function getFirstDayOfMonth(year, month) {
90
+ // 0=Sun…6=Sat; we want Mon-first grid (0=Mon…6=Sun)
91
+ const raw = new Date(year, month, 1).getDay()
92
+ return (raw + 6) % 7
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Components
97
+ // ---------------------------------------------------------------------------
98
+
99
+ function Spinner() {
100
+ return (
101
+ <div
102
+ style={{
103
+ width: 24,
104
+ height: 24,
105
+ border: '3px solid #e2e2e2',
106
+ borderTopColor: '#111111',
107
+ borderRadius: '50%',
108
+ animation: 'spin 0.7s linear infinite',
109
+ margin: '0 auto',
110
+ }}
111
+ />
112
+ )
113
+ }
114
+
115
+ function MonthCalendar({ year, month, slotsGrouped, selectedDate, onSelectDate, onPrevMonth, onNextMonth }) {
116
+ const daysInMonth = getDaysInMonth(year, month)
117
+ const firstDayOffset = getFirstDayOfMonth(year, month)
118
+ const today = new Date()
119
+ const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
120
+
121
+ const cells = []
122
+ for (let i = 0; i < firstDayOffset; i++) cells.push(null)
123
+ for (let d = 1; d <= daysInMonth; d++) cells.push(d)
124
+
125
+ return (
126
+ <div>
127
+ {/* Month nav */}
128
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
129
+ <button
130
+ onClick={onPrevMonth}
131
+ style={{
132
+ background: 'none',
133
+ border: `1px solid ${COLOR.border}`,
134
+ borderRadius: 8,
135
+ padding: '6px 12px',
136
+ cursor: 'pointer',
137
+ fontFamily: FONT,
138
+ fontSize: 14,
139
+ color: COLOR.primary,
140
+ }}
141
+ >
142
+
143
+ </button>
144
+ <span style={{ fontWeight: 600, fontSize: 15, color: COLOR.primary }}>
145
+ {SV_MONTHS[month]} {year}
146
+ </span>
147
+ <button
148
+ onClick={onNextMonth}
149
+ style={{
150
+ background: 'none',
151
+ border: `1px solid ${COLOR.border}`,
152
+ borderRadius: 8,
153
+ padding: '6px 12px',
154
+ cursor: 'pointer',
155
+ fontFamily: FONT,
156
+ fontSize: 14,
157
+ color: COLOR.primary,
158
+ }}
159
+ >
160
+
161
+ </button>
162
+ </div>
163
+
164
+ {/* Weekday headers */}
165
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 4, marginBottom: 4 }}>
166
+ {SV_WEEKDAYS_SHORT.map((d, i) => (
167
+ <div
168
+ key={i}
169
+ style={{
170
+ textAlign: 'center',
171
+ fontSize: 11,
172
+ fontWeight: 600,
173
+ color: COLOR.muted,
174
+ padding: '4px 0',
175
+ }}
176
+ >
177
+ {/* Shift: Mon=index 0 → SV_WEEKDAYS_SHORT[1] */}
178
+ {SV_WEEKDAYS_SHORT[(i + 1) % 7]}
179
+ </div>
180
+ ))}
181
+ </div>
182
+
183
+ {/* Day cells */}
184
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 4 }}>
185
+ {cells.map((day, idx) => {
186
+ if (!day) return <div key={`empty-${idx}`} />
187
+
188
+ const dateKey = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
189
+ const hasSlots = !!slotsGrouped[dateKey]
190
+ const isSelected = selectedDate === dateKey
191
+ const isToday = dateKey === todayKey
192
+ const isPast = dateKey < todayKey
193
+
194
+ return (
195
+ <button
196
+ key={dateKey}
197
+ disabled={!hasSlots || isPast}
198
+ onClick={() => hasSlots && !isPast && onSelectDate(dateKey)}
199
+ style={{
200
+ border: isSelected
201
+ ? `2px solid ${COLOR.primary}`
202
+ : isToday
203
+ ? `2px solid #d1d5db`
204
+ : `1px solid transparent`,
205
+ borderRadius: 8,
206
+ padding: '8px 4px',
207
+ cursor: hasSlots && !isPast ? 'pointer' : 'default',
208
+ background: isSelected
209
+ ? COLOR.primary
210
+ : hasSlots && !isPast
211
+ ? COLOR.slotAvailableBg
212
+ : 'transparent',
213
+ color: isSelected
214
+ ? '#ffffff'
215
+ : isPast
216
+ ? COLOR.muted
217
+ : COLOR.primary,
218
+ fontFamily: FONT,
219
+ fontSize: 13,
220
+ fontWeight: isToday ? 700 : 400,
221
+ textAlign: 'center',
222
+ transition: 'background 0.1s',
223
+ position: 'relative',
224
+ lineHeight: 1.4,
225
+ }}
226
+ >
227
+ {day}
228
+ {hasSlots && !isPast && !isSelected && (
229
+ <div
230
+ style={{
231
+ position: 'absolute',
232
+ bottom: 3,
233
+ left: '50%',
234
+ transform: 'translateX(-50%)',
235
+ width: 4,
236
+ height: 4,
237
+ borderRadius: '50%',
238
+ background: COLOR.success,
239
+ }}
240
+ />
241
+ )}
242
+ </button>
243
+ )
244
+ })}
245
+ </div>
246
+ </div>
247
+ )
248
+ }
249
+
250
+ function SlotPicker({ slots, selectedSlot, onSelectSlot }) {
251
+ if (!slots || slots.length === 0) {
252
+ return (
253
+ <p style={{ color: COLOR.secondary, fontSize: 14, margin: 0 }}>
254
+ Inga tillgängliga tider för valt datum.
255
+ </p>
256
+ )
257
+ }
258
+
259
+ return (
260
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
261
+ {slots.map(slot => {
262
+ const isSelected = selectedSlot?.startAt === slot.startAt
263
+ return (
264
+ <button
265
+ key={slot.startAt}
266
+ onClick={() => onSelectSlot(slot)}
267
+ style={{
268
+ border: isSelected
269
+ ? `2px solid ${COLOR.primary}`
270
+ : `1px solid ${COLOR.border}`,
271
+ borderRadius: 8,
272
+ padding: '8px 14px',
273
+ cursor: 'pointer',
274
+ background: isSelected ? COLOR.primary : COLOR.surface,
275
+ color: isSelected ? '#ffffff' : COLOR.primary,
276
+ fontFamily: FONT,
277
+ fontSize: 14,
278
+ fontWeight: 500,
279
+ transition: 'background 0.1s, border-color 0.1s',
280
+ }}
281
+ >
282
+ {formatSlotRange(slot.startAt, slot.duration)}
283
+ </button>
284
+ )
285
+ })}
286
+ </div>
287
+ )
288
+ }
289
+
290
+ function BookingForm({ slot, consultantSlug, onSuccess }) {
291
+ const [clientName, setClientName] = useState('')
292
+ const [clientEmail, setClientEmail] = useState('')
293
+ const [clientMessage, setClientMessage] = useState('')
294
+ const [submitting, setSubmitting] = useState(false)
295
+ const [error, setError] = useState(null)
296
+
297
+ const inputStyle = {
298
+ width: '100%',
299
+ padding: '10px 12px',
300
+ border: `1px solid ${COLOR.border}`,
301
+ borderRadius: 8,
302
+ fontFamily: FONT,
303
+ fontSize: 14,
304
+ color: COLOR.primary,
305
+ background: COLOR.surface,
306
+ boxSizing: 'border-box',
307
+ outline: 'none',
308
+ marginTop: 4,
309
+ display: 'block',
310
+ }
311
+
312
+ const labelStyle = {
313
+ display: 'block',
314
+ fontSize: 13,
315
+ fontWeight: 600,
316
+ color: COLOR.primary,
317
+ marginBottom: 12,
318
+ }
319
+
320
+ const handleSubmit = async (e) => {
321
+ e.preventDefault()
322
+ if (!clientName.trim() || !clientEmail.trim()) return
323
+
324
+ setSubmitting(true)
325
+ setError(null)
326
+
327
+ try {
328
+ const res = await fetch('/actions/booking/create', {
329
+ method: 'POST',
330
+ headers: { 'Content-Type': 'application/json' },
331
+ body: JSON.stringify({
332
+ consultantId: consultantSlug,
333
+ startAt: slot.startAt,
334
+ duration: slot.duration,
335
+ clientName: clientName.trim(),
336
+ clientEmail: clientEmail.trim(),
337
+ clientMessage: clientMessage.trim(),
338
+ }),
339
+ })
340
+
341
+ if (!res.ok) {
342
+ const data = await res.json().catch(() => ({}))
343
+ throw new Error(data?.message ?? `Bokning misslyckades (${res.status})`)
344
+ }
345
+
346
+ const booking = await res.json()
347
+ onSuccess(booking)
348
+ } catch (err) {
349
+ setError(err.message)
350
+ } finally {
351
+ setSubmitting(false)
352
+ }
353
+ }
354
+
355
+ return (
356
+ <form onSubmit={handleSubmit}>
357
+ <label style={labelStyle}>
358
+ Ditt namn
359
+ <input
360
+ type="text"
361
+ required
362
+ value={clientName}
363
+ onChange={e => setClientName(e.target.value)}
364
+ placeholder="Anna Svensson"
365
+ style={inputStyle}
366
+ />
367
+ </label>
368
+
369
+ <label style={labelStyle}>
370
+ E-postadress
371
+ <input
372
+ type="email"
373
+ required
374
+ value={clientEmail}
375
+ onChange={e => setClientEmail(e.target.value)}
376
+ placeholder="anna@exempel.se"
377
+ style={inputStyle}
378
+ />
379
+ </label>
380
+
381
+ <label style={labelStyle}>
382
+ Meddelande (valfritt)
383
+ <textarea
384
+ value={clientMessage}
385
+ onChange={e => setClientMessage(e.target.value)}
386
+ placeholder="Berätta gärna vad du vill prata om..."
387
+ rows={3}
388
+ style={{ ...inputStyle, resize: 'vertical' }}
389
+ />
390
+ </label>
391
+
392
+ {error && (
393
+ <p style={{ color: COLOR.error, fontSize: 13, margin: '0 0 12px' }}>{error}</p>
394
+ )}
395
+
396
+ <button
397
+ type="submit"
398
+ disabled={submitting}
399
+ style={{
400
+ width: '100%',
401
+ padding: '12px 20px',
402
+ background: submitting ? COLOR.muted : COLOR.primary,
403
+ color: '#ffffff',
404
+ border: 'none',
405
+ borderRadius: 8,
406
+ fontFamily: FONT,
407
+ fontSize: 15,
408
+ fontWeight: 600,
409
+ cursor: submitting ? 'not-allowed' : 'pointer',
410
+ transition: 'background 0.1s',
411
+ }}
412
+ >
413
+ {submitting ? 'Skickar…' : 'Skicka förfrågan'}
414
+ </button>
415
+ </form>
416
+ )
417
+ }
418
+
419
+ function PendingScreen({ slot }) {
420
+ return (
421
+ <div style={{ textAlign: 'center', padding: '16px 0' }}>
422
+ <div
423
+ style={{
424
+ width: 56,
425
+ height: 56,
426
+ borderRadius: '50%',
427
+ background: '#fef9c3',
428
+ display: 'flex',
429
+ alignItems: 'center',
430
+ justifyContent: 'center',
431
+ margin: '0 auto 20px',
432
+ fontSize: 28,
433
+ }}
434
+ >
435
+
436
+ </div>
437
+ <h2 style={{ margin: '0 0 8px', fontSize: 20, fontWeight: 700, color: COLOR.primary }}>
438
+ Din förfrågan är skickad!
439
+ </h2>
440
+ <p style={{ margin: '0 0 24px', color: COLOR.secondary, fontSize: 14 }}>
441
+ Konsulten bekräftar inom kort. Du får ett mejl med kalenderinbjudan när bokningen är bekräftad.
442
+ </p>
443
+ <div
444
+ style={{
445
+ background: '#fefce8',
446
+ border: '1px solid #fde68a',
447
+ borderRadius: 12,
448
+ padding: '16px 20px',
449
+ textAlign: 'left',
450
+ fontSize: 14,
451
+ color: COLOR.primary,
452
+ }}
453
+ >
454
+ <p style={{ margin: '0 0 8px', fontWeight: 600 }}>Önskad tid</p>
455
+ <p style={{ margin: 0 }}>{formatDate(slot.startAt)}</p>
456
+ <p style={{ margin: '4px 0 0', color: COLOR.secondary }}>{slot.duration} minuter</p>
457
+ </div>
458
+ </div>
459
+ )
460
+ }
461
+
462
+ // ---------------------------------------------------------------------------
463
+ // Page
464
+ // ---------------------------------------------------------------------------
465
+
466
+ export const path = { sv: '/boka/:consultantSlug', en: '/book/:consultantSlug' }
467
+ export const layout = false
468
+
469
+ export default function PublicBookingPage({ consultantSlug, ...props }) {
470
+ const router = useRouter()
471
+ const slug = consultantSlug ?? router?.params?.consultantSlug
472
+
473
+ const today = new Date()
474
+ const [calYear, setCalYear] = useState(today.getFullYear())
475
+ const [calMonth, setCalMonth] = useState(today.getMonth())
476
+
477
+ const [slots, setSlots] = useState([])
478
+ const [loadingSlots, setLoadingSlots] = useState(true)
479
+ const [slotsError, setSlotsError] = useState(null)
480
+
481
+ const [selectedDate, setSelectedDate] = useState(null)
482
+ const [selectedSlot, setSelectedSlot] = useState(null)
483
+
484
+ const [step, setStep] = useState('pick-slot') // 'pick-slot' | 'fill-form' | 'confirmed'
485
+ const [confirmedBooking, setConfirmedBooking] = useState(null)
486
+
487
+ // Load slots for next 60 days
488
+ useEffect(() => {
489
+ if (!slug) return
490
+
491
+ setLoadingSlots(true)
492
+ setSlotsError(null)
493
+
494
+ const from = new Date().toISOString()
495
+ const to = new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString()
496
+
497
+ fetch(`/actions/booking/get-available-slots?consultantId=${encodeURIComponent(slug)}&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`)
498
+ .then(r => r.ok ? r.json() : Promise.reject(r))
499
+ .then(data => { setSlots(data); setLoadingSlots(false) })
500
+ .catch(() => { setSlotsError('Kunde inte ladda tillgängliga tider.'); setLoadingSlots(false) })
501
+ }, [slug])
502
+
503
+ const slotsGrouped = groupSlotsByDate(slots)
504
+
505
+ const handlePrevMonth = () => {
506
+ if (calMonth === 0) { setCalMonth(11); setCalYear(y => y - 1) }
507
+ else setCalMonth(m => m - 1)
508
+ }
509
+
510
+ const handleNextMonth = () => {
511
+ if (calMonth === 11) { setCalMonth(0); setCalYear(y => y + 1) }
512
+ else setCalMonth(m => m + 1)
513
+ }
514
+
515
+ const handleSelectDate = (dateKey) => {
516
+ setSelectedDate(dateKey)
517
+ setSelectedSlot(null)
518
+ }
519
+
520
+ const handleSelectSlot = (slot) => {
521
+ setSelectedSlot(slot)
522
+ }
523
+
524
+ const handleConfirmSlot = () => {
525
+ if (selectedSlot) setStep('fill-form')
526
+ }
527
+
528
+ const handleBookingSuccess = (booking) => {
529
+ setConfirmedBooking(booking)
530
+ setStep('confirmed')
531
+ }
532
+
533
+ const selectedDateSlots = selectedDate ? (slotsGrouped[selectedDate] ?? []) : []
534
+
535
+ return (
536
+ <>
537
+ {/* Keyframe for spinner — injected once via style tag */}
538
+ <style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
539
+
540
+ <div
541
+ style={{
542
+ minHeight: '100dvh',
543
+ background: COLOR.bg,
544
+ fontFamily: FONT,
545
+ display: 'flex',
546
+ alignItems: 'flex-start',
547
+ justifyContent: 'center',
548
+ padding: '24px 16px',
549
+ boxSizing: 'border-box',
550
+ }}
551
+ >
552
+ <div
553
+ style={{
554
+ width: '100%',
555
+ maxWidth: 720,
556
+ background: COLOR.surface,
557
+ borderRadius: 16,
558
+ boxShadow: '0 1px 4px rgba(0,0,0,0.08)',
559
+ overflow: 'hidden',
560
+ }}
561
+ >
562
+ {/* Header */}
563
+ <div
564
+ style={{
565
+ padding: '24px 28px 20px',
566
+ borderBottom: `1px solid ${COLOR.border}`,
567
+ }}
568
+ >
569
+ <h1 style={{ margin: 0, fontSize: 20, fontWeight: 700, color: COLOR.primary }}>
570
+ Boka en tid
571
+ </h1>
572
+ <p style={{ margin: '4px 0 0', fontSize: 14, color: COLOR.secondary }}>
573
+ Välj ett datum och en tid som passar dig.
574
+ </p>
575
+ </div>
576
+
577
+ {/* Body */}
578
+ <div style={{ padding: '24px 28px' }}>
579
+ {step === 'confirmed' && selectedSlot ? (
580
+ <PendingScreen slot={selectedSlot} />
581
+ ) : step === 'fill-form' && selectedSlot ? (
582
+ <div>
583
+ <button
584
+ onClick={() => setStep('pick-slot')}
585
+ style={{
586
+ background: 'none',
587
+ border: 'none',
588
+ cursor: 'pointer',
589
+ color: COLOR.secondary,
590
+ fontFamily: FONT,
591
+ fontSize: 14,
592
+ padding: 0,
593
+ marginBottom: 20,
594
+ display: 'flex',
595
+ alignItems: 'center',
596
+ gap: 4,
597
+ }}
598
+ >
599
+ ← Tillbaka
600
+ </button>
601
+
602
+ {/* Selected slot summary */}
603
+ <div
604
+ style={{
605
+ background: '#f9f9f9',
606
+ border: `1px solid ${COLOR.border}`,
607
+ borderRadius: 10,
608
+ padding: '12px 16px',
609
+ marginBottom: 24,
610
+ fontSize: 14,
611
+ color: COLOR.primary,
612
+ }}
613
+ >
614
+ <span style={{ fontWeight: 600 }}>Vald tid: </span>
615
+ {formatDate(selectedSlot.startAt)}
616
+ <span style={{ color: COLOR.secondary }}>
617
+ {' '}({selectedSlot.duration} min)
618
+ </span>
619
+ </div>
620
+
621
+ <BookingForm
622
+ slot={selectedSlot}
623
+ consultantSlug={slug}
624
+ onSuccess={handleBookingSuccess}
625
+ />
626
+ </div>
627
+ ) : (
628
+ // Step: pick-slot
629
+ <div
630
+ style={{
631
+ display: 'grid',
632
+ gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)',
633
+ gap: 32,
634
+ }}
635
+ >
636
+ {/* Left: Calendar */}
637
+ <div>
638
+ {loadingSlots ? (
639
+ <div style={{ padding: '40px 0' }}>
640
+ <Spinner />
641
+ <p style={{ textAlign: 'center', color: COLOR.secondary, fontSize: 14, marginTop: 12 }}>
642
+ Laddar tider…
643
+ </p>
644
+ </div>
645
+ ) : slotsError ? (
646
+ <p style={{ color: COLOR.error, fontSize: 14 }}>{slotsError}</p>
647
+ ) : (
648
+ <MonthCalendar
649
+ year={calYear}
650
+ month={calMonth}
651
+ slotsGrouped={slotsGrouped}
652
+ selectedDate={selectedDate}
653
+ onSelectDate={handleSelectDate}
654
+ onPrevMonth={handlePrevMonth}
655
+ onNextMonth={handleNextMonth}
656
+ />
657
+ )}
658
+ </div>
659
+
660
+ {/* Right: Time slots */}
661
+ <div>
662
+ {selectedDate ? (
663
+ <>
664
+ <h3
665
+ style={{
666
+ margin: '0 0 16px',
667
+ fontSize: 14,
668
+ fontWeight: 600,
669
+ color: COLOR.primary,
670
+ }}
671
+ >
672
+ {(() => {
673
+ const [y, m, d] = selectedDate.split('-').map(Number)
674
+ return `${d} ${SV_MONTHS[m - 1]} ${y}`
675
+ })()}
676
+ </h3>
677
+
678
+ <SlotPicker
679
+ slots={selectedDateSlots}
680
+ selectedSlot={selectedSlot}
681
+ onSelectSlot={handleSelectSlot}
682
+ />
683
+
684
+ {selectedSlot && (
685
+ <button
686
+ onClick={handleConfirmSlot}
687
+ style={{
688
+ marginTop: 20,
689
+ width: '100%',
690
+ padding: '11px 20px',
691
+ background: COLOR.primary,
692
+ color: '#ffffff',
693
+ border: 'none',
694
+ borderRadius: 8,
695
+ fontFamily: FONT,
696
+ fontSize: 14,
697
+ fontWeight: 600,
698
+ cursor: 'pointer',
699
+ }}
700
+ >
701
+ Fortsätt →
702
+ </button>
703
+ )}
704
+ </>
705
+ ) : (
706
+ <div
707
+ style={{
708
+ display: 'flex',
709
+ flexDirection: 'column',
710
+ alignItems: 'center',
711
+ justifyContent: 'center',
712
+ height: '100%',
713
+ minHeight: 180,
714
+ color: COLOR.muted,
715
+ fontSize: 14,
716
+ textAlign: 'center',
717
+ gap: 8,
718
+ }}
719
+ >
720
+ <span style={{ fontSize: 28 }}>📅</span>
721
+ Välj ett markerat datum i kalendern
722
+ </div>
723
+ )}
724
+ </div>
725
+ </div>
726
+ )}
727
+ </div>
728
+ </div>
729
+ </div>
730
+
731
+ {/* Mobile-responsive override */}
732
+ <style>{`
733
+ @media (max-width: 600px) {
734
+ .booking-grid { grid-template-columns: 1fr !important; }
735
+ }
736
+ `}</style>
737
+ </>
738
+ )
739
+ }