@ossy/booking 1.15.6 → 1.16.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.
@@ -1,77 +1,38 @@
1
- import React, { useState, useEffect, useCallback } from 'react'
1
+ import React, { useState, useEffect, useMemo } from 'react'
2
2
  import { useRouter } from '@ossy/router-react'
3
+ import { View, Title, Text, Button, Input, Textarea, Alert, useLocale } 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
- const SV_MONTHS = [
31
- 'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni',
32
- 'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December',
5
+ const MONTH_KEYS = [
6
+ 'publicBooking.month.january', 'publicBooking.month.february', 'publicBooking.month.march',
7
+ 'publicBooking.month.april', 'publicBooking.month.may', 'publicBooking.month.june',
8
+ 'publicBooking.month.july', 'publicBooking.month.august', 'publicBooking.month.september',
9
+ 'publicBooking.month.october', 'publicBooking.month.november', 'publicBooking.month.december',
10
+ ]
11
+ const WEEKDAY_SHORT_KEYS = [
12
+ 'publicBooking.weekday.sun', 'publicBooking.weekday.mon', 'publicBooking.weekday.tue',
13
+ 'publicBooking.weekday.wed', 'publicBooking.weekday.thu', 'publicBooking.weekday.fri', 'publicBooking.weekday.sat',
33
14
  ]
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
15
 
37
- function formatDate(isoString) {
16
+ function formatDate (isoString, t, language) {
38
17
  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}`
18
+ const locale = language === 'sv' ? 'sv-SE' : 'en-GB'
19
+ return new Intl.DateTimeFormat(locale, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' }).format(d)
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) {
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) {
27
+ function formatSlotRange (startIso, durationMinutes, language) {
63
28
  const start = new Date(startIso)
64
29
  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}`
30
+ const tz = new Intl.DateTimeFormat(language === 'sv' ? 'sv-SE' : 'en-GB', { timeZoneName: 'short', hour: 'numeric' }).formatToParts(start).find(p => p.type === 'timeZoneName')?.value ?? ''
31
+ const range = `${formatTime(startIso)}–${formatTime(end.toISOString())}`
32
+ return tz ? `${range} (${tz})` : range
69
33
  }
70
34
 
71
- // ---------------------------------------------------------------------------
72
- // Group slots by local date key "YYYY-MM-DD"
73
- // ---------------------------------------------------------------------------
74
- function groupSlotsByDate(slots) {
35
+ function groupSlotsByDate (slots) {
75
36
  const groups = {}
76
37
  for (const slot of slots) {
77
38
  const d = new Date(slot.startAt)
@@ -82,660 +43,201 @@ function groupSlotsByDate(slots) {
82
43
  return groups
83
44
  }
84
45
 
85
- function getDaysInMonth(year, month) {
86
- return new Date(year, month + 1, 0).getDate()
87
- }
46
+ function getDaysInMonth (year, month) { return new Date(year, month + 1, 0).getDate() }
47
+ function getFirstDayOfMonth (year, month) { return (new Date(year, month, 1).getDay() + 6) % 7 }
88
48
 
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 }) {
49
+ function MonthCalendar ({ year, month, slotsGrouped, selectedDate, onSelectDate, onPrevMonth, onNextMonth, t }) {
116
50
  const daysInMonth = getDaysInMonth(year, month)
117
51
  const firstDayOffset = getFirstDayOfMonth(year, month)
118
52
  const today = new Date()
119
53
  const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
120
-
121
54
  const cells = []
122
55
  for (let i = 0; i < firstDayOffset; i++) cells.push(null)
123
56
  for (let d = 1; d <= daysInMonth; d++) cells.push(d)
124
57
 
125
58
  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>
59
+ <View gap="m">
60
+ <View layout="row" justifyContent="space-between" alignItems="center">
61
+ <Button variant="neutral" onClick={onPrevMonth}>‹</Button>
62
+ <Text weight="medium">{t(MONTH_KEYS[month])} {year}</Text>
63
+ <Button variant="neutral" onClick={onNextMonth}>›</Button>
64
+ </View>
65
+ <View layout="row" gap="xs" style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
66
+ {WEEKDAY_SHORT_KEYS.map((key, i) => (
67
+ <Text key={key} variant="small" style={{ textAlign: 'center', opacity: 0.6 }}>{t(key)}</Text>
180
68
  ))}
181
- </div>
182
-
183
- {/* Day cells */}
184
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 4 }}>
69
+ </View>
70
+ <View gap="xs" style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
185
71
  {cells.map((day, idx) => {
186
- if (!day) return <div key={`empty-${idx}`} />
187
-
72
+ if (!day) return <View key={`empty-${idx}`} />
188
73
  const dateKey = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
189
74
  const hasSlots = !!slotsGrouped[dateKey]
190
75
  const isSelected = selectedDate === dateKey
191
- const isToday = dateKey === todayKey
192
76
  const isPast = dateKey < todayKey
193
-
77
+ const disabled = !hasSlots || isPast
194
78
  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>
79
+ <Button key={dateKey} variant={isSelected ? 'tab-active' : hasSlots && !isPast ? 'tab' : 'neutral'} disabled={disabled}
80
+ onClick={() => !disabled && onSelectDate(dateKey)} style={{ padding: '8px 4px', minWidth: 0 }}>{day}</Button>
243
81
  )
244
82
  })}
245
- </div>
246
- </div>
83
+ </View>
84
+ </View>
247
85
  )
