@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.
- package/README.md +12 -0
- package/package.json +7 -7
- package/src/AvailabilityEditor.jsx +30 -19
- package/src/BookingForm.jsx +21 -31
- package/src/Definition.js +1 -23
- package/src/HeroCover.jsx +99 -0
- package/src/SalesSection.jsx +77 -0
- package/src/availability-setup.page.jsx +147 -397
- package/src/availability.page.jsx +25 -23
- package/src/booking-card.component.jsx +76 -215
- package/src/booking-detail.page.jsx +103 -573
- package/src/booking-page.page.jsx +4 -7
- package/src/bookings.page.jsx +62 -196
- package/src/cover.js +8 -0
- package/src/en.translations.json +213 -0
- package/src/features.js +27 -0
- package/src/home.page.jsx +47 -364
- package/src/public-booking.page.jsx +138 -636
- package/src/service-card-slot.component.jsx +48 -0
- package/src/service-detail.component.jsx +38 -0
- package/src/services.page.jsx +44 -43
- package/src/sv.translations.json +213 -0
|
@@ -1,77 +1,38 @@
|
|
|
1
|
-
import React, { useState, useEffect,
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
40
|
-
|
|
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
|
|
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
|
|
66
|
-
const
|
|
67
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
<
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
<
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
</
|
|
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 <
|
|
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
|
-
<
|
|
196
|
-
|
|
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
|
-
</
|
|
246
|
-
</
|
|
83
|
+
</View>
|
|
84
|
+
</View>
|
|
247
85
|
)
|
|
248
86
|
}
|
|
249
87
|
|
|
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
|
-
)
|
|
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
|
-
<
|
|
261
|
-
{slots.map(slot =>
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
342
|
-
|
|
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
|
-
<
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
type="text"
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
<
|
|
372
|
-
|
|
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
|
-
<
|
|
422
|
-
<
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
588
|
-
|
|
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
|
}
|