@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.
- package/package.json +9 -2
- package/src/availability-engine.js +118 -0
- package/src/availability-engine.spec.js +291 -0
- package/src/availability.resource.js +6 -0
- package/src/booking-cancellation.email.jsx +47 -0
- package/src/booking-confirmation.email.jsx +107 -0
- package/src/booking-reminder.email.jsx +69 -0
- package/src/booking-request.email.jsx +96 -0
- package/src/cancel-booking.action.js +61 -0
- package/src/confirm-booking.action.js +82 -0
- package/src/create-booking.action.js +156 -0
- package/src/decline-booking.action.js +68 -0
- package/src/get-available-slots.action.js +73 -0
- package/src/index.js +2 -0
- package/src/public-booking.page.jsx +739 -0
- package/src/send-booking-reminder.task.js +76 -0
|
@@ -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
|
+
}
|