248
86
  }
249
87
 
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
-
88
+ function SlotPicker ({ slots, selectedSlot, onSelectSlot, t, language }) {
89
+ if (!slots?.length) return <Text color="secondary">{t('publicBooking.noSlots')}</Text>
259
90
  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>
91
+ <View layout="row" gap="s" style={{ flexWrap: 'wrap' }}>
92
+ {slots.map(slot => (
93
+ <Button key={slot.startAt} variant={selectedSlot?.startAt === slot.startAt ? 'tab-active' : 'tab'} onClick={() => onSelectSlot(slot)}>
94
+ {formatSlotRange(slot.startAt, slot.duration, language)}
95
+ </Button>
96
+ ))}
97
+ </View>
287
98
  )
288
99
  }
289
100
 
290
- function BookingForm({ slot, consultantSlug, onSuccess }) {
101
+ function BookingForm ({ slot, consultantSlug, onSuccess, t }) {
291
102
  const [clientName, setClientName] = useState('')
292
103
  const [clientEmail, setClientEmail] = useState('')
293
104
  const [clientMessage, setClientMessage] = useState('')
294
105
  const [submitting, setSubmitting] = useState(false)
295
106
  const [error, setError] = useState(null)
296
107
 
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
108
  const handleSubmit = async (e) => {
321
109
  e.preventDefault()
322
110
  if (!clientName.trim() || !clientEmail.trim()) return
323
-
324
- setSubmitting(true)
325
- setError(null)
326
-
111
+ setSubmitting(true); setError(null)
327
112
  try {
328
113
  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
- }),
114
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
115
+ body: JSON.stringify({ consultantId: consultantSlug, startAt: slot.startAt, duration: slot.duration, clientName: clientName.trim(), clientEmail: clientEmail.trim(), clientMessage: clientMessage.trim() }),
339
116
  })
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
- }
117
+ if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data?.message ?? t('publicBooking.form.error', { status: res.status })) }
118
+ await res.json(); onSuccess()
119
+ } catch (err) { setError(err.message) } finally { setSubmitting(false) }
353
120
  }
354
121
 
