@ossy/booking 1.16.0 → 1.16.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -19
- package/package.json +7 -7
- package/src/availability-setup.page.jsx +18 -17
- package/src/booking-card.component.jsx +6 -0
- package/src/booking-detail.page.jsx +28 -23
- package/src/booking-resources.js +10 -0
- package/src/bookings.page.jsx +27 -39
- 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 +42 -9
- 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/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 +138 -42
- 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 +157 -37
- package/src/sv.translations.json +42 -9
- package/src/update-service.action.js +1 -0
- package/src/update-service.task.js +52 -0
- package/src/BookingForm.jsx +0 -72
- package/src/availability.page.jsx +0 -43
- package/src/booking-page.page.jsx +0 -39
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import React, { useState, useEffect, useMemo } from 'react'
|
|
2
2
|
import { useRouter } from '@ossy/router-react'
|
|
3
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'
|
|
4
9
|
|
|
5
10
|
const MONTH_KEYS = [
|
|
6
11
|
'publicBooking.month.january', 'publicBooking.month.february', 'publicBooking.month.march',
|
|
@@ -98,7 +103,8 @@ function SlotPicker ({ slots, selectedSlot, onSelectSlot, t, language }) {
|
|
|
98
103
|
)
|
|
99
104
|
}
|
|
100
105
|
|
|
101
|
-
function BookingForm ({ slot, consultantSlug, onSuccess, t }) {
|
|
106
|
+
function BookingForm ({ slot, service, consultantSlug, onSuccess, t }) {
|
|
107
|
+
const sdk = useSdk();
|
|
102
108
|
const [clientName, setClientName] = useState('')
|
|
103
109
|
const [clientEmail, setClientEmail] = useState('')
|
|
104
110
|
const [clientMessage, setClientMessage] = useState('')
|
|
@@ -110,13 +116,17 @@ function BookingForm ({ slot, consultantSlug, onSuccess, t }) {
|
|
|
110
116
|
if (!clientName.trim() || !clientEmail.trim()) return
|
|
111
117
|
setSubmitting(true); setError(null)
|
|
112
118
|
try {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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(),
|
|
116
127
|
})
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
} catch (err) { setError(err.message) } finally { setSubmitting(false) }
|
|
128
|
+
onSuccess()
|
|
129
|
+
} catch (err) { setError(await invokeErrorMessage(err, t('publicBooking.form.error', { status: '' }))) } finally { setSubmitting(false) }
|
|
120
130
|
}
|
|
121
131
|
|
|
122
132
|
return (
|
|
@@ -135,7 +145,7 @@ function BookingForm ({ slot, consultantSlug, onSuccess, t }) {
|
|
|
135
145
|
<Textarea value={clientMessage} onChange={e => setClientMessage(e.target.value)} placeholder={t('publicBooking.form.messagePlaceholder')} rows={3} />
|
|
136
146
|
</View>
|
|
137
147
|
{error && <Alert variant="danger">{error}</Alert>}
|
|
138
|
-
<Button type="submit" variant="primary" disabled={submitting}>{submitting ? t('publicBooking.form.submitting') : t('publicBooking.form.submit')}</Button>
|
|
148
|
+
<Button data-action={CreateBooking.id} type="submit" variant="primary" disabled={submitting}>{submitting ? t('publicBooking.form.submitting') : t('publicBooking.form.submit')}</Button>
|
|
139
149
|
</View>
|
|
140
150
|
</form>
|
|
141
151
|
)
|
|
@@ -156,33 +166,86 @@ function PendingScreen ({ slot, t, language }) {
|
|
|
156
166
|
|
|
157
167
|
export const metadata = {
|
|
158
168
|
id: 'public-booking',
|
|
169
|
+
public: true,
|
|
159
170
|
path: { sv: '/boka/:consultantSlug', en: '/book/:consultantSlug' },
|
|
160
171
|
}
|
|
161
172
|
|
|
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
|
+
|
|
162
202
|
export default function PublicBookingPage ({ consultantSlug }) {
|
|
163
203
|
const { t, language } = useLocale()
|
|
204
|
+
const sdk = useSdk()
|
|
164
205
|
const router = useRouter()
|
|
165
206
|
const slug = consultantSlug ?? router?.params?.consultantSlug
|
|
166
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)
|
|
167
212
|
const [calYear, setCalYear] = useState(today.getFullYear())
|
|
168
213
|
const [calMonth, setCalMonth] = useState(today.getMonth())
|
|
169
214
|
const [slots, setSlots] = useState([])
|
|
170
|
-
const [loadingSlots, setLoadingSlots] = useState(
|
|
215
|
+
const [loadingSlots, setLoadingSlots] = useState(false)
|
|
171
216
|
const [slotsError, setSlotsError] = useState(null)
|
|
172
217
|
const [selectedDate, setSelectedDate] = useState(null)
|
|
173
218
|
const [selectedSlot, setSelectedSlot] = useState(null)
|
|
174
|
-
const [step, setStep] = useState('pick-
|
|
219
|
+
const [step, setStep] = useState('pick-service')
|
|
175
220
|
|
|
176
221
|
useEffect(() => {
|
|
177
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])
|
|
234
|
+
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
if (!slug || !selectedService || step === 'pick-service') return
|
|
178
237
|
setLoadingSlots(true); setSlotsError(null)
|
|
179
238
|
const from = new Date().toISOString()
|
|
180
239
|
const to = new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString()
|
|
181
|
-
|
|
182
|
-
|
|
240
|
+
sdk.invoke(GetAvailableSlots, {
|
|
241
|
+
consultantId: slug,
|
|
242
|
+
serviceId: selectedService.id,
|
|
243
|
+
from,
|
|
244
|
+
to,
|
|
245
|
+
})
|
|
183
246
|
.then(data => { setSlots(data); setLoadingSlots(false) })
|
|
184
247
|
.catch(() => { setSlotsError(t('publicBooking.slotsError')); setLoadingSlots(false) })
|
|
185
|
-
}, [slug, t])
|
|
248
|
+
}, [slug, selectedService, step, sdk, t])
|
|
186
249
|
|
|
187
250
|
const slotsGrouped = groupSlotsByDate(slots)
|
|
188
251
|
const selectedDateSlots = selectedDate ? (slotsGrouped[selectedDate] ?? []) : []
|
|
@@ -199,43 +262,76 @@ export default function PublicBookingPage ({ consultantSlug }) {
|
|
|
199
262
|
) : step === 'fill-form' && selectedSlot ? (
|
|
200
263
|
<View gap="m">
|
|
201
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
|
+
)}
|
|
202
271
|
<View surface="secondary" roundness="m" inset="s">
|
|
203
272
|
<Text weight="medium">{t('publicBooking.selectedTime', { datetime: formatDate(selectedSlot.startAt, t, language) })}</Text>
|
|
204
273
|
<Text color="secondary">{t('publicBooking.durationMin', { minutes: selectedSlot.duration })}</Text>
|
|
205
274
|
</View>
|
|
206
|
-
<BookingForm slot={selectedSlot} consultantSlug={slug} onSuccess={() => setStep('confirmed')} t={t} />
|
|
275
|
+
<BookingForm slot={selectedSlot} service={selectedService} consultantSlug={slug} onSuccess={() => setStep('confirmed')} t={t} />
|
|
207
276
|
</View>
|
|
208
|
-
) : (
|
|
209
|
-
<View gap="
|
|
210
|
-
<
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
)}
|
|
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>
|
|
222
283
|
</View>
|
|
223
|
-
<View gap="
|
|
224
|
-
{
|
|
225
|
-
|
|
226
|
-
<
|
|
227
|
-
|
|
228
|
-
</
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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>
|
|
237
313
|
</View>
|
|
238
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>
|
|
239
335
|
)}
|
|
240
336
|
</View>
|
|
241
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' }
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid'
|
|
2
|
+
import { Aggregate } from '@ossy/event-store'
|
|
3
|
+
import { Resource, ResourcesEvents } from '@ossy/resources/server'
|
|
4
|
+
import { getBookingResources } from './booking-resources.js'
|
|
5
|
+
import { availability } from './locations.js'
|
|
6
|
+
|
|
7
|
+
export const metadata = { id: 'booking/save-availability' }
|
|
8
|
+
|
|
9
|
+
export async function run({ payload, req, log }) {
|
|
10
|
+
const workspaceId = req?.workspaceId
|
|
11
|
+
|
|
12
|
+
if (!workspaceId) {
|
|
13
|
+
throw Object.assign(new Error('workspaceId is required'), { status: 400 })
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const { weeklyWindows, sessionDurations, bufferMinutes, timezone } = payload ?? {}
|
|
17
|
+
|
|
18
|
+
log?.info(`[booking/save-availability] Saving availability for workspace ${workspaceId}`)
|
|
19
|
+
|
|
20
|
+
const content = {
|
|
21
|
+
weeklyWindows: weeklyWindows ?? [],
|
|
22
|
+
sessionDurations: Array.isArray(sessionDurations) && sessionDurations.length
|
|
23
|
+
? sessionDurations
|
|
24
|
+
: [60],
|
|
25
|
+
bufferMinutes: bufferMinutes ?? 0,
|
|
26
|
+
timezone: timezone ?? 'Europe/Stockholm',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Check if a config resource already exists for this workspace
|
|
30
|
+
const existing = await getBookingResources({
|
|
31
|
+
type: '@ossy/booking/availability',
|
|
32
|
+
belongsTo: workspaceId,
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// Identify the single config resource (new format has weeklyWindows array in content)
|
|
36
|
+
const configResource = existing.find(r => Array.isArray(r.content?.weeklyWindows))
|
|
37
|
+
|
|
38
|
+
if (configResource) {
|
|
39
|
+
log?.info(`[booking/save-availability] Updating existing resource ${configResource.id}`)
|
|
40
|
+
|
|
41
|
+
await Aggregate.Of(Resource, configResource.id)
|
|
42
|
+
.then(
|
|
43
|
+
Aggregate.Add(
|
|
44
|
+
ResourcesEvents.ContentUpdated({
|
|
45
|
+
createdBy: req?.userId ?? 'system',
|
|
46
|
+
content,
|
|
47
|
+
}),
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
.then(Aggregate.Save())
|
|
51
|
+
|
|
52
|
+
return { id: configResource.id, content }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// No config resource found — create a new one
|
|
56
|
+
const resourceId = nanoid()
|
|
57
|
+
log?.info(`[booking/save-availability] Creating new resource ${resourceId}`)
|
|
58
|
+
|
|
59
|
+
const event = ResourcesEvents.Created({
|
|
60
|
+
aggregateId: resourceId,
|
|
61
|
+
type: '@ossy/booking/availability',
|
|
62
|
+
createdBy: req?.userId ?? 'system',
|
|
63
|
+
belongsTo: workspaceId,
|
|
64
|
+
location: availability,
|
|
65
|
+
name: `availability-config.json`,
|
|
66
|
+
content,
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
await Aggregate.Of(Resource, event).then(Aggregate.View())
|
|
70
|
+
|
|
71
|
+
log?.info(`[booking/save-availability] Resource ${resourceId} created`)
|
|
72
|
+
|
|
73
|
+
return { id: resourceId, content }
|
|
74
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getBookingResources } from './booking-resources.js'
|
|
2
2
|
import BookingReminderEmail from './booking-reminder.email.jsx'
|
|
3
3
|
|
|
4
4
|
export const metadata = {
|
|
@@ -13,7 +13,7 @@ export async function run({ sdk, log, integrations }) {
|
|
|
13
13
|
|
|
14
14
|
log?.info(`[booking/send-reminder] Checking bookings between ${windowStart} and ${windowEnd}`)
|
|
15
15
|
|
|
16
|
-
const allBookings = await
|
|
16
|
+
const allBookings = await getBookingResources({
|
|
17
17
|
type: '@ossy/booking/booking',
|
|
18
18
|
})
|
|
19
19
|
|
package/src/services.page.jsx
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import React from 'react'
|
|
2
|
-
import { View, Title, Text, Button,
|
|
1
|
+
import React, { useState, useEffect, useCallback } from 'react'
|
|
2
|
+
import { View, Title, Text, Button, Input, Textarea, Alert, useLocale } from '@ossy/design-system'
|
|
3
|
+
import { useSdk } from '@ossy/sdk-react'
|
|
3
4
|
import { Definition } from './Definition.js'
|
|
4
|
-
import { moduleStatusTags } from './moduleStatus.js'
|
|
5
5
|
import { ServiceCard } from './ServiceCard.jsx'
|
|
6
|
+
import { metadata as CreateService } from './create-service.action.js'
|
|
7
|
+
import { metadata as UpdateService } from './update-service.action.js'
|
|
8
|
+
import { metadata as DeleteService } from './delete-service.action.js'
|
|
9
|
+
import { metadata as GetServices } from './get-services.action.js'
|
|
10
|
+
import { invokeErrorMessage } from './invoke-error-message.js'
|
|
6
11
|
|
|
7
12
|
export const metadata = {
|
|
8
13
|
id: 'booking/services',
|
|
@@ -12,8 +17,90 @@ export const metadata = {
|
|
|
12
17
|
},
|
|
13
18
|
}
|
|
14
19
|
|
|
15
|
-
const
|
|
20
|
+
const EMPTY_FORM = {
|
|
21
|
+
name: '',
|
|
22
|
+
duration: 60,
|
|
23
|
+
price: 0,
|
|
24
|
+
currency: 'SEK',
|
|
25
|
+
description: '',
|
|
26
|
+
active: true,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default function ServicesPage () {
|
|
16
30
|
const { t } = useLocale()
|
|
31
|
+
const sdk = useSdk()
|
|
32
|
+
const [services, setServices] = useState([])
|
|
33
|
+
const [loading, setLoading] = useState(true)
|
|
34
|
+
const [error, setError] = useState(null)
|
|
35
|
+
const [formOpen, setFormOpen] = useState(false)
|
|
36
|
+
const [editingId, setEditingId] = useState(null)
|
|
37
|
+
const [form, setForm] = useState(EMPTY_FORM)
|
|
38
|
+
const [saving, setSaving] = useState(false)
|
|
39
|
+
|
|
40
|
+
const loadServices = useCallback(async () => {
|
|
41
|
+
setLoading(true)
|
|
42
|
+
setError(null)
|
|
43
|
+
try {
|
|
44
|
+
const data = await sdk.invoke(GetServices, { includeInactive: true })
|
|
45
|
+
setServices(Array.isArray(data) ? data : [])
|
|
46
|
+
} catch (err) {
|
|
47
|
+
setError(err.message)
|
|
48
|
+
} finally {
|
|
49
|
+
setLoading(false)
|
|
50
|
+
}
|
|
51
|
+
}, [sdk, t])
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
loadServices()
|
|
55
|
+
}, [loadServices])
|
|
56
|
+
|
|
57
|
+
const openCreate = () => {
|
|
58
|
+
setEditingId(null)
|
|
59
|
+
setForm(EMPTY_FORM)
|
|
60
|
+
setFormOpen(true)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const openEdit = (service) => {
|
|
64
|
+
setEditingId(service.id)
|
|
65
|
+
setForm({
|
|
66
|
+
name: service.name ?? '',
|
|
67
|
+
duration: service.duration ?? 60,
|
|
68
|
+
price: service.price ?? 0,
|
|
69
|
+
currency: service.currency ?? 'SEK',
|
|
70
|
+
description: service.description ?? '',
|
|
71
|
+
active: service.active !== false,
|
|
72
|
+
})
|
|
73
|
+
setFormOpen(true)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const handleSave = async () => {
|
|
77
|
+
if (!form.name.trim()) return
|
|
78
|
+
setSaving(true)
|
|
79
|
+
setError(null)
|
|
80
|
+
try {
|
|
81
|
+
const bookingAction = editingId ? UpdateService : CreateService
|
|
82
|
+
const body = editingId ? { serviceId: editingId, ...form } : form
|
|
83
|
+
await sdk.invoke(bookingAction, body)
|
|
84
|
+
setFormOpen(false)
|
|
85
|
+
await loadServices()
|
|
86
|
+
} catch (err) {
|
|
87
|
+
setError(await invokeErrorMessage(err, t('booking.services.errorSave')))
|
|
88
|
+
} finally {
|
|
89
|
+
setSaving(false)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const handleDelete = async (serviceId) => {
|
|
94
|
+
if (!window.confirm(t('booking.services.deleteConfirm'))) return
|
|
95
|
+
setError(null)
|
|
96
|
+
try {
|
|
97
|
+
await sdk.invoke(DeleteService, { serviceId })
|
|
98
|
+
await loadServices()
|
|
99
|
+
} catch (err) {
|
|
100
|
+
setError(await invokeErrorMessage(err, t('booking.services.errorDelete')))
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
17
104
|
return (
|
|
18
105
|
<View
|
|
19
106
|
gap="m"
|
|
@@ -21,45 +108,78 @@ const ServicesPage = () => {
|
|
|
21
108
|
style={{ padding: 'var(--space-m) var(--space-l)', height: '100%', overflowY: 'auto' }}
|
|
22
109
|
>
|
|
23
110
|
<View inset="s" gap="s">
|
|
24
|
-
<View layout="row" justifyContent="space-between" alignItems="center" style={{ flexShrink: 0 }}>
|
|
25
|
-
<
|
|
26
|
-
|
|
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>
|
|
111
|
+
<View layout="row" justifyContent="space-between" alignItems="center" style={{ flexShrink: 0, flexWrap: 'wrap', gap: 'var(--space-s)' }}>
|
|
112
|
+
<Title>{Definition.title} — {t('booking.services.titleSuffix')}</Title>
|
|
113
|
+
<Button variant="primary" size="s" onClick={openCreate}>{t('booking.services.addService')}</Button>
|
|
32
114
|
</View>
|
|
33
|
-
|
|
34
|
-
<Text style={{ maxWidth: '400px' }}>
|
|
35
|
-
{t('booking.services.description')}
|
|
36
|
-
</Text>
|
|
115
|
+
<Text style={{ maxWidth: '480px' }}>{t('booking.services.description')}</Text>
|
|
37
116
|
</View>
|
|
38
117
|
|
|
39
|
-
<
|
|
40
|
-
<Title variant="secondary">{t('booking.services.yourServices')}</Title>
|
|
118
|
+
{error && <Alert variant="danger">{error}</Alert>}
|
|
41
119
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
120
|
+
{formOpen && (
|
|
121
|
+
<View gap="m" surface="secondary" roundness="m" inset="m">
|
|
122
|
+
<Title variant="tertiary">{editingId ? t('booking.services.editService') : t('booking.services.newService')}</Title>
|
|
123
|
+
<View gap="xs">
|
|
124
|
+
<Text weight="medium">{t('booking.services.field.name')}</Text>
|
|
125
|
+
<Input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} />
|
|
126
|
+
</View>
|
|
127
|
+
<View gap="xs">
|
|
128
|
+
<Text weight="medium">{t('booking.services.field.duration')}</Text>
|
|
129
|
+
<Input type="number" value={String(form.duration)} onChange={e => setForm(f => ({ ...f, duration: Number(e.target.value) || 60 }))} />
|
|
130
|
+
</View>
|
|
131
|
+
<View gap="xs">
|
|
132
|
+
<Text weight="medium">{t('booking.services.field.price')}</Text>
|
|
133
|
+
<Input type="number" value={String(form.price)} onChange={e => setForm(f => ({ ...f, price: Number(e.target.value) || 0 }))} />
|
|
134
|
+
</View>
|
|
135
|
+
<View gap="xs">
|
|
136
|
+
<Text weight="medium">{t('booking.services.field.currency')}</Text>
|
|
137
|
+
<Input value={form.currency} onChange={e => setForm(f => ({ ...f, currency: e.target.value }))} />
|
|
138
|
+
</View>
|
|
139
|
+
<View gap="xs">
|
|
140
|
+
<Text weight="medium">{t('booking.services.field.description')}</Text>
|
|
141
|
+
<Textarea value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} rows={3} />
|
|
142
|
+
</View>
|
|
143
|
+
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
144
|
+
<input type="checkbox" checked={form.active} onChange={e => setForm(f => ({ ...f, active: e.target.checked }))} />
|
|
145
|
+
<Text>{t('booking.services.field.active')}</Text>
|
|
146
|
+
</label>
|
|
147
|
+
<View layout="row" gap="s">
|
|
148
|
+
<Button data-action={editingId ? UpdateService.id : CreateService.id} variant="primary" disabled={saving} onClick={handleSave}>
|
|
149
|
+
{saving ? t('booking.services.saving') : t('booking.services.save')}
|
|
150
|
+
</Button>
|
|
151
|
+
<Button variant="neutral" onClick={() => setFormOpen(false)}>{t('booking.services.cancel')}</Button>
|
|
152
|
+
</View>
|
|
59
153
|
</View>
|
|
154
|
+
)}
|
|
155
|
+
|
|
156
|
+
<View gap="s" inset="s">
|
|
157
|
+
<Title variant="secondary">{t('booking.services.yourServices')}</Title>
|
|
158
|
+
{loading ? (
|
|
159
|
+
<Text color="secondary">{t('booking.services.loading')}</Text>
|
|
160
|
+
) : services.length === 0 ? (
|
|
161
|
+
<Text color="secondary">{t('booking.services.empty')}</Text>
|
|
162
|
+
) : (
|
|
163
|
+
<View gap="s">
|
|
164
|
+
{services.map(service => (
|
|
165
|
+
<View key={service.id} gap="xs">
|
|
166
|
+
<ServiceCard
|
|
167
|
+
name={service.name}
|
|
168
|
+
duration={service.duration}
|
|
169
|
+
price={service.price}
|
|
170
|
+
currency={service.currency}
|
|
171
|
+
description={service.description}
|
|
172
|
+
active={service.active !== false}
|
|
173
|
+
/>
|
|
174
|
+
<View layout="row" gap="s">
|
|
175
|
+
<Button variant="link" size="s" onClick={() => openEdit(service)}>{t('booking.services.edit')}</Button>
|
|
176
|
+
<Button data-action={DeleteService.id} variant="link" size="s" onClick={() => handleDelete(service.id)}>{t('booking.services.delete')}</Button>
|
|
177
|
+
</View>
|
|
178
|
+
</View>
|
|
179
|
+
))}
|
|
180
|
+
</View>
|
|
181
|
+
)}
|
|
60
182
|
</View>
|
|
61
183
|
</View>
|
|
62
184
|
)
|
|
63
185
|
}
|
|
64
|
-
|
|
65
|
-
export default ServicesPage
|