@ossy/booking 1.15.7 → 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/package.json +7 -7
- package/src/AvailabilityEditor.jsx +22 -15
- package/src/BookingForm.jsx +18 -28
- package/src/Definition.js +1 -23
- package/src/SalesSection.jsx +9 -4
- package/src/availability-setup.page.jsx +77 -110
- package/src/availability.page.jsx +25 -23
- package/src/booking-card.component.jsx +24 -33
- package/src/booking-detail.page.jsx +61 -205
- package/src/booking-page.page.jsx +4 -7
- package/src/bookings.page.jsx +32 -22
- package/src/en.translations.json +213 -0
- package/src/home.page.jsx +48 -5
- package/src/public-booking.page.jsx +81 -222
- package/src/services.page.jsx +44 -43
- package/src/sv.translations.json +213 -0
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
import React, { useState, useEffect } 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 } from '@ossy/design-system'
|
|
3
|
+
import { View, Title, Text, Button, Input, Textarea, Alert, useLocale } from '@ossy/design-system'
|
|
4
4
|
|
|
5
|
-
const
|
|
6
|
-
'
|
|
7
|
-
'
|
|
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',
|
|
8
14
|
]
|
|
9
|
-
const SV_WEEKDAYS_SHORT = ['Sön', 'Mån', 'Tis', 'Ons', 'Tor', 'Fre', 'Lör']
|
|
10
|
-
const SV_WEEKDAYS_LONG = ['Söndag', 'Måndag', 'Tisdag', 'Onsdag', 'Torsdag', 'Fredag', 'Lördag']
|
|
11
15
|
|
|
12
|
-
function formatDate (isoString) {
|
|
16
|
+
function formatDate (isoString, t, language) {
|
|
13
17
|
const d = new Date(isoString)
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
const month = SV_MONTHS[d.getMonth()]
|
|
17
|
-
const hours = String(d.getHours()).padStart(2, '0')
|
|
18
|
-
const minutes = String(d.getMinutes()).padStart(2, '0')
|
|
19
|
-
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)
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
function formatTime (isoString) {
|
|
@@ -24,20 +24,10 @@ function formatTime (isoString) {
|
|
|
24
24
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
function
|
|
28
|
-
try {
|
|
29
|
-
return new Intl.DateTimeFormat('sv-SE', { timeZoneName: 'short', hour: 'numeric' })
|
|
30
|
-
.formatToParts(new Date(isoString))
|
|
31
|
-
.find(p => p.type === 'timeZoneName')?.value ?? ''
|
|
32
|
-
} catch {
|
|
33
|
-
return ''
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function formatSlotRange (startIso, durationMinutes) {
|
|
27
|
+
function formatSlotRange (startIso, durationMinutes, language) {
|
|
38
28
|
const start = new Date(startIso)
|
|
39
29
|
const end = new Date(start.getTime() + durationMinutes * 60 * 1000)
|
|
40
|
-
const tz =
|
|
30
|
+
const tz = new Intl.DateTimeFormat(language === 'sv' ? 'sv-SE' : 'en-GB', { timeZoneName: 'short', hour: 'numeric' }).formatToParts(start).find(p => p.type === 'timeZoneName')?.value ?? ''
|
|
41
31
|
const range = `${formatTime(startIso)}–${formatTime(end.toISOString())}`
|
|
42
32
|
return tz ? `${range} (${tz})` : range
|
|
43
33
|
}
|
|
@@ -53,21 +43,14 @@ function groupSlotsByDate (slots) {
|
|
|
53
43
|
return groups
|
|
54
44
|
}
|
|
55
45
|
|
|
56
|
-
function getDaysInMonth (year, month) {
|
|
57
|
-
|
|
58
|
-
}
|
|
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 }
|
|
59
48
|
|
|
60
|
-
function
|
|
61
|
-
const raw = new Date(year, month, 1).getDay()
|
|
62
|
-
return (raw + 6) % 7
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function MonthCalendar ({ year, month, slotsGrouped, selectedDate, onSelectDate, onPrevMonth, onNextMonth }) {
|
|
49
|
+
function MonthCalendar ({ year, month, slotsGrouped, selectedDate, onSelectDate, onPrevMonth, onNextMonth, t }) {
|
|
66
50
|
const daysInMonth = getDaysInMonth(year, month)
|
|
67
51
|
const firstDayOffset = getFirstDayOfMonth(year, month)
|
|
68
52
|
const today = new Date()
|
|
69
53
|
const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
|
|
70
|
-
|
|
71
54
|
const cells = []
|
|
72
55
|
for (let i = 0; i < firstDayOffset; i++) cells.push(null)
|
|
73
56
|
for (let d = 1; d <= daysInMonth; d++) cells.push(d)
|
|
@@ -76,38 +59,25 @@ function MonthCalendar ({ year, month, slotsGrouped, selectedDate, onSelectDate,
|
|
|
76
59
|
<View gap="m">
|
|
77
60
|
<View layout="row" justifyContent="space-between" alignItems="center">
|
|
78
61
|
<Button variant="neutral" onClick={onPrevMonth}>‹</Button>
|
|
79
|
-
<Text weight="medium">{
|
|
62
|
+
<Text weight="medium">{t(MONTH_KEYS[month])} {year}</Text>
|
|
80
63
|
<Button variant="neutral" onClick={onNextMonth}>›</Button>
|
|
81
64
|
</View>
|
|
82
|
-
|
|
83
65
|
<View layout="row" gap="xs" style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
|
|
84
|
-
{
|
|
85
|
-
<Text key={
|
|
86
|
-
{SV_WEEKDAYS_SHORT[(i + 1) % 7]}
|
|
87
|
-
</Text>
|
|
66
|
+
{WEEKDAY_SHORT_KEYS.map((key, i) => (
|
|
67
|
+
<Text key={key} variant="small" style={{ textAlign: 'center', opacity: 0.6 }}>{t(key)}</Text>
|
|
88
68
|
))}
|
|
89
69
|
</View>
|
|
90
|
-
|
|
91
70
|
<View gap="xs" style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
|
|
92
71
|
{cells.map((day, idx) => {
|
|
93
72
|
if (!day) return <View key={`empty-${idx}`} />
|
|
94
|
-
|
|
95
73
|
const dateKey = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
|
96
74
|
const hasSlots = !!slotsGrouped[dateKey]
|
|
97
75
|
const isSelected = selectedDate === dateKey
|
|
98
76
|
const isPast = dateKey < todayKey
|
|
99
77
|
const disabled = !hasSlots || isPast
|
|
100
|
-
|
|
101
78
|
return (
|
|
102
|
-
<Button
|
|
103
|
-
|
|
104
|
-
variant={isSelected ? 'tab-active' : hasSlots && !isPast ? 'tab' : 'neutral'}
|
|
105
|
-
disabled={disabled}
|
|
106
|
-
onClick={() => !disabled && onSelectDate(dateKey)}
|
|
107
|
-
style={{ padding: '8px 4px', minWidth: 0 }}
|
|
108
|
-
>
|
|
109
|
-
{day}
|
|
110
|
-
</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>
|
|
111
81
|
)
|
|
112
82
|
})}
|
|
113
83
|
</View>
|
|
@@ -115,30 +85,20 @@ function MonthCalendar ({ year, month, slotsGrouped, selectedDate, onSelectDate,
|
|
|
115
85
|
)
|
|
116
86
|
}
|
|
117
87
|
|
|
118
|
-
function SlotPicker ({ slots, selectedSlot, onSelectSlot }) {
|
|
119
|
-
if (!slots?.length) {
|
|
120
|
-
return <Text color="secondary">Inga tillgängliga tider för valt datum.</Text>
|
|
121
|
-
}
|
|
122
|
-
|
|
88
|
+
function SlotPicker ({ slots, selectedSlot, onSelectSlot, t, language }) {
|
|
89
|
+
if (!slots?.length) return <Text color="secondary">{t('publicBooking.noSlots')}</Text>
|
|
123
90
|
return (
|
|
124
91
|
<View layout="row" gap="s" style={{ flexWrap: 'wrap' }}>
|
|
125
|
-
{slots.map(slot =>
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
variant={isSelected ? 'tab-active' : 'tab'}
|
|
131
|
-
onClick={() => onSelectSlot(slot)}
|
|
132
|
-
>
|
|
133
|
-
{formatSlotRange(slot.startAt, slot.duration)}
|
|
134
|
-
</Button>
|
|
135
|
-
)
|
|
136
|
-
})}
|
|
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
|
+
))}
|
|
137
97
|
</View>
|
|
138
98
|
)
|
|
139
99
|
}
|
|
140
100
|
|
|
141
|
-
function BookingForm ({ slot, consultantSlug, onSuccess }) {
|
|
101
|
+
function BookingForm ({ slot, consultantSlug, onSuccess, t }) {
|
|
142
102
|
const [clientName, setClientName] = useState('')
|
|
143
103
|
const [clientEmail, setClientEmail] = useState('')
|
|
144
104
|
const [clientMessage, setClientMessage] = useState('')
|
|
@@ -148,93 +108,47 @@ function BookingForm ({ slot, consultantSlug, onSuccess }) {
|
|
|
148
108
|
const handleSubmit = async (e) => {
|
|
149
109
|
e.preventDefault()
|
|
150
110
|
if (!clientName.trim() || !clientEmail.trim()) return
|
|
151
|
-
|
|
152
|
-
setSubmitting(true)
|
|
153
|
-
setError(null)
|
|
154
|
-
|
|
111
|
+
setSubmitting(true); setError(null)
|
|
155
112
|
try {
|
|
156
113
|
const res = await fetch('/actions/booking/create', {
|
|
157
|
-
method: 'POST',
|
|
158
|
-
|
|
159
|
-
body: JSON.stringify({
|
|
160
|
-
consultantId: consultantSlug,
|
|
161
|
-
startAt: slot.startAt,
|
|
162
|
-
duration: slot.duration,
|
|
163
|
-
clientName: clientName.trim(),
|
|
164
|
-
clientEmail: clientEmail.trim(),
|
|
165
|
-
clientMessage: clientMessage.trim(),
|
|
166
|
-
}),
|
|
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() }),
|
|
167
116
|
})
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
throw new Error(data?.message ?? `Bokning misslyckades (${res.status})`)
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const booking = await res.json()
|
|
175
|
-
onSuccess(booking)
|
|
176
|
-
} catch (err) {
|
|
177
|
-
setError(err.message)
|
|
178
|
-
} finally {
|
|
179
|
-
setSubmitting(false)
|
|
180
|
-
}
|
|
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) }
|
|
181
120
|
}
|
|
182
121
|
|
|
183
122
|
return (
|
|
184
123
|
<form onSubmit={handleSubmit}>
|
|
185
124
|
<View gap="m">
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
onChange={e =>
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
<
|
|
199
|
-
<
|
|
200
|
-
type="email"
|
|
201
|
-
required
|
|
202
|
-
value={clientEmail}
|
|
203
|
-
onChange={e => setClientEmail(e.target.value)}
|
|
204
|
-
placeholder="anna@exempel.se"
|
|
205
|
-
/>
|
|
206
|
-
</View>
|
|
207
|
-
|
|
208
|
-
<View gap="xs">
|
|
209
|
-
<Text weight="medium">Meddelande (valfritt)</Text>
|
|
210
|
-
<Textarea
|
|
211
|
-
value={clientMessage}
|
|
212
|
-
onChange={e => setClientMessage(e.target.value)}
|
|
213
|
-
placeholder="Berätta gärna vad du vill prata om..."
|
|
214
|
-
rows={3}
|
|
215
|
-
/>
|
|
216
|
-
</View>
|
|
217
|
-
|
|
218
|
-
{error && <Alert variant="danger">{error}</Alert>}
|
|
219
|
-
|
|
220
|
-
<Button type="submit" variant="primary" disabled={submitting}>
|
|
221
|
-
{submitting ? 'Skickar…' : 'Skicka förfrågan'}
|
|
222
|
-
</Button>
|
|
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>
|
|
223
139
|
</View>
|
|
224
140
|
</form>
|
|
225
141
|
)
|
|
226
142
|
}
|
|
227
143
|
|
|
228
|
-
function PendingScreen ({ slot }) {
|
|
144
|
+
function PendingScreen ({ slot, t, language }) {
|
|
229
145
|
return (
|
|
230
146
|
<View gap="m" alignItems="center" style={{ textAlign: 'center' }}>
|
|
231
|
-
<Title variant="secondary">
|
|
232
|
-
<Text color="secondary">
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
<Text>{formatDate(slot.startAt)}</Text>
|
|
237
|
-
<Text color="secondary">{slot.duration} minuter</Text>
|
|
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>
|
|
238
152
|
</Alert>
|
|
239
153
|
</View>
|
|
240
154
|
)
|
|
@@ -245,134 +159,79 @@ export const metadata = {
|
|
|
245
159
|
path: { sv: '/boka/:consultantSlug', en: '/book/:consultantSlug' },
|
|
246
160
|
}
|
|
247
161
|
|
|
248
|
-
export default function PublicBookingPage ({ consultantSlug
|
|
162
|
+
export default function PublicBookingPage ({ consultantSlug }) {
|
|
163
|
+
const { t, language } = useLocale()
|
|
249
164
|
const router = useRouter()
|
|
250
165
|
const slug = consultantSlug ?? router?.params?.consultantSlug
|
|
251
|
-
|
|
252
166
|
const today = new Date()
|
|
253
167
|
const [calYear, setCalYear] = useState(today.getFullYear())
|
|
254
168
|
const [calMonth, setCalMonth] = useState(today.getMonth())
|
|
255
|
-
|
|
256
169
|
const [slots, setSlots] = useState([])
|
|
257
170
|
const [loadingSlots, setLoadingSlots] = useState(true)
|
|
258
171
|
const [slotsError, setSlotsError] = useState(null)
|
|
259
|
-
|
|
260
172
|
const [selectedDate, setSelectedDate] = useState(null)
|
|
261
173
|
const [selectedSlot, setSelectedSlot] = useState(null)
|
|
262
174
|
const [step, setStep] = useState('pick-slot')
|
|
263
175
|
|
|
264
176
|
useEffect(() => {
|
|
265
177
|
if (!slug) return
|
|
266
|
-
|
|
267
|
-
setLoadingSlots(true)
|
|
268
|
-
setSlotsError(null)
|
|
269
|
-
|
|
178
|
+
setLoadingSlots(true); setSlotsError(null)
|
|
270
179
|
const from = new Date().toISOString()
|
|
271
180
|
const to = new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString()
|
|
272
|
-
|
|
273
181
|
fetch(`/actions/booking/get-available-slots?consultantId=${encodeURIComponent(slug)}&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`)
|
|
274
182
|
.then(r => r.ok ? r.json() : Promise.reject(r))
|
|
275
183
|
.then(data => { setSlots(data); setLoadingSlots(false) })
|
|
276
|
-
.catch(() => { setSlotsError('
|
|
277
|
-
}, [slug])
|
|
184
|
+
.catch(() => { setSlotsError(t('publicBooking.slotsError')); setLoadingSlots(false) })
|
|
185
|
+
}, [slug, t])
|
|
278
186
|
|
|
279
187
|
const slotsGrouped = groupSlotsByDate(slots)
|
|
280
188
|
const selectedDateSlots = selectedDate ? (slotsGrouped[selectedDate] ?? []) : []
|
|
281
189
|
|
|
282
|
-
const handlePrevMonth = () => {
|
|
283
|
-
if (calMonth === 0) { setCalMonth(11); setCalYear(y => y - 1) }
|
|
284
|
-
else setCalMonth(m => m - 1)
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
const handleNextMonth = () => {
|
|
288
|
-
if (calMonth === 11) { setCalMonth(0); setCalYear(y => y + 1) }
|
|
289
|
-
else setCalMonth(m => m + 1)
|
|
290
|
-
}
|
|
291
|
-
|
|
292
190
|
return (
|
|
293
|
-
<View
|
|
294
|
-
surface="base"
|
|
295
|
-
layout="off-center-s"
|
|
296
|
-
inset="s"
|
|
297
|
-
style={{ minHeight: '100dvh' }}
|
|
298
|
-
>
|
|
191
|
+
<View surface="base" layout="off-center-s" inset="s" style={{ minHeight: '100dvh' }}>
|
|
299
192
|
<View inset="m" roundness="l" surface="primary" slot="content" gap="l" style={{ maxWidth: 720, width: '100%' }}>
|
|
300
193
|
<View gap="xs" style={{ borderBottom: '1px solid var(--separator)', paddingBottom: 'var(--space-m)' }}>
|
|
301
|
-
<Title variant="secondary">
|
|
302
|
-
<Text color="secondary">
|
|
194
|
+
<Title variant="secondary">{t('publicBooking.title')}</Title>
|
|
195
|
+
<Text color="secondary">{t('publicBooking.description')}</Text>
|
|
303
196
|
</View>
|
|
304
|
-
|
|
305
197
|
{step === 'confirmed' && selectedSlot ? (
|
|
306
|
-
<PendingScreen slot={selectedSlot} />
|
|
198
|
+
<PendingScreen slot={selectedSlot} t={t} language={language} />
|
|
307
199
|
) : step === 'fill-form' && selectedSlot ? (
|
|
308
200
|
<View gap="m">
|
|
309
|
-
<Button variant="link" onClick={() => setStep('pick-slot')}>
|
|
310
|
-
← Tillbaka
|
|
311
|
-
</Button>
|
|
312
|
-
|
|
201
|
+
<Button variant="link" onClick={() => setStep('pick-slot')}>{t('publicBooking.back')}</Button>
|
|
313
202
|
<View surface="secondary" roundness="m" inset="s">
|
|
314
|
-
<Text weight="medium">
|
|
315
|
-
<Text color="secondary">({selectedSlot.duration}
|
|
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>
|
|
316
205
|
</View>
|
|
317
|
-
|
|
318
|
-
<BookingForm
|
|
319
|
-
slot={selectedSlot}
|
|
320
|
-
consultantSlug={slug}
|
|
321
|
-
onSuccess={() => setStep('confirmed')}
|
|
322
|
-
/>
|
|
206
|
+
<BookingForm slot={selectedSlot} consultantSlug={slug} onSuccess={() => setStep('confirmed')} t={t} />
|
|
323
207
|
</View>
|
|
324
208
|
) : (
|
|
325
209
|
<View gap="l" layout="row" style={{ flexWrap: 'wrap' }}>
|
|
326
210
|
<View style={{ flex: 1, minWidth: 260 }}>
|
|
327
211
|
{loadingSlots ? (
|
|
328
|
-
<Text color="secondary" style={{ textAlign: 'center', padding: 'var(--space-l)' }}>
|
|
329
|
-
Laddar tider…
|
|
330
|
-
</Text>
|
|
212
|
+
<Text color="secondary" style={{ textAlign: 'center', padding: 'var(--space-l)' }}>{t('publicBooking.loadingSlots')}</Text>
|
|
331
213
|
) : slotsError ? (
|
|
332
214
|
<Alert variant="danger">{slotsError}</Alert>
|
|
333
215
|
) : (
|
|
334
|
-
<MonthCalendar
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
onSelectDate={(dateKey) => {
|
|
340
|
-
setSelectedDate(dateKey)
|
|
341
|
-
setSelectedSlot(null)
|
|
342
|
-
}}
|
|
343
|
-
onPrevMonth={handlePrevMonth}
|
|
344
|
-
onNextMonth={handleNextMonth}
|
|
345
|
-
/>
|
|
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} />
|
|
346
221
|
)}
|
|
347
222
|
</View>
|
|
348
|
-
|
|
349
223
|
<View gap="m" style={{ flex: 1, minWidth: 260 }}>
|
|
350
224
|
{selectedDate ? (
|
|
351
225
|
<>
|
|
352
226
|
<Title variant="tertiary">
|
|
353
|
-
{(() => {
|
|
354
|
-
const [y, m, d] = selectedDate.split('-').map(Number)
|
|
355
|
-
return `${d} ${SV_MONTHS[m - 1]} ${y}`
|
|
356
|
-
})()}
|
|
227
|
+
{(() => { const [y, m, d] = selectedDate.split('-').map(Number); return `${d} ${t(MONTH_KEYS[m - 1])} ${y}` })()}
|
|
357
228
|
</Title>
|
|
358
|
-
|
|
359
|
-
<
|
|
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>
|
|
369
|
-
)}
|
|
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>}
|
|
370
231
|
</>
|
|
371
232
|
) : (
|
|
372
233
|
<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>
|
|
234
|
+
<Text color="secondary" style={{ textAlign: 'center' }}>{t('publicBooking.pickDate')}</Text>
|
|
376
235
|
</View>
|
|
377
236
|
)}
|
|
378
237
|
</View>
|
package/src/services.page.jsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
|
-
import { View, Title, Text, Button, Tags } from '@ossy/design-system'
|
|
2
|
+
import { View, Title, Text, Button, Tags, useLocale } from '@ossy/design-system'
|
|
3
3
|
import { Definition } from './Definition.js'
|
|
4
4
|
import { moduleStatusTags } from './moduleStatus.js'
|
|
5
5
|
import { ServiceCard } from './ServiceCard.jsx'
|
|
@@ -12,53 +12,54 @@ export const metadata = {
|
|
|
12
12
|
},
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
const ServicesPage = () =>
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
<
|
|
27
|
-
|
|
15
|
+
const ServicesPage = () => {
|
|
16
|
+
const { t } = useLocale()
|
|
17
|
+
return (
|
|
18
|
+
<View
|
|
19
|
+
gap="m"
|
|
20
|
+
surface="primary"
|
|
21
|
+
style={{ padding: 'var(--space-m) var(--space-l)', height: '100%', overflowY: 'auto' }}
|
|
22
|
+
>
|
|
23
|
+
<View inset="s" gap="s">
|
|
24
|
+
<View layout="row" justifyContent="space-between" alignItems="center" style={{ flexShrink: 0 }}>
|
|
25
|
+
<View layout="row" gap="s" alignItems="center">
|
|
26
|
+
<Title>{Definition.title} — {t('booking.services.titleSuffix')}</Title>
|
|
27
|
+
{moduleStatusTags(Definition).length > 0 && (
|
|
28
|
+
<Tags tags={moduleStatusTags(Definition)} size="s" />
|
|
29
|
+
)}
|
|
30
|
+
</View>
|
|
31
|
+
<Button variant="primary" size="s">{t('booking.services.addService')}</Button>
|
|
28
32
|
</View>
|
|
29
|
-
{/* TODO: wire to booking.create-service action */}
|
|
30
|
-
<Button variant="primary" size="s">Add service</Button>
|
|
31
|
-
</View>
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
<Text style={{ maxWidth: '400px' }}>
|
|
35
|
+
{t('booking.services.description')}
|
|
36
|
+
</Text>
|
|
37
|
+
</View>
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
<View gap="s" inset="s">
|
|
40
|
+
<Title variant="secondary">{t('booking.services.yourServices')}</Title>
|
|
40
41
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
42
|
+
<View gap="s">
|
|
43
|
+
<ServiceCard
|
|
44
|
+
name={t('booking.services.example.introCall.name')}
|
|
45
|
+
duration={30}
|
|
46
|
+
price={0}
|
|
47
|
+
currency="SEK"
|
|
48
|
+
description={t('booking.services.example.introCall.description')}
|
|
49
|
+
active
|
|
50
|
+
/>
|
|
51
|
+
<ServiceCard
|
|
52
|
+
name={t('booking.services.example.strategy.name')}
|
|
53
|
+
duration={60}
|
|
54
|
+
price={150000}
|
|
55
|
+
currency="SEK"
|
|
56
|
+
description={t('booking.services.example.strategy.description')}
|
|
57
|
+
active
|
|
58
|
+
/>
|
|
59
|
+
</View>
|
|
59
60
|
</View>
|
|
60
61
|
</View>
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
)
|
|
63
|
+
}
|
|
63
64
|
|
|
64
65
|
export default ServicesPage
|