355
122
  return (
356
123
  <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>
124
+ <View gap="m">
125
+ <View gap="xs">
126
+ <Text weight="medium">{t('publicBooking.form.name')}</Text>
127
+ <Input type="text" required value={clientName} onChange={e => setClientName(e.target.value)} placeholder={t('publicBooking.form.namePlaceholder')} />
128
+ </View>
129
+ <View gap="xs">
130
+ <Text weight="medium">{t('publicBooking.form.email')}</Text>
131
+ <Input type="email" required value={clientEmail} onChange={e => setClientEmail(e.target.value)} placeholder={t('publicBooking.form.emailPlaceholder')} />
132
+ </View>
133
+ <View gap="xs">
134
+ <Text weight="medium">{t('publicBooking.form.message')}</Text>
135
+ <Textarea value={clientMessage} onChange={e => setClientMessage(e.target.value)} placeholder={t('publicBooking.form.messagePlaceholder')} rows={3} />
136
+ </View>
137
+ {error && <Alert variant="danger">{error}</Alert>}
138
+ <Button type="submit" variant="primary" disabled={submitting}>{submitting ? t('publicBooking.form.submitting') : t('publicBooking.form.submit')}</Button>
139
+ </View>
415
140
  </form>
416
141
  )
417
142
  }
418
143
 
419
- function PendingScreen({ slot }) {
144
+ function PendingScreen ({ slot, t, language }) {
420
145
  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>
146
+ <View gap="m" alignItems="center" style={{ textAlign: 'center' }}>
147
+ <Title variant="secondary">{t('publicBooking.pending.title')}</Title>
148
+ <Text color="secondary">{t('publicBooking.pending.body')}</Text>
149
+ <Alert variant="warning" title={t('publicBooking.pending.requestedTime')}>
150
+ <Text>{formatDate(slot.startAt, t, language)}</Text>
151
+ <Text color="secondary">{t('publicBooking.pending.duration', { minutes: slot.duration })}</Text>
152
+ </Alert>
153
+ </View>
459
154
  )
460
155
  }
461
156
 
462
- // ---------------------------------------------------------------------------
463
- // Page
464
- // ---------------------------------------------------------------------------
465
-
466
157
  export const metadata = {
467
158
  id: 'public-booking',
468
159
  path: { sv: '/boka/:consultantSlug', en: '/book/:consultantSlug' },
469
160
  }
470
161
 
471
- export default function PublicBookingPage({ consultantSlug, ...props }) {
162
+ export default function PublicBookingPage ({ consultantSlug }) {
163
+ const { t, language } = useLocale()
472
164
  const router = useRouter()
473
165
  const slug = consultantSlug ?? router?.params?.consultantSlug
474
-
475
166
  const today = new Date()
476
167
  const [calYear, setCalYear] = useState(today.getFullYear())
477
168
  const [calMonth, setCalMonth] = useState(today.getMonth())
478
-
479
169
  const [slots, setSlots] = useState([])
480
170
  const [loadingSlots, setLoadingSlots] = useState(true)
481
171
  const [slotsError, setSlotsError] = useState(null)
482
-
483
172
  const [selectedDate, setSelectedDate] = useState(null)
484
173
  const [selectedSlot, setSelectedSlot] = useState(null)
174
+ const [step, setStep] = useState('pick-slot')
485
175
 
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
176
  useEffect(() => {
491
177
  if (!slug) return
492
-
493
- setLoadingSlots(true)
494
- setSlotsError(null)
495
-
178
+ setLoadingSlots(true); setSlotsError(null)
496
179
  const from = new Date().toISOString()
497
180
  const to = new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString()
498
-
499
181
  fetch(`/actions/booking/get-available-slots?consultantId=${encodeURIComponent(slug)}&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`)
500
182
  .then(r => r.ok ? r.json() : Promise.reject(r))
501
183
  .then(data => { setSlots(data); setLoadingSlots(false) })
502
- .catch(() => { setSlotsError('Kunde inte ladda tillgängliga tider.'); setLoadingSlots(false) })
503
- }, [slug])
184
+ .catch(() => { setSlotsError(t('publicBooking.slotsError')); setLoadingSlots(false) })
185
+ }, [slug, t])
504
186
 
505
187
  const slotsGrouped = groupSlotsByDate(slots)
506
-
507
- const handlePrevMonth = () => {
508
- if (calMonth === 0) { setCalMonth(11); setCalYear(y => y - 1) }
509
- else setCalMonth(m => m - 1)
510
- }
511
-
512
- const handleNextMonth = () => {
513
- if (calMonth === 11) { setCalMonth(0); setCalYear(y => y + 1) }
514
- else setCalMonth(m => m + 1)
515
- }
516
-
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
188
  const selectedDateSlots = selectedDate ? (slotsGrouped[selectedDate] ?? []) : []
