@ossy/booking 1.15.7 → 1.16.1
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 +26 -19
- package/package.json +7 -7
- package/src/AvailabilityEditor.jsx +22 -15
- package/src/Definition.js +1 -23
- package/src/SalesSection.jsx +9 -4
- package/src/availability-setup.page.jsx +94 -126
- package/src/booking-card.component.jsx +30 -33
- package/src/booking-detail.page.jsx +65 -204
- package/src/booking-resources.js +10 -0
- package/src/bookings.page.jsx +54 -56
- package/src/cancel-booking.action.js +1 -61
- package/src/cancel-booking.task.js +60 -0
- package/src/confirm-booking.action.js +1 -82
- package/src/confirm-booking.task.js +81 -0
- package/src/create-booking.action.js +1 -168
- package/src/create-booking.task.js +170 -0
- package/src/create-service.action.js +1 -0
- package/src/create-service.task.js +46 -0
- package/src/decline-booking.action.js +1 -68
- package/src/decline-booking.task.js +67 -0
- package/src/delete-service.action.js +1 -0
- package/src/delete-service.task.js +41 -0
- package/src/en.translations.json +246 -0
- package/src/get-availability.action.js +1 -52
- package/src/get-availability.task.js +51 -0
- package/src/get-available-slots.action.js +1 -89
- package/src/get-available-slots.task.js +99 -0
- package/src/get-services.action.js +1 -0
- package/src/get-services.task.js +37 -0
- package/src/home.page.jsx +48 -5
- package/src/index.js +20 -1
- package/src/invoke-error-message.js +10 -0
- package/src/list-bookings.action.js +1 -36
- package/src/list-bookings.task.js +35 -0
- package/src/locations.js +14 -0
- package/src/public-booking.page.jsx +200 -245
- package/src/save-availability.action.js +1 -73
- package/src/save-availability.task.js +74 -0
- package/src/send-booking-reminder.task.js +2 -2
- package/src/services.page.jsx +167 -46
- package/src/sv.translations.json +246 -0
- package/src/update-service.action.js +1 -0
- package/src/update-service.task.js +52 -0
- package/src/BookingForm.jsx +0 -82
- package/src/availability.page.jsx +0 -41
- package/src/booking-page.page.jsx +0 -42
|
@@ -1,22 +1,27 @@
|
|
|
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'
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
import { View, Title, Text, Button, Input, Textarea, Alert, useLocale } from '@ossy/design-system'
|
|
4
|
+
import { useSdk } from '@ossy/sdk-react'
|
|
5
|
+
import { metadata as CreateBooking } from './create-booking.action.js'
|
|
6
|
+
import { metadata as GetServices } from './get-services.action.js'
|
|
7
|
+
import { metadata as GetAvailableSlots } from './get-available-slots.action.js'
|
|
8
|
+
import { invokeErrorMessage } from './invoke-error-message.js'
|
|
9
|
+
|
|
10
|
+
const MONTH_KEYS = [
|
|
11
|
+
'publicBooking.month.january', 'publicBooking.month.february', 'publicBooking.month.march',
|
|
12
|
+
'publicBooking.month.april', 'publicBooking.month.may', 'publicBooking.month.june',
|
|
13
|
+
'publicBooking.month.july', 'publicBooking.month.august', 'publicBooking.month.september',
|
|
14
|
+
'publicBooking.month.october', 'publicBooking.month.november', 'publicBooking.month.december',
|
|
15
|
+
]
|
|
16
|
+
const WEEKDAY_SHORT_KEYS = [
|
|
17
|
+
'publicBooking.weekday.sun', 'publicBooking.weekday.mon', 'publicBooking.weekday.tue',
|
|
18
|
+
'publicBooking.weekday.wed', 'publicBooking.weekday.thu', 'publicBooking.weekday.fri', 'publicBooking.weekday.sat',
|
|
8
19
|
]
|
|
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
20
|
|
|
12
|
-
function formatDate (isoString) {
|
|
21
|
+
function formatDate (isoString, t, language) {
|
|
13
22
|
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}`
|
|
23
|
+
const locale = language === 'sv' ? 'sv-SE' : 'en-GB'
|
|
24
|
+
return new Intl.DateTimeFormat(locale, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' }).format(d)
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
function formatTime (isoString) {
|
|
@@ -24,20 +29,10 @@ function formatTime (isoString) {
|
|
|
24
29
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
|
25
30
|
}
|
|
26
31
|
|
|
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) {
|
|
32
|
+
function formatSlotRange (startIso, durationMinutes, language) {
|
|
38
33
|
const start = new Date(startIso)
|
|
39
34
|
const end = new Date(start.getTime() + durationMinutes * 60 * 1000)
|
|
40
|
-
const tz =
|
|
35
|
+
const tz = new Intl.DateTimeFormat(language === 'sv' ? 'sv-SE' : 'en-GB', { timeZoneName: 'short', hour: 'numeric' }).formatToParts(start).find(p => p.type === 'timeZoneName')?.value ?? ''
|
|
41
36
|
const range = `${formatTime(startIso)}–${formatTime(end.toISOString())}`
|
|
42
37
|
return tz ? `${range} (${tz})` : range
|
|
43
38
|
}
|
|
@@ -53,21 +48,14 @@ function groupSlotsByDate (slots) {
|
|
|
53
48
|
return groups
|
|
54
49
|
}
|
|
55
50
|
|
|
56
|
-
function getDaysInMonth (year, month) {
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function getFirstDayOfMonth (year, month) {
|
|
61
|
-
const raw = new Date(year, month, 1).getDay()
|
|
62
|
-
return (raw + 6) % 7
|
|
63
|
-
}
|
|
51
|
+
function getDaysInMonth (year, month) { return new Date(year, month + 1, 0).getDate() }
|
|
52
|
+
function getFirstDayOfMonth (year, month) { return (new Date(year, month, 1).getDay() + 6) % 7 }
|
|
64
53
|
|
|
65
|
-
function MonthCalendar ({ year, month, slotsGrouped, selectedDate, onSelectDate, onPrevMonth, onNextMonth }) {
|
|
54
|
+
function MonthCalendar ({ year, month, slotsGrouped, selectedDate, onSelectDate, onPrevMonth, onNextMonth, t }) {
|
|
66
55
|
const daysInMonth = getDaysInMonth(year, month)
|
|
67
56
|
const firstDayOffset = getFirstDayOfMonth(year, month)
|
|
68
57
|
const today = new Date()
|
|
69
58
|
const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
|
|
70
|
-
|
|
71
59
|
const cells = []
|
|
72
60
|
for (let i = 0; i < firstDayOffset; i++) cells.push(null)
|
|
73
61
|
for (let d = 1; d <= daysInMonth; d++) cells.push(d)
|
|
@@ -76,38 +64,25 @@ function MonthCalendar ({ year, month, slotsGrouped, selectedDate, onSelectDate,
|
|
|
76
64
|
<View gap="m">
|
|
77
65
|
<View layout="row" justifyContent="space-between" alignItems="center">
|
|
78
66
|
<Button variant="neutral" onClick={onPrevMonth}>‹</Button>
|
|
79
|
-
<Text weight="medium">{
|
|
67
|
+
<Text weight="medium">{t(MONTH_KEYS[month])} {year}</Text>
|
|
80
68
|
<Button variant="neutral" onClick={onNextMonth}>›</Button>
|
|
81
69
|
</View>
|
|
82
|
-
|
|
83
70
|
<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>
|
|
71
|
+
{WEEKDAY_SHORT_KEYS.map((key, i) => (
|
|
72
|
+
<Text key={key} variant="small" style={{ textAlign: 'center', opacity: 0.6 }}>{t(key)}</Text>
|
|
88
73
|
))}
|
|
89
74
|
</View>
|
|
90
|
-
|
|
91
75
|
<View gap="xs" style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
|
|
92
76
|
{cells.map((day, idx) => {
|
|
93
77
|
if (!day) return <View key={`empty-${idx}`} />
|
|
94
|
-
|
|
95
78
|
const dateKey = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
|
96
79
|
const hasSlots = !!slotsGrouped[dateKey]
|
|
97
80
|
const isSelected = selectedDate === dateKey
|
|
98
81
|
const isPast = dateKey < todayKey
|
|
99
82
|
const disabled = !hasSlots || isPast
|
|
100
|
-
|
|
101
83
|
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>
|
|
84
|
+
<Button key={dateKey} variant={isSelected ? 'tab-active' : hasSlots && !isPast ? 'tab' : 'neutral'} disabled={disabled}
|
|
85
|
+
onClick={() => !disabled && onSelectDate(dateKey)} style={{ padding: '8px 4px', minWidth: 0 }}>{day}</Button>
|
|
111
86
|
)
|
|
112
87
|
})}
|
|
113
88
|
</View>
|
|
@@ -115,30 +90,21 @@ function MonthCalendar ({ year, month, slotsGrouped, selectedDate, onSelectDate,
|
|
|
115
90
|
)
|
|
116
91
|
}
|
|
117
92
|
|
|
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
|
-
|
|
93
|
+
function SlotPicker ({ slots, selectedSlot, onSelectSlot, t, language }) {
|
|
94
|
+
if (!slots?.length) return <Text color="secondary">{t('publicBooking.noSlots')}</Text>
|
|
123
95
|
return (
|
|
124
96
|
<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
|
-
})}
|
|
97
|
+
{slots.map(slot => (
|
|
98
|
+
<Button key={slot.startAt} variant={selectedSlot?.startAt === slot.startAt ? 'tab-active' : 'tab'} onClick={() => onSelectSlot(slot)}>
|
|
99
|
+
{formatSlotRange(slot.startAt, slot.duration, language)}
|
|
100
|
+
</Button>
|
|
101
|
+
))}
|
|
137
102
|
</View>
|
|
138
103
|
)
|
|
139
104
|
}
|
|
140
105
|
|
|
141
|
-
function BookingForm ({ slot, consultantSlug, onSuccess }) {
|
|
106
|
+
function BookingForm ({ slot, service, consultantSlug, onSuccess, t }) {
|
|
107
|
+
const sdk = useSdk();
|
|
142
108
|
const [clientName, setClientName] = useState('')
|
|
143
109
|
const [clientEmail, setClientEmail] = useState('')
|
|
144
110
|
const [clientMessage, setClientMessage] = useState('')
|
|
@@ -148,93 +114,51 @@ function BookingForm ({ slot, consultantSlug, onSuccess }) {
|
|
|
148
114
|
const handleSubmit = async (e) => {
|
|
149
115
|
e.preventDefault()
|
|
150
116
|
if (!clientName.trim() || !clientEmail.trim()) return
|
|
151
|
-
|
|
152
|
-
setSubmitting(true)
|
|
153
|
-
setError(null)
|
|
154
|
-
|
|
117
|
+
setSubmitting(true); setError(null)
|
|
155
118
|
try {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
clientEmail: clientEmail.trim(),
|
|
165
|
-
clientMessage: clientMessage.trim(),
|
|
166
|
-
}),
|
|
119
|
+
await sdk.invoke(CreateBooking, {
|
|
120
|
+
consultantId: consultantSlug,
|
|
121
|
+
startAt: slot.startAt,
|
|
122
|
+
duration: slot.duration,
|
|
123
|
+
serviceId: service?.id,
|
|
124
|
+
clientName: clientName.trim(),
|
|
125
|
+
clientEmail: clientEmail.trim(),
|
|
126
|
+
clientMessage: clientMessage.trim(),
|
|
167
127
|
})
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const data = await res.json().catch(() => ({}))
|
|
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
|
-
}
|
|
128
|
+
onSuccess()
|
|
129
|
+
} catch (err) { setError(await invokeErrorMessage(err, t('publicBooking.form.error', { status: '' }))) } finally { setSubmitting(false) }
|
|
181
130
|
}
|
|
182
131
|
|
|
183
132
|
return (
|
|
184
133
|
<form onSubmit={handleSubmit}>
|
|
185
134
|
<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>
|
|
135
|
+
<View gap="xs">
|
|
136
|
+
<Text weight="medium">{t('publicBooking.form.name')}</Text>
|
|
137
|
+
<Input type="text" required value={clientName} onChange={e => setClientName(e.target.value)} placeholder={t('publicBooking.form.namePlaceholder')} />
|
|
138
|
+
</View>
|
|
139
|
+
<View gap="xs">
|
|
140
|
+
<Text weight="medium">{t('publicBooking.form.email')}</Text>
|
|
141
|
+
<Input type="email" required value={clientEmail} onChange={e => setClientEmail(e.target.value)} placeholder={t('publicBooking.form.emailPlaceholder')} />
|
|
142
|
+
</View>
|
|
143
|
+
<View gap="xs">
|
|
144
|
+
<Text weight="medium">{t('publicBooking.form.message')}</Text>
|
|
145
|
+
<Textarea value={clientMessage} onChange={e => setClientMessage(e.target.value)} placeholder={t('publicBooking.form.messagePlaceholder')} rows={3} />
|
|
146
|
+
</View>
|
|
147
|
+
{error && <Alert variant="danger">{error}</Alert>}
|
|
148
|
+
<Button data-action={CreateBooking.id} type="submit" variant="primary" disabled={submitting}>{submitting ? t('publicBooking.form.submitting') : t('publicBooking.form.submit')}</Button>
|
|
223
149
|
</View>
|
|
224
150
|
</form>
|
|
225
151
|
)
|
|
226
152
|
}
|
|
227
153
|
|
|
228
|
-
function PendingScreen ({ slot }) {
|
|
154
|
+
function PendingScreen ({ slot, t, language }) {
|
|
229
155
|
return (
|
|
230
156
|
<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>
|
|
157
|
+
<Title variant="secondary">{t('publicBooking.pending.title')}</Title>
|
|
158
|
+
<Text color="secondary">{t('publicBooking.pending.body')}</Text>
|
|
159
|
+
<Alert variant="warning" title={t('publicBooking.pending.requestedTime')}>
|
|
160
|
+
<Text>{formatDate(slot.startAt, t, language)}</Text>
|
|
161
|
+
<Text color="secondary">{t('publicBooking.pending.duration', { minutes: slot.duration })}</Text>
|
|
238
162
|
</Alert>
|
|
239
163
|
</View>
|
|
240
164
|
)
|
|
@@ -242,141 +166,172 @@ function PendingScreen ({ slot }) {
|
|
|
242
166
|
|
|
243
167
|
export const metadata = {
|
|
244
168
|
id: 'public-booking',
|
|
169
|
+
public: true,
|
|
245
170
|
path: { sv: '/boka/:consultantSlug', en: '/book/:consultantSlug' },
|
|
246
171
|
}
|
|
247
172
|
|
|
248
|
-
|
|
173
|
+
function ServicePicker ({ services, selectedService, onSelect, t, language }) {
|
|
174
|
+
if (!services?.length) {
|
|
175
|
+
return <Text color="secondary">{t('publicBooking.noServices')}</Text>
|
|
176
|
+
}
|
|
177
|
+
const formatPrice = (cents, currency) => {
|
|
178
|
+
if (!cents) return t('publicBooking.serviceFree')
|
|
179
|
+
return new Intl.NumberFormat(language === 'sv' ? 'sv-SE' : 'en-GB', { style: 'currency', currency: currency || 'SEK' }).format(cents / 100)
|
|
180
|
+
}
|
|
181
|
+
return (
|
|
182
|
+
<View gap="s">
|
|
183
|
+
{services.map(service => (
|
|
184
|
+
<Button
|
|
185
|
+
key={service.id}
|
|
186
|
+
variant={selectedService?.id === service.id ? 'tab-active' : 'tab'}
|
|
187
|
+
onClick={() => onSelect(service)}
|
|
188
|
+
style={{ justifyContent: 'flex-start', textAlign: 'left' }}
|
|
189
|
+
>
|
|
190
|
+
<View gap="xs">
|
|
191
|
+
<Text weight="medium">{service.name}</Text>
|
|
192
|
+
<Text size="s" color="secondary">
|
|
193
|
+
{t('publicBooking.serviceMeta', { duration: service.duration, price: formatPrice(service.price, service.currency) })}
|
|
194
|
+
</Text>
|
|
195
|
+
</View>
|
|
196
|
+
</Button>
|
|
197
|
+
))}
|
|
198
|
+
</View>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export default function PublicBookingPage ({ consultantSlug }) {
|
|
203
|
+
const { t, language } = useLocale()
|
|
204
|
+
const sdk = useSdk()
|
|
249
205
|
const router = useRouter()
|
|
250
206
|
const slug = consultantSlug ?? router?.params?.consultantSlug
|
|
251
|
-
|
|
252
207
|
const today = new Date()
|
|
208
|
+
const [services, setServices] = useState([])
|
|
209
|
+
const [loadingServices, setLoadingServices] = useState(true)
|
|
210
|
+
const [servicesError, setServicesError] = useState(null)
|
|
211
|
+
const [selectedService, setSelectedService] = useState(null)
|
|
253
212
|
const [calYear, setCalYear] = useState(today.getFullYear())
|
|
254
213
|
const [calMonth, setCalMonth] = useState(today.getMonth())
|
|
255
|
-
|
|
256
214
|
const [slots, setSlots] = useState([])
|
|
257
|
-
const [loadingSlots, setLoadingSlots] = useState(
|
|
215
|
+
const [loadingSlots, setLoadingSlots] = useState(false)
|
|
258
216
|
const [slotsError, setSlotsError] = useState(null)
|
|
259
|
-
|
|
260
217
|
const [selectedDate, setSelectedDate] = useState(null)
|
|
261
218
|
const [selectedSlot, setSelectedSlot] = useState(null)
|
|
262
|
-
const [step, setStep] = useState('pick-
|
|
219
|
+
const [step, setStep] = useState('pick-service')
|
|
263
220
|
|
|
264
221
|
useEffect(() => {
|
|
265
222
|
if (!slug) return
|
|
223
|
+
setLoadingServices(true)
|
|
224
|
+
setServicesError(null)
|
|
225
|
+
sdk.invoke(GetServices, { consultantId: slug })
|
|
226
|
+
.then(data => {
|
|
227
|
+
const list = Array.isArray(data) ? data : []
|
|
228
|
+
setServices(list)
|
|
229
|
+
if (list.length === 1) setSelectedService(list[0])
|
|
230
|
+
setLoadingServices(false)
|
|
231
|
+
})
|
|
232
|
+
.catch(() => { setServicesError(t('publicBooking.servicesError')); setLoadingServices(false) })
|
|
233
|
+
}, [slug, sdk, t])
|
|
266
234
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
if (!slug || !selectedService || step === 'pick-service') return
|
|
237
|
+
setLoadingSlots(true); setSlotsError(null)
|
|
270
238
|
const from = new Date().toISOString()
|
|
271
239
|
const to = new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString()
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
240
|
+
sdk.invoke(GetAvailableSlots, {
|
|
241
|
+
consultantId: slug,
|
|
242
|
+
serviceId: selectedService.id,
|
|
243
|
+
from,
|
|
244
|
+
to,
|
|
245
|
+
})
|
|
275
246
|
.then(data => { setSlots(data); setLoadingSlots(false) })
|
|
276
|
-
.catch(() => { setSlotsError('
|
|
277
|
-
}, [slug])
|
|
247
|
+
.catch(() => { setSlotsError(t('publicBooking.slotsError')); setLoadingSlots(false) })
|
|
248
|
+
}, [slug, selectedService, step, sdk, t])
|
|
278
249
|
|
|
279
250
|
const slotsGrouped = groupSlotsByDate(slots)
|
|
280
251
|
const selectedDateSlots = selectedDate ? (slotsGrouped[selectedDate] ?? []) : []
|
|
281
252
|
|
|
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
253
|
return (
|
|
293
|
-
<View
|
|
294
|
-
surface="base"
|
|
295
|
-
layout="off-center-s"
|
|
296
|
-
inset="s"
|
|
297
|
-
style={{ minHeight: '100dvh' }}
|
|
298
|
-
>
|
|
254
|
+
<View surface="base" layout="off-center-s" inset="s" style={{ minHeight: '100dvh' }}>
|
|
299
255
|
<View inset="m" roundness="l" surface="primary" slot="content" gap="l" style={{ maxWidth: 720, width: '100%' }}>
|
|
300
256
|
<View gap="xs" style={{ borderBottom: '1px solid var(--separator)', paddingBottom: 'var(--space-m)' }}>
|
|
301
|
-
<Title variant="secondary">
|
|
302
|
-
<Text color="secondary">
|
|
257
|
+
<Title variant="secondary">{t('publicBooking.title')}</Title>
|
|
258
|
+
<Text color="secondary">{t('publicBooking.description')}</Text>
|
|
303
259
|
</View>
|
|
304
|
-
|
|
305
260
|
{step === 'confirmed' && selectedSlot ? (
|
|
306
|
-
<PendingScreen slot={selectedSlot} />
|
|
261
|
+
<PendingScreen slot={selectedSlot} t={t} language={language} />
|
|
307
262
|
) : step === 'fill-form' && selectedSlot ? (
|
|
308
263
|
<View gap="m">
|
|
309
|
-
<Button variant="link" onClick={() => setStep('pick-slot')}>
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
264
|
+
<Button variant="link" onClick={() => setStep('pick-slot')}>{t('publicBooking.back')}</Button>
|
|
265
|
+
{selectedService && (
|
|
266
|
+
<View surface="secondary" roundness="m" inset="s">
|
|
267
|
+
<Text weight="medium">{selectedService.name}</Text>
|
|
268
|
+
<Text color="secondary">{t('publicBooking.durationMin', { minutes: selectedService.duration })}</Text>
|
|
269
|
+
</View>
|
|
270
|
+
)}
|
|
313
271
|
<View surface="secondary" roundness="m" inset="s">
|
|
314
|
-
<Text weight="medium">
|
|
315
|
-
<Text color="secondary">({selectedSlot.duration}
|
|
272
|
+
<Text weight="medium">{t('publicBooking.selectedTime', { datetime: formatDate(selectedSlot.startAt, t, language) })}</Text>
|
|
273
|
+
<Text color="secondary">{t('publicBooking.durationMin', { minutes: selectedSlot.duration })}</Text>
|
|
316
274
|
</View>
|
|
317
|
-
|
|
318
|
-
<BookingForm
|
|
319
|
-
slot={selectedSlot}
|
|
320
|
-
consultantSlug={slug}
|
|
321
|
-
onSuccess={() => setStep('confirmed')}
|
|
322
|
-
/>
|
|
275
|
+
<BookingForm slot={selectedSlot} service={selectedService} consultantSlug={slug} onSuccess={() => setStep('confirmed')} t={t} />
|
|
323
276
|
</View>
|
|
324
|
-
) : (
|
|
325
|
-
<View gap="
|
|
326
|
-
<
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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)
|
|
342
|
-
}}
|
|
343
|
-
onPrevMonth={handlePrevMonth}
|
|
344
|
-
onNextMonth={handleNextMonth}
|
|
345
|
-
/>
|
|
346
|
-
)}
|
|
277
|
+
) : step === 'pick-slot' && selectedService ? (
|
|
278
|
+
<View gap="m">
|
|
279
|
+
<Button variant="link" onClick={() => { setStep('pick-service'); setSelectedSlot(null); setSelectedDate(null) }}>{t('publicBooking.back')}</Button>
|
|
280
|
+
<View surface="secondary" roundness="m" inset="s">
|
|
281
|
+
<Text weight="medium">{selectedService.name}</Text>
|
|
282
|
+
<Text color="secondary">{t('publicBooking.durationMin', { minutes: selectedService.duration })}</Text>
|
|
347
283
|
</View>
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
)}
|
|
284
|
+
<View gap="l" layout="row" style={{ flexWrap: 'wrap' }}>
|
|
285
|
+
<View style={{ flex: 1, minWidth: 260 }}>
|
|
286
|
+
{loadingSlots ? (
|
|
287
|
+
<Text color="secondary" style={{ textAlign: 'center', padding: 'var(--space-l)' }}>{t('publicBooking.loadingSlots')}</Text>
|
|
288
|
+
) : slotsError ? (
|
|
289
|
+
<Alert variant="danger">{slotsError}</Alert>
|
|
290
|
+
) : (
|
|
291
|
+
<MonthCalendar year={calYear} month={calMonth} slotsGrouped={slotsGrouped} selectedDate={selectedDate}
|
|
292
|
+
onSelectDate={(dateKey) => { setSelectedDate(dateKey); setSelectedSlot(null) }}
|
|
293
|
+
onPrevMonth={() => { if (calMonth === 0) { setCalMonth(11); setCalYear(y => y - 1) } else setCalMonth(m => m - 1) }}
|
|
294
|
+
onNextMonth={() => { if (calMonth === 11) { setCalMonth(0); setCalYear(y => y + 1) } else setCalMonth(m => m + 1) }}
|
|
295
|
+
t={t} />
|
|
296
|
+
)}
|
|
297
|
+
</View>
|
|
298
|
+
<View gap="m" style={{ flex: 1, minWidth: 260 }}>
|
|
299
|
+
{selectedDate ? (
|
|
300
|
+
<>
|
|
301
|
+
<Title variant="tertiary">
|
|
302
|
+
{(() => { const [y, m, d] = selectedDate.split('-').map(Number); return `${d} ${t(MONTH_KEYS[m - 1])} ${y}` })()}
|
|
303
|
+
</Title>
|
|
304
|
+
<SlotPicker slots={selectedDateSlots} selectedSlot={selectedSlot} onSelectSlot={setSelectedSlot} t={t} language={language} />
|
|
305
|
+
{selectedSlot && <Button variant="primary" onClick={() => setStep('fill-form')}>{t('publicBooking.continue')}</Button>}
|
|
306
|
+
</>
|
|
307
|
+
) : (
|
|
308
|
+
<View gap="s" alignItems="center" justifyContent="center" inset="l" style={{ minHeight: 180 }}>
|
|
309
|
+
<Text color="secondary" style={{ textAlign: 'center' }}>{t('publicBooking.pickDate')}</Text>
|
|
310
|
+
</View>
|
|
311
|
+
)}
|
|
312
|
+
</View>
|
|
378
313
|
</View>
|
|
379
314
|
</View>
|
|
315
|
+
) : (
|
|
316
|
+
<View gap="m">
|
|
317
|
+
<Title variant="tertiary">{t('publicBooking.chooseService')}</Title>
|
|
318
|
+
{loadingServices ? (
|
|
319
|
+
<Text color="secondary">{t('publicBooking.loadingServices')}</Text>
|
|
320
|
+
) : servicesError ? (
|
|
321
|
+
<Alert variant="danger">{servicesError}</Alert>
|
|
322
|
+
) : (
|
|
323
|
+
<ServicePicker
|
|
324
|
+
services={services}
|
|
325
|
+
selectedService={selectedService}
|
|
326
|
+
onSelect={setSelectedService}
|
|
327
|
+
t={t}
|
|
328
|
+
language={language}
|
|
329
|
+
/>
|
|
330
|
+
)}
|
|
331
|
+
{selectedService && (
|
|
332
|
+
<Button variant="primary" onClick={() => setStep('pick-slot')}>{t('publicBooking.continue')}</Button>
|
|
333
|
+
)}
|
|
334
|
+
</View>
|
|
380
335
|
)}
|
|
381
336
|
</View>
|
|
382
337
|
</View>
|
|
@@ -1,73 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { Aggregate } from '@ossy/event-store'
|
|
3
|
-
import { Resource, ResourcesEvents, ResourcesQueries } from '@ossy/resources'
|
|
4
|
-
|
|
5
|
-
export const id = 'booking/save-availability'
|
|
6
|
-
export const access = 'workspace'
|
|
7
|
-
|
|
8
|
-
export async function run({ payload, req, log }) {
|
|
9
|
-
const workspaceId = req?.workspaceId
|
|
10
|
-
|
|
11
|
-
if (!workspaceId) {
|
|
12
|
-
throw Object.assign(new Error('workspaceId is required'), { status: 400 })
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const { weeklyWindows, sessionDurations, bufferMinutes, timezone } = payload ?? {}
|
|
16
|
-
|
|
17
|
-
log?.info(`[booking/save-availability] Saving availability for workspace ${workspaceId}`)
|
|
18
|
-
|
|
19
|
-
const content = {
|
|
20
|
-
weeklyWindows: weeklyWindows ?? [],
|
|
21
|
-
sessionDurations: Array.isArray(sessionDurations) && sessionDurations.length
|
|
22
|
-
? sessionDurations
|
|
23
|
-
: [60],
|
|
24
|
-
bufferMinutes: bufferMinutes ?? 0,
|
|
25
|
-
timezone: timezone ?? 'Europe/Stockholm',
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// Check if a config resource already exists for this workspace
|
|
29
|
-
const existing = await ResourcesQueries.GetResources({
|
|
30
|
-
type: '@ossy/booking/availability',
|
|
31
|
-
belongsTo: workspaceId,
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
// Identify the single config resource (new format has weeklyWindows array in content)
|
|
35
|
-
const configResource = existing.find(r => Array.isArray(r.content?.weeklyWindows))
|
|
36
|
-
|
|
37
|
-
if (configResource) {
|
|
38
|
-
log?.info(`[booking/save-availability] Updating existing resource ${configResource.id}`)
|
|
39
|
-
|
|
40
|
-
await Aggregate.Of(Resource, configResource.id)
|
|
41
|
-
.then(
|
|
42
|
-
Aggregate.Add(
|
|
43
|
-
ResourcesEvents.ContentUpdated({
|
|
44
|
-
createdBy: req?.userId ?? 'system',
|
|
45
|
-
content,
|
|
46
|
-
}),
|
|
47
|
-
),
|
|
48
|
-
)
|
|
49
|
-
.then(Aggregate.Save())
|
|
50
|
-
|
|
51
|
-
return { id: configResource.id, content }
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// No config resource found — create a new one
|
|
55
|
-
const resourceId = nanoid()
|
|
56
|
-
log?.info(`[booking/save-availability] Creating new resource ${resourceId}`)
|
|
57
|
-
|
|
58
|
-
const event = ResourcesEvents.Created({
|
|
59
|
-
aggregateId: resourceId,
|
|
60
|
-
type: '@ossy/booking/availability',
|
|
61
|
-
createdBy: req?.userId ?? 'system',
|
|
62
|
-
belongsTo: workspaceId,
|
|
63
|
-
location: '/availability/',
|
|
64
|
-
name: `availability-config.json`,
|
|
65
|
-
content,
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
await Aggregate.Of(Resource, event).then(Aggregate.View())
|
|
69
|
-
|
|
70
|
-
log?.info(`[booking/save-availability] Resource ${resourceId} created`)
|
|
71
|
-
|
|
72
|
-
return { id: resourceId, content }
|
|
73
|
-
}
|
|
1
|
+
export const metadata = { id: 'booking/save-availability', access: 'workspace' }
|