@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.
- package/README.md +12 -0
- package/package.json +7 -7
- package/src/AvailabilityEditor.jsx +10 -6
- package/src/BookingForm.jsx +5 -5
- package/src/HeroCover.jsx +99 -0
- package/src/SalesSection.jsx +72 -0
- package/src/availability-setup.page.jsx +123 -340
- package/src/booking-card.component.jsx +67 -197
- package/src/booking-detail.page.jsx +102 -428
- package/src/bookings.page.jsx +36 -180
- package/src/cover.js +8 -0
- package/src/features.js +27 -0
- package/src/home.page.jsx +4 -364
- package/src/public-booking.page.jsx +163 -520
- package/src/service-card-slot.component.jsx +48 -0
- package/src/service-detail.component.jsx +38 -0
|
@@ -1,32 +1,7 @@
|
|
|
1
|
-
import React, { useState, useEffect
|
|
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
|
-
|
|
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
|
-
<
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
<
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
</
|
|
87
|
+
</Text>
|
|
180
88
|
))}
|
|
181
|
-
</
|
|
89
|
+
</View>
|
|
182
90
|
|
|
183
|
-
{
|
|
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 <
|
|
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
|
-
<
|
|
102
|
+
<Button
|
|
196
103
|
key={dateKey}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
246
|
-
</
|
|
113
|
+
</View>
|
|
114
|
+
</View>
|
|
247
115
|
)
|
|
248
116
|
}
|
|
249
117
|
|
|
250
|
-
function SlotPicker({ slots, selectedSlot, onSelectSlot }) {
|
|
251
|
-
if (!slots
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
</
|
|
134
|
+
</Button>
|
|
284
135
|
)
|
|
285
136
|
})}
|
|
286
|
-
</
|
|
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
|
-
<
|
|
358
|
-
|
|
359
|
-
<
|
|
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
|
-
</
|
|
195
|
+
</View>
|
|
368
196
|
|
|
369
|
-
<
|
|
370
|
-
E-postadress
|
|
371
|
-
<
|
|
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
|
-
</
|
|
206
|
+
</View>
|
|
380
207
|
|
|
381
|
-
<
|
|
382
|
-
Meddelande (valfritt)
|
|
383
|
-
<
|
|
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
|
-
</
|
|
391
|
-
|
|
392
|
-
{error &&
|
|
393
|
-
|
|
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
|
-
</
|
|
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
|
-
<
|
|
422
|
-
<
|
|
423
|
-
|
|
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
|
-
</
|
|
443
|
-
<
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
>
|
|
571
|
-
<
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
-
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
<
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
}
|