@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.
Files changed (42) hide show
  1. package/README.md +26 -19
  2. package/package.json +7 -7
  3. package/src/availability-setup.page.jsx +18 -17
  4. package/src/booking-card.component.jsx +6 -0
  5. package/src/booking-detail.page.jsx +28 -23
  6. package/src/booking-resources.js +10 -0
  7. package/src/bookings.page.jsx +27 -39
  8. package/src/cancel-booking.action.js +1 -61
  9. package/src/cancel-booking.task.js +60 -0
  10. package/src/confirm-booking.action.js +1 -82
  11. package/src/confirm-booking.task.js +81 -0
  12. package/src/create-booking.action.js +1 -168
  13. package/src/create-booking.task.js +170 -0
  14. package/src/create-service.action.js +1 -0
  15. package/src/create-service.task.js +46 -0
  16. package/src/decline-booking.action.js +1 -68
  17. package/src/decline-booking.task.js +67 -0
  18. package/src/delete-service.action.js +1 -0
  19. package/src/delete-service.task.js +41 -0
  20. package/src/en.translations.json +42 -9
  21. package/src/get-availability.action.js +1 -52
  22. package/src/get-availability.task.js +51 -0
  23. package/src/get-available-slots.action.js +1 -89
  24. package/src/get-available-slots.task.js +99 -0
  25. package/src/get-services.action.js +1 -0
  26. package/src/get-services.task.js +37 -0
  27. package/src/index.js +20 -1
  28. package/src/invoke-error-message.js +10 -0
  29. package/src/list-bookings.action.js +1 -36
  30. package/src/list-bookings.task.js +35 -0
  31. package/src/locations.js +14 -0
  32. package/src/public-booking.page.jsx +138 -42
  33. package/src/save-availability.action.js +1 -73
  34. package/src/save-availability.task.js +74 -0
  35. package/src/send-booking-reminder.task.js +2 -2
  36. package/src/services.page.jsx +157 -37
  37. package/src/sv.translations.json +42 -9
  38. package/src/update-service.action.js +1 -0
  39. package/src/update-service.task.js +52 -0
  40. package/src/BookingForm.jsx +0 -72
  41. package/src/availability.page.jsx +0 -43
  42. 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
- const res = await fetch('/actions/booking/create', {
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() }),
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
- 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) }
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(true)
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-slot')
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
- fetch(`/actions/booking/get-available-slots?consultantId=${encodeURIComponent(slug)}&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`)
182
- .then(r => r.ok ? r.json() : Promise.reject(r))
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="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
- )}
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="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
- )}
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
- import { nanoid } from 'nanoid'
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 { ResourcesQueries } from '@ossy/resources'
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 ResourcesQueries.GetResources({
16
+ const allBookings = await getBookingResources({
17
17
  type: '@ossy/booking/booking',
18
18
  })
19
19
 
@@ -1,8 +1,13 @@
1
- import React from 'react'
2
- import { View, Title, Text, Button, Tags, useLocale } from '@ossy/design-system'
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 ServicesPage = () => {
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
- <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>
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
- <View gap="s" inset="s">
40
- <Title variant="secondary">{t('booking.services.yourServices')}</Title>
118
+ {error && <Alert variant="danger">{error}</Alert>}
41
119
 
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
- />
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