@ossy/booking 1.15.6 → 1.15.7

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.
@@ -1,32 +1,7 @@
1
- import React, { useState, useEffect, useCallback } from 'react'
1
+ import React, { useState, useEffect } from 'react'
2
2
  import { useRouter } from '@ossy/router-react'
3
+ import { View, Title, Text, Button, Input, Textarea, Alert } from '@ossy/design-system'
3
4
 
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
5
  const SV_MONTHS = [
31
6
  'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni',
32
7
  'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December',
@@ -34,7 +9,7 @@ const SV_MONTHS = [
34
9
  const SV_WEEKDAYS_SHORT = ['Sön', 'Mån', 'Tis', 'Ons', 'Tor', 'Fre', 'Lör']
35
10
  const SV_WEEKDAYS_LONG = ['Söndag', 'Måndag', 'Tisdag', 'Onsdag', 'Torsdag', 'Fredag', 'Lördag']
36
11
 
37
- function formatDate(isoString) {
12
+ function formatDate (isoString) {
38
13
  const d = new Date(isoString)
39
14
  const weekday = SV_WEEKDAYS_LONG[d.getDay()]
40
15
  const day = d.getDate()
@@ -44,12 +19,12 @@ function formatDate(isoString) {
44
19
  return `${weekday} ${day} ${month} kl. ${hours}:${minutes}`
45
20
  }
46
21
 
47
- function formatTime(isoString) {
22
+ function formatTime (isoString) {
48
23
  const d = new Date(isoString)
49
24
  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
50
25
  }
51
26
 
52
- function getTimezoneLabel(isoString) {
27
+ function getTimezoneLabel (isoString) {
53
28
  try {
54
29
  return new Intl.DateTimeFormat('sv-SE', { timeZoneName: 'short', hour: 'numeric' })
55
30
  .formatToParts(new Date(isoString))
@@ -59,19 +34,15 @@ function getTimezoneLabel(isoString) {
59
34
  }
60
35
  }
61
36
 
62
- function formatSlotRange(startIso, durationMinutes) {
37
+ function formatSlotRange (startIso, durationMinutes) {
63
38
  const start = new Date(startIso)
64
39
  const end = new Date(start.getTime() + durationMinutes * 60 * 1000)
65
- const startStr = formatTime(startIso)
66
- const endStr = formatTime(end.toISOString())
67
40
  const tz = getTimezoneLabel(startIso)
68
- return tz ? `${startStr}–${endStr} (${tz})` : `${startStr}–${endStr}`
41
+ const range = `${formatTime(startIso)}–${formatTime(end.toISOString())}`
42
+ return tz ? `${range} (${tz})` : range
69
43
  }
70
44
 
71
- // ---------------------------------------------------------------------------
72
- // Group slots by local date key "YYYY-MM-DD"
73
- // ---------------------------------------------------------------------------
74
- function groupSlotsByDate(slots) {
45
+ function groupSlotsByDate (slots) {
75
46
  const groups = {}
76
47
  for (const slot of slots) {
77
48
  const d = new Date(slot.startAt)
@@ -82,37 +53,16 @@ function groupSlotsByDate(slots) {
82
53
  return groups
83
54
  }
84
55
 
85
- function getDaysInMonth(year, month) {
56
+ function getDaysInMonth (year, month) {
86
57
  return new Date(year, month + 1, 0).getDate()
87
58
  }
88
59
 
89
- function getFirstDayOfMonth(year, month) {
90
- // 0=Sun…6=Sat; we want Mon-first grid (0=Mon…6=Sun)
60
+ function getFirstDayOfMonth (year, month) {
91
61
  const raw = new Date(year, month, 1).getDay()
92
62
  return (raw + 6) % 7
93
63
  }
94
64
 
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 }) {
65
+ function MonthCalendar ({ year, month, slotsGrouped, selectedDate, onSelectDate, onPrevMonth, onNextMonth }) {
116
66
  const daysInMonth = getDaysInMonth(year, month)
117
67
  const firstDayOffset = getFirstDayOfMonth(year, month)
118
68
  const today = new Date()
@@ -123,200 +73,78 @@ function MonthCalendar({ year, month, slotsGrouped, selectedDate, onSelectDate,
123
73
  for (let d = 1; d <= daysInMonth; d++) cells.push(d)
124
74
 
125
75
  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] */}
76
+ <View gap="m">
77
+ <View layout="row" justifyContent="space-between" alignItems="center">
78
+ <Button variant="neutral" onClick={onPrevMonth}>‹</Button>
79
+ <Text weight="medium">{SV_MONTHS[month]} {year}</Text>
80
+ <Button variant="neutral" onClick={onNextMonth}>›</Button>
81
+ </View>
82
+
83
+ <View layout="row" gap="xs" style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
84
+ {SV_WEEKDAYS_SHORT.map((_, i) => (
85
+ <Text key={i} variant="small" style={{ textAlign: 'center', opacity: 0.6 }}>
178
86
  {SV_WEEKDAYS_SHORT[(i + 1) % 7]}
179
- </div>
87
+ </Text>
180
88
  ))}
181
- </div>
89
+ </View>
182
90
 
183
- {/* Day cells */}
184
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 4 }}>
91
+ <View gap="xs" style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
185
92
  {cells.map((day, idx) => {
186
- if (!day) return <div key={`empty-${idx}`} />
93
+ if (!day) return <View key={`empty-${idx}`} />
187
94
 
188
95
  const dateKey = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
189
96
  const hasSlots = !!slotsGrouped[dateKey]
190
97
  const isSelected = selectedDate === dateKey
191
- const isToday = dateKey === todayKey
192
98
  const isPast = dateKey < todayKey
99
+ const disabled = !hasSlots || isPast
193
100
 
194
101
  return (
195
- <button
102
+ <Button
196
103
  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
- }}
104
+ variant={isSelected ? 'tab-active' : hasSlots && !isPast ? 'tab' : 'neutral'}
105
+ disabled={disabled}
106
+ onClick={() => !disabled && onSelectDate(dateKey)}
107
+ style={{ padding: '8px 4px', minWidth: 0 }}
226
108
  >
227
109
  {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>
110
+ </Button>
243
111
  )
244
112
  })}
245
- </div>
246
- </div>
113
+ </View>
114
+ </View>
247
115
  )
248
116
  }
249
117
 
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
- )
118
+ function SlotPicker ({ slots, selectedSlot, onSelectSlot }) {
119
+ if (!slots?.length) {
120
+ return <Text color="secondary">Inga tillgängliga tider för valt datum.</Text>
257
121
  }
258
122
 
259
123
  return (
260
- <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
124
+ <View layout="row" gap="s" style={{ flexWrap: 'wrap' }}>
261
125
  {slots.map(slot => {
262
126
  const isSelected = selectedSlot?.startAt === slot.startAt
263
127
  return (
264
- <button
128
+ <Button
265
129
  key={slot.startAt}
130
+ variant={isSelected ? 'tab-active' : 'tab'}
266
131
  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
132
  >
282
133
  {formatSlotRange(slot.startAt, slot.duration)}
283
- </button>
134
+ </Button>
284
135
  )
285
136
  })}
286
- </div>
137
+ </View>
287
138
  )
288
139
  }
289
140
 
290
- function BookingForm({ slot, consultantSlug, onSuccess }) {
141
+ function BookingForm ({ slot, consultantSlug, onSuccess }) {
291
142
  const [clientName, setClientName] = useState('')
292
143
  const [clientEmail, setClientEmail] = useState('')
293
144
  const [clientMessage, setClientMessage] = useState('')
294
145
  const [submitting, setSubmitting] = useState(false)
295
146
  const [error, setError] = useState(null)
296
147
 
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
148
  const handleSubmit = async (e) => {
321
149
  e.preventDefault()
322
150
  if (!clientName.trim() || !clientEmail.trim()) return
@@ -354,121 +182,70 @@ function BookingForm({ slot, consultantSlug, onSuccess }) {
354
182
 
355
183
  return (
356
184
  <form onSubmit={handleSubmit}>
357
- <label style={labelStyle}>
358
- Ditt namn
359
- <input
185
+ <View gap="m">
186
+ <View gap="xs">
187
+ <Text weight="medium">Ditt namn</Text>
188
+ <Input
360
189
  type="text"
361
190
  required
362
191
  value={clientName}
363
192
  onChange={e => setClientName(e.target.value)}
364
193
  placeholder="Anna Svensson"
365
- style={inputStyle}
366
194
  />
367
- </label>
195
+ </View>
368
196
 
369
- <label style={labelStyle}>
370
- E-postadress
371
- <input
197
+ <View gap="xs">
198
+ <Text weight="medium">E-postadress</Text>
199
+ <Input
372
200
  type="email"
373
201
  required
374
202
  value={clientEmail}
375
203
  onChange={e => setClientEmail(e.target.value)}
376
204
  placeholder="anna@exempel.se"
377
- style={inputStyle}
378
205
  />
379
- </label>
206
+ </View>
380
207
 
381
- <label style={labelStyle}>
382
- Meddelande (valfritt)
383
- <textarea
208
+ <View gap="xs">
209
+ <Text weight="medium">Meddelande (valfritt)</Text>
210
+ <Textarea
384
211
  value={clientMessage}
385
212
  onChange={e => setClientMessage(e.target.value)}
386
213
  placeholder="Berätta gärna vad du vill prata om..."
387
214
  rows={3}
388
- style={{ ...inputStyle, resize: 'vertical' }}
389
215
  />
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
- >
216
+ </View>
217
+
218
+ {error && <Alert variant="danger">{error}</Alert>}
219
+
220
+ <Button type="submit" variant="primary" disabled={submitting}>
413
221
  {submitting ? 'Skickar…' : 'Skicka förfrågan'}
414
- </button>
222
+ </Button>
223
+ </View>
415
224
  </form>
416
225
  )
417
226
  }
418
227
 
419
- function PendingScreen({ slot }) {
228
+ function PendingScreen ({ slot }) {
420
229
  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 }}>
230
+ <View gap="m" alignItems="center" style={{ textAlign: 'center' }}>
231
+ <Title variant="secondary">Din förfrågan är skickad!</Title>
232
+ <Text color="secondary">
441
233
  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>
234
+ </Text>
235
+ <Alert variant="warning" title="Önskad tid">
236
+ <Text>{formatDate(slot.startAt)}</Text>
237
+ <Text color="secondary">{slot.duration} minuter</Text>
238
+ </Alert>
239
+ </View>
459
240
  )
460
241
  }
461
242
 
462
- // ---------------------------------------------------------------------------
463
- // Page
464
- // ---------------------------------------------------------------------------
465
-
466
243
  export const metadata = {
467
244
  id: 'public-booking',
468
245
  path: { sv: '/boka/:consultantSlug', en: '/book/:consultantSlug' },
469
246
  }
470
247
 
471
- export default function PublicBookingPage({ consultantSlug, ...props }) {
248
+ export default function PublicBookingPage ({ consultantSlug, ...props }) {
472
249
  const router = useRouter()
473
250
  const slug = consultantSlug ?? router?.params?.consultantSlug
474
251
 
@@ -482,11 +259,8 @@ export default function PublicBookingPage({ consultantSlug, ...props }) {
482
259
 
483
260
  const [selectedDate, setSelectedDate] = useState(null)
484
261
  const [selectedSlot, setSelectedSlot] = useState(null)
262
+ const [step, setStep] = useState('pick-slot')
485
263
 
486
- const [step, setStep] = useState('pick-slot') // 'pick-slot' | 'fill-form' | 'confirmed'
487
- const [confirmedBooking, setConfirmedBooking] = useState(null)
488
-
489
- // Load slots for next 60 days
490
264
  useEffect(() => {
491
265
  if (!slug) return
492
266
 
@@ -503,6 +277,7 @@ export default function PublicBookingPage({ consultantSlug, ...props }) {
503
277
  }, [slug])
504
278
 
505
279
  const slotsGrouped = groupSlotsByDate(slots)
280
+ const selectedDateSlots = selectedDate ? (slotsGrouped[selectedDate] ?? []) : []
506
281
 
507
282
  const handlePrevMonth = () => {
508
283
  if (calMonth === 0) { setCalMonth(11); setCalYear(y => y - 1) }
@@ -514,228 +289,96 @@ export default function PublicBookingPage({ consultantSlug, ...props }) {
514
289
  else setCalMonth(m => m + 1)
515
290
  }
516
291
 
517
- const handleSelectDate = (dateKey) => {
518
- setSelectedDate(dateKey)
519
- setSelectedSlot(null)
520
- }
521
-
522
- const handleSelectSlot = (slot) => {
523
- setSelectedSlot(slot)
524
- }
525
-
526
- const handleConfirmSlot = () => {
527
- if (selectedSlot) setStep('fill-form')
528
- }
529
-
530
- const handleBookingSuccess = (booking) => {
531
- setConfirmedBooking(booking)
532
- setStep('confirmed')
533
- }
534
-
535
- const selectedDateSlots = selectedDate ? (slotsGrouped[selectedDate] ?? []) : []
536
-
537
292
  return (
538
- <>
539
- {/* Keyframe for spinner — injected once via style tag */}
540
- <style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
541
-
542
- <div
543
- style={{
544
- minHeight: '100dvh',
545
- background: COLOR.bg,
546
- fontFamily: FONT,
547
- display: 'flex',
548
- alignItems: 'flex-start',
549
- justifyContent: 'center',
550
- padding: '24px 16px',
551
- boxSizing: 'border-box',
552
- }}
553
- >
554
- <div
555
- style={{
556
- width: '100%',
557
- maxWidth: 720,
558
- background: COLOR.surface,
559
- borderRadius: 16,
560
- boxShadow: '0 1px 4px rgba(0,0,0,0.08)',
561
- overflow: 'hidden',
562
- }}
563
- >
564
- {/* Header */}
565
- <div
566
- style={{
567
- padding: '24px 28px 20px',
568
- borderBottom: `1px solid ${COLOR.border}`,
569
- }}
570
- >
571
- <h1 style={{ margin: 0, fontSize: 20, fontWeight: 700, color: COLOR.primary }}>
572
- Boka en tid
573
- </h1>
574
- <p style={{ margin: '4px 0 0', fontSize: 14, color: COLOR.secondary }}>
575
- Välj ett datum och en tid som passar dig.
576
- </p>
577
- </div>
578
-
579
- {/* Body */}
580
- <div style={{ padding: '24px 28px' }}>
581
- {step === 'confirmed' && selectedSlot ? (
582
- <PendingScreen slot={selectedSlot} />
583
- ) : step === 'fill-form' && selectedSlot ? (
584
- <div>
585
- <button
586
- onClick={() => setStep('pick-slot')}
587
- style={{
588
- background: 'none',
589
- border: 'none',
590
- cursor: 'pointer',
591
- color: COLOR.secondary,
592
- fontFamily: FONT,
593
- fontSize: 14,
594
- padding: 0,
595
- marginBottom: 20,
596
- display: 'flex',
597
- alignItems: 'center',
598
- gap: 4,
293
+ <View
294
+ surface="base"
295
+ layout="off-center-s"
296
+ inset="s"
297
+ style={{ minHeight: '100dvh' }}
298
+ >
299
+ <View inset="m" roundness="l" surface="primary" slot="content" gap="l" style={{ maxWidth: 720, width: '100%' }}>
300
+ <View gap="xs" style={{ borderBottom: '1px solid var(--separator)', paddingBottom: 'var(--space-m)' }}>
301
+ <Title variant="secondary">Boka en tid</Title>
302
+ <Text color="secondary">Välj ett datum och en tid som passar dig.</Text>
303
+ </View>
304
+
305
+ {step === 'confirmed' && selectedSlot ? (
306
+ <PendingScreen slot={selectedSlot} />
307
+ ) : step === 'fill-form' && selectedSlot ? (
308
+ <View gap="m">
309
+ <Button variant="link" onClick={() => setStep('pick-slot')}>
310
+ ← Tillbaka
311
+ </Button>
312
+
313
+ <View surface="secondary" roundness="m" inset="s">
314
+ <Text weight="medium">Vald tid: {formatDate(selectedSlot.startAt)}</Text>
315
+ <Text color="secondary">({selectedSlot.duration} min)</Text>
316
+ </View>
317
+
318
+ <BookingForm
319
+ slot={selectedSlot}
320
+ consultantSlug={slug}
321
+ onSuccess={() => setStep('confirmed')}
322
+ />
323
+ </View>
324
+ ) : (
325
+ <View gap="l" layout="row" style={{ flexWrap: 'wrap' }}>
326
+ <View style={{ flex: 1, minWidth: 260 }}>
327
+ {loadingSlots ? (
328
+ <Text color="secondary" style={{ textAlign: 'center', padding: 'var(--space-l)' }}>
329
+ Laddar tider…
330
+ </Text>
331
+ ) : slotsError ? (
332
+ <Alert variant="danger">{slotsError}</Alert>
333
+ ) : (
334
+ <MonthCalendar
335
+ year={calYear}
336
+ month={calMonth}
337
+ slotsGrouped={slotsGrouped}
338
+ selectedDate={selectedDate}
339
+ onSelectDate={(dateKey) => {
340
+ setSelectedDate(dateKey)
341
+ setSelectedSlot(null)
599
342
  }}
600
- >
601
- ← Tillbaka
602
- </button>
603
-
604
- {/* Selected slot summary */}
605
- <div
606
- style={{
607
- background: '#f9f9f9',
608
- border: `1px solid ${COLOR.border}`,
609
- borderRadius: 10,
610
- padding: '12px 16px',
611
- marginBottom: 24,
612
- fontSize: 14,
613
- color: COLOR.primary,
614
- }}
615
- >
616
- <span style={{ fontWeight: 600 }}>Vald tid: </span>
617
- {formatDate(selectedSlot.startAt)}
618
- <span style={{ color: COLOR.secondary }}>
619
- {' '}({selectedSlot.duration} min)
620
- </span>
621
- </div>
622
-
623
- <BookingForm
624
- slot={selectedSlot}
625
- consultantSlug={slug}
626
- onSuccess={handleBookingSuccess}
343
+ onPrevMonth={handlePrevMonth}
344
+ onNextMonth={handleNextMonth}
627
345
  />
628
- </div>
629
- ) : (
630
- // Step: pick-slot
631
- <div
632
- style={{
633
- display: 'grid',
634
- gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)',
635
- gap: 32,
636
- }}
637
- >
638
- {/* Left: Calendar */}
639
- <div>
640
- {loadingSlots ? (
641
- <div style={{ padding: '40px 0' }}>
642
- <Spinner />
643
- <p style={{ textAlign: 'center', color: COLOR.secondary, fontSize: 14, marginTop: 12 }}>
644
- Laddar tider…
645
- </p>
646
- </div>
647
- ) : slotsError ? (
648
- <p style={{ color: COLOR.error, fontSize: 14 }}>{slotsError}</p>
649
- ) : (
650
- <MonthCalendar
651
- year={calYear}
652
- month={calMonth}
653
- slotsGrouped={slotsGrouped}
654
- selectedDate={selectedDate}
655
- onSelectDate={handleSelectDate}
656
- onPrevMonth={handlePrevMonth}
657
- onNextMonth={handleNextMonth}
658
- />
659
- )}
660
- </div>
661
-
662
- {/* Right: Time slots */}
663
- <div>
664
- {selectedDate ? (
665
- <>
666
- <h3
667
- style={{
668
- margin: '0 0 16px',
669
- fontSize: 14,
670
- fontWeight: 600,
671
- color: COLOR.primary,
672
- }}
673
- >
674
- {(() => {
675
- const [y, m, d] = selectedDate.split('-').map(Number)
676
- return `${d} ${SV_MONTHS[m - 1]} ${y}`
677
- })()}
678
- </h3>
679
-
680
- <SlotPicker
681
- slots={selectedDateSlots}
682
- selectedSlot={selectedSlot}
683
- onSelectSlot={handleSelectSlot}
684
- />
685
-
686
- {selectedSlot && (
687
- <button
688
- onClick={handleConfirmSlot}
689
- style={{
690
- marginTop: 20,
691
- width: '100%',
692
- padding: '11px 20px',
693
- background: COLOR.primary,
694
- color: '#ffffff',
695
- border: 'none',
696
- borderRadius: 8,
697
- fontFamily: FONT,
698
- fontSize: 14,
699
- fontWeight: 600,
700
- cursor: 'pointer',
701
- }}
702
- >
703
- Fortsätt →
704
- </button>
705
- )}
706
- </>
707
- ) : (
708
- <div
709
- style={{
710
- display: 'flex',
711
- flexDirection: 'column',
712
- alignItems: 'center',
713
- justifyContent: 'center',
714
- height: '100%',
715
- minHeight: 180,
716
- color: COLOR.muted,
717
- fontSize: 14,
718
- textAlign: 'center',
719
- gap: 8,
720
- }}
721
- >
722
- <span style={{ fontSize: 28 }}>📅</span>
723
- Välj ett markerat datum i kalendern
724
- </div>
346
+ )}
347
+ </View>
348
+
349
+ <View gap="m" style={{ flex: 1, minWidth: 260 }}>
350
+ {selectedDate ? (
351
+ <>
352
+ <Title variant="tertiary">
353
+ {(() => {
354
+ const [y, m, d] = selectedDate.split('-').map(Number)
355
+ return `${d} ${SV_MONTHS[m - 1]} ${y}`
356
+ })()}
357
+ </Title>
358
+
359
+ <SlotPicker
360
+ slots={selectedDateSlots}
361
+ selectedSlot={selectedSlot}
362
+ onSelectSlot={setSelectedSlot}
363
+ />
364
+
365
+ {selectedSlot && (
366
+ <Button variant="primary" onClick={() => setStep('fill-form')}>
367
+ Fortsätt
368
+ </Button>
725
369
  )}
726
- </div>
727
- </div>
728
- )}
729
- </div>
730
- </div>
731
- </div>
732
-
733
- {/* Mobile-responsive override */}
734
- <style>{`
735
- @media (max-width: 600px) {
736
- .booking-grid { grid-template-columns: 1fr !important; }
737
- }
738
- `}</style>
739
- </>
370
+ </>
371
+ ) : (
372
+ <View gap="s" alignItems="center" justifyContent="center" inset="l" style={{ minHeight: 180 }}>
373
+ <Text color="secondary" style={{ textAlign: 'center' }}>
374
+ Välj ett markerat datum i kalendern
375
+ </Text>
376
+ </View>
377
+ )}
378
+ </View>
379
+ </View>
380
+ )}
381
+ </View>
382
+ </View>
740
383
  )
741
384
  }