536
189
 
537
190
  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,
599
- }}
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}
627
- />
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>
725
- )}
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
- </>
191
+ <View surface="base" layout="off-center-s" inset="s" style={{ minHeight: '100dvh' }}>
192
+ <View inset="m" roundness="l" surface="primary" slot="content" gap="l" style={{ maxWidth: 720, width: '100%' }}>
193
+ <View gap="xs" style={{ borderBottom: '1px solid var(--separator)', paddingBottom: 'var(--space-m)' }}>
194
+ <Title variant="secondary">{t('publicBooking.title')}</Title>
195
+ <Text color="secondary">{t('publicBooking.description')}</Text>
196
+ </View>
197
+ {step === 'confirmed' && selectedSlot ? (
198
+ <PendingScreen slot={selectedSlot} t={t} language={language} />
199
+ ) : step === 'fill-form' && selectedSlot ? (
200
+ <View gap="m">
201
+ <Button variant="link" onClick={() => setStep('pick-slot')}>{t('publicBooking.back')}</Button>
202
+ <View surface="secondary" roundness="m" inset="s">
203
+ <Text weight="medium">{t('publicBooking.selectedTime', { datetime: formatDate(selectedSlot.startAt, t, language) })}</Text>
204
+ <Text color="secondary">{t('publicBooking.durationMin', { minutes: selectedSlot.duration })}</Text>
205
+ </View>
206
+ <BookingForm slot={selectedSlot} consultantSlug={slug} onSuccess={() => setStep('confirmed')} t={t} />
207
+ </View>
208
+ ) : (
209
+ <View gap="l" layout="row" style={{ flexWrap: 'wrap' }}>
210
+ <View style={{ flex: 1, minWidth: 260 }}>
211
+ {loadingSlots ? (
212
+ <Text color="secondary" style={{ textAlign: 'center', padding: 'var(--space-l)' }}>{t('publicBooking.loadingSlots')}</Text>
213
+ ) : slotsError ? (
214
+ <Alert variant="danger">{slotsError}</Alert>
215
+ ) : (
216
+ <MonthCalendar year={calYear} month={calMonth} slotsGrouped={slotsGrouped} selectedDate={selectedDate}
217
+ onSelectDate={(dateKey) => { setSelectedDate(dateKey); setSelectedSlot(null) }}
218
+ onPrevMonth={() => { if (calMonth === 0) { setCalMonth(11); setCalYear(y => y - 1) } else setCalMonth(m => m - 1) }}
219
+ onNextMonth={() => { if (calMonth === 11) { setCalMonth(0); setCalYear(y => y + 1) } else setCalMonth(m => m + 1) }}
220
+ t={t} />
221
+ )}
222
+ </View>
223
+ <View gap="m" style={{ flex: 1, minWidth: 260 }}>
224
+ {selectedDate ? (
225
+ <>
226
+ <Title variant="tertiary">
227
+ {(() => { const [y, m, d] = selectedDate.split('-').map(Number); return `${d} ${t(MONTH_KEYS[m - 1])} ${y}` })()}
228
+ </Title>
229
+ <SlotPicker slots={selectedDateSlots} selectedSlot={selectedSlot} onSelectSlot={setSelectedSlot} t={t} language={language} />
230
+ {selectedSlot && <Button variant="primary" onClick={() => setStep('fill-form')}>{t('publicBooking.continue')}</Button>}
231
+ </>
232
+ ) : (
233
+ <View gap="s" alignItems="center" justifyContent="center" inset="l" style={{ minHeight: 180 }}>
234
+ <Text color="secondary" style={{ textAlign: 'center' }}>{t('publicBooking.pickDate')}</Text>
235
+ </View>
236
+ )}
237
+ </View>
238
+ </View>
239
+ )}
240
+ </View>
241
+ </View>
740
242
  )
741
243
  }