@ossy/booking 1.16.0 → 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.
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
package/README.md CHANGED
@@ -1,39 +1,46 @@
1
1
  # @ossy/booking
2
2
 
3
- Booking feature package for the Ossy platform. Lets consultants define services, set weekly availability, and accept client bookings with optional Stripe payment.
3
+ Booking feature package for the Ossy platform. Lets consultants define services, set weekly availability, and accept client bookings.
4
4
 
5
5
  ## Pages
6
6
 
7
7
  | Page | Path | Audience |
8
8
  |------|------|----------|
9
- | `booking-page` | `/book/:workspaceId` | Public (clients) |
10
- | `services` | `/services` | Consultant (authenticated) |
11
- | `bookings` | `/bookings` | Consultant (authenticated) |
12
- | `availability` | `/availability` | Consultant (authenticated) |
9
+ | `public-booking` | `/book/:consultantSlug` · `/boka/:consultantSlug` | Public (clients) |
10
+ | `services` | `/services` · `/tjanster` | Consultant (authenticated) |
11
+ | `bookings` | `/bookings` · `/bokningar` | Consultant (authenticated) |
12
+ | `availability-setup` | `/availability` · `/tillganglighet` | Consultant (authenticated) |
13
+ | `booking-detail` | `/bookings/:bookingId` · `/bokningar/:bookingId` | Consultant (authenticated) |
14
+
15
+ ## Public booking flow
16
+
17
+ 1. Client opens `public-booking` (no auth; `metadata.public: true`)
18
+ 2. Picks a service → calendar slot → contact details
19
+ 3. `booking/create` stores a pending booking and sends notification emails
20
+
21
+ ## Service actions
22
+
23
+ | Action | Access |
24
+ |--------|--------|
25
+ | `booking/get-services` | public |
26
+ | `booking/create-service` | workspace |
27
+ | `booking/update-service` | workspace |
28
+ | `booking/delete-service` | workspace |
13
29
 
14
30
  ## Resource templates
15
31
 
16
- Three resource schemas are registered under the `@ossy/booking` category:
32
+ - `@ossy/booking/service` what a consultant sells (`/@ossy/booking/services/`)
33
+ - `@ossy/booking/booking` — an appointment (`/@ossy/booking/bookings/`)
34
+ - `@ossy/booking/availability` — weekly availability config (`/@ossy/booking/availability/`)
17
35
 
18
- - `@ossy/booking/service` what a consultant sells
19
- - `@ossy/booking/booking` — a confirmed appointment
20
- - `@ossy/booking/availability` — recurring weekly availability window
36
+ Import locations from `@ossy/booking` (`services`, `bookings`, `availability`).
21
37
 
22
38
  ## Resource view slots
23
39
 
24
- Register custom UI for resource types via `*.component.jsx` with `metadata.id` matching the slot key (see `@ossy/design-system` [SLOTS.md](../design-system/docs/SLOTS.md)):
25
-
26
40
  | Component | Slot key |
27
41
  |-----------|----------|
28
42
  | `service-detail.component.jsx` | `resource:booking/service/detail` |
29
43
  | `service-card-slot.component.jsx` | `resource:booking/service/card` |
30
44
  | `booking-card.component.jsx` | `resource:booking/booking/card` |
31
45
 
32
- Dedicated pages (`services.page.jsx`, `booking-detail.page.jsx`, etc.) continue to work alongside generic `@ossy/resources` slot hosts.
33
-
34
- ## What still needs implementation
35
-
36
- - **Slot generation** — derive available time slots from `availability` records minus existing `booking` records
37
- - **Stripe integration** — create a PaymentIntent on booking submit, webhook to flip `paymentStatus`
38
- - **Confirmation emails** — `booking.send-confirmation-email` task
39
- - **Public workspace resolution** — resolve `workspaceId` param on the public booking page without auth
46
+ See [SPEC.md](./SPEC.md) for the full product specification.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ossy/booking",
3
3
  "description": "Booking feature package — services, availability, and appointment management for Ossy consultants",
4
- "version": "1.16.0",
4
+ "version": "1.16.1",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "module": "./src/index.js",
@@ -14,11 +14,11 @@
14
14
  "src": "./src"
15
15
  },
16
16
  "dependencies": {
17
- "@ossy/email": "^1.6.0",
18
- "@ossy/event-store": "^1.8.0",
19
- "@ossy/observability": "^1.8.0",
20
- "@ossy/platform": "^1.39.0",
21
- "@ossy/resources": "^1.12.0"
17
+ "@ossy/email": "^1.6.1",
18
+ "@ossy/event-store": "^1.8.1",
19
+ "@ossy/observability": "^1.8.1",
20
+ "@ossy/platform": "^1.39.1",
21
+ "@ossy/resources": "^1.12.1"
22
22
  },
23
23
  "peerDependencies": {
24
24
  "@ossy/design-system": ">=1.0.0",
@@ -34,5 +34,5 @@
34
34
  "/src",
35
35
  "README.md"
36
36
  ],
37
- "gitHead": "d9d31182be64a448d575da432af2830d909ad4b9"
37
+ "gitHead": "c0ba5d90749690634e4dc2705178ff8d89dd3070"
38
38
  }
@@ -9,6 +9,11 @@ import {
9
9
  Alert,
10
10
  useLocale,
11
11
  } from '@ossy/design-system'
12
+ import { useRouter } from '@ossy/router-react'
13
+ import { useSdk } from '@ossy/sdk-react'
14
+ import { metadata as SaveAvailability } from './save-availability.action.js'
15
+ import { metadata as GetAvailability } from './get-availability.action.js'
16
+ import { invokeErrorMessage } from './invoke-error-message.js'
12
17
 
13
18
  export const metadata = {
14
19
  id: 'booking/availability-setup',
@@ -87,6 +92,8 @@ function DayRow ({ label, day, onToggle, onTimeChange, t }) {
87
92
 
88
93
  export default function AvailabilitySetupPage ({ workspaceId }) {
89
94
  const { t } = useLocale()
95
+ const sdk = useSdk()
96
+ const router = useRouter()
90
97
  const dayDefs = useMemo(() => [
91
98
  { dayOfWeek: 1, key: 'booking.availabilitySetup.day.monday' },
92
99
  { dayOfWeek: 2, key: 'booking.availabilitySetup.day.tuesday' },
@@ -107,11 +114,15 @@ export default function AvailabilitySetupPage ({ workspaceId }) {
107
114
  const [error, setError] = useState(null)
108
115
  const [copied, setCopied] = useState(false)
109
116
 
110
- const bookingUrl = `https://ossy.se/boka/${workspaceId ?? ''}`
117
+ const bookingPath = workspaceId
118
+ ? router.getHref({ id: 'public-booking', params: { consultantSlug: workspaceId } })
119
+ : ''
120
+ const bookingUrl = typeof window !== 'undefined' && bookingPath
121
+ ? `${window.location.origin}${bookingPath}`
122
+ : bookingPath
111
123
 
112
124
  useEffect(() => {
113
- fetch('/actions/booking/get-availability')
114
- .then(r => (r.ok ? r.json() : null))
125
+ sdk.invoke(GetAvailability, {})
115
126
  .then(data => {
116
127
  if (data) {
117
128
  setDayState(buildDayState(data.weeklyWindows ?? []))
@@ -127,7 +138,7 @@ export default function AvailabilitySetupPage ({ workspaceId }) {
127
138
  })
128
139
  .catch(() => {})
129
140
  .finally(() => setLoading(false))
130
- }, [])
141
+ }, [sdk])
131
142
 
132
143
  const toggleDay = (dayOfWeek) => {
133
144
  setDayState(prev => prev.map(d => d.dayOfWeek === dayOfWeek
@@ -161,20 +172,10 @@ export default function AvailabilitySetupPage ({ workspaceId }) {
161
172
  }))
162
173
 
163
174
  try {
164
- const res = await fetch('/actions/booking/save-availability', {
165
- method: 'POST',
166
- headers: { 'Content-Type': 'application/json' },
167
- body: JSON.stringify({ weeklyWindows, sessionDurations, bufferMinutes, timezone }),
168
- })
169
-
170
- if (!res.ok) {
171
- const data = await res.json().catch(() => ({}))
172
- throw new Error(data?.message ?? t('booking.availabilitySetup.errorSave', { status: res.status }))
173
- }
174
-
175
+ await sdk.invoke(SaveAvailability, { weeklyWindows, sessionDurations, bufferMinutes, timezone })
175
176
  setSaved(true)
176
177
  } catch (err) {
177
- setError(err.message)
178
+ setError(await invokeErrorMessage(err, t('booking.availabilitySetup.errorSave', { status: '' })))
178
179
  } finally {
179
180
  setSaving(false)
180
181
  }
@@ -268,7 +269,7 @@ export default function AvailabilitySetupPage ({ workspaceId }) {
268
269
 
269
270
  {error && <Alert variant="danger">{error}</Alert>}
270
271
 
271
- <Button variant="primary" disabled={saving} onClick={handleSave}>
272
+ <Button data-action={SaveAvailability.id} variant="primary" disabled={saving} onClick={handleSave}>
272
273
  {saving ? t('booking.availabilitySetup.saving') : t('booking.availabilitySetup.save')}
273
274
  </Button>
274
275
 
@@ -1,5 +1,8 @@
1
1
  import React, { useState } from 'react'
2
2
  import { View, Title, Text, Button, Badge, Alert, useLocale } from '@ossy/design-system'
3
+ import { metadata as ConfirmBooking } from './confirm-booking.action.js'
4
+ import { metadata as DeclineBooking } from './decline-booking.action.js'
5
+ import { metadata as CancelBooking } from './cancel-booking.action.js'
3
6
 
4
7
  export const metadata = { id: 'resource:booking/booking/card' }
5
8
 
@@ -102,6 +105,7 @@ export function BookingCard ({ booking, resource, onConfirm, onDecline, onCancel
102
105
  <View layout="row" gap="s" style={{ flexWrap: 'wrap' }}>
103
106
  {isPending && onConfirm && (
104
107
  <Button
108
+ data-action={ConfirmBooking.id}
105
109
  variant="primary"
106
110
  disabled={loadingAction === 'confirm'}
107
111
  onClick={() => handle(onConfirm, 'confirm')}
@@ -111,6 +115,7 @@ export function BookingCard ({ booking, resource, onConfirm, onDecline, onCancel
111
115
  )}
112
116
  {isPending && onDecline && (
113
117
  <Button
118
+ data-action={DeclineBooking.id}
114
119
  variant="danger"
115
120
  disabled={loadingAction === 'decline'}
116
121
  onClick={() => handle(onDecline, 'decline')}
@@ -120,6 +125,7 @@ export function BookingCard ({ booking, resource, onConfirm, onDecline, onCancel
120
125
  )}
121
126
  {isConfirmed && onCancel && (
122
127
  <Button
128
+ data-action={CancelBooking.id}
123
129
  variant="danger"
124
130
  disabled={loadingAction === 'cancel'}
125
131
  onClick={() => handle(onCancel, 'cancel')}
@@ -1,6 +1,12 @@
1
1
  import React, { useState, useEffect, useCallback, useMemo } from 'react'
2
2
  import { useRouter } from '@ossy/router-react'
3
3
  import { View, Title, Text, Button, Badge, Alert, Textarea, useLocale } from '@ossy/design-system'
4
+ import { useSdk } from '@ossy/sdk-react'
5
+ import { metadata as ConfirmBooking } from './confirm-booking.action.js'
6
+ import { metadata as DeclineBooking } from './decline-booking.action.js'
7
+ import { metadata as CancelBooking } from './cancel-booking.action.js'
8
+ import { metadata as ListBookings } from './list-bookings.action.js'
9
+ import { invokeErrorMessage } from './invoke-error-message.js'
4
10
 
5
11
  export const metadata = {
6
12
  id: 'booking/booking-detail',
@@ -36,34 +42,32 @@ function DetailRow ({ label, value }) {
36
42
  )
37
43
  }
38
44
 
39
- function ConfirmForm ({ booking, onSuccess, onError, t }) {
45
+ function ConfirmForm ({ booking, onSuccess, onError, t, sdk }) {
40
46
  const [loading, setLoading] = useState(false)
41
47
  const handleConfirm = async () => {
42
48
  setLoading(true)
43
49
  try {
44
- const res = await fetch('/actions/booking/confirm', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bookingId: booking.id }) })
45
- if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data?.message ?? t('booking.bookingDetail.errorConfirm')) }
50
+ await sdk.invoke(ConfirmBooking, { bookingId: booking.id })
46
51
  onSuccess()
47
- } catch (err) { onError(err.message) } finally { setLoading(false) }
52
+ } catch (err) { onError(await invokeErrorMessage(err, t('booking.bookingDetail.errorConfirm'))) } finally { setLoading(false) }
48
53
  }
49
54
  return (
50
- <Button variant="primary" disabled={loading} onClick={handleConfirm}>
55
+ <Button data-action={ConfirmBooking.id} variant="primary" disabled={loading} onClick={handleConfirm}>
51
56
  {loading ? t('booking.bookingDetail.confirming') : t('booking.bookingDetail.confirm')}
52
57
  </Button>
53
58
  )
54
59
  }
55
60
 
56
- function DeclineForm ({ booking, onSuccess, onError, t }) {
61
+ function DeclineForm ({ booking, onSuccess, onError, t, sdk }) {
57
62
  const [reason, setReason] = useState('')
58
63
  const [loading, setLoading] = useState(false)
59
64
  const [expanded, setExpanded] = useState(false)
60
65
  const handleDecline = async () => {
61
66
  setLoading(true)
62
67
  try {
63
- const res = await fetch('/actions/booking/decline', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bookingId: booking.id, reason: reason.trim() || null }) })
64
- if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data?.message ?? t('booking.bookingDetail.errorDecline')) }
68
+ await sdk.invoke(DeclineBooking, { bookingId: booking.id, reason: reason.trim() || null })
65
69
  onSuccess()
66
- } catch (err) { onError(err.message) } finally { setLoading(false) }
70
+ } catch (err) { onError(await invokeErrorMessage(err, t('booking.bookingDetail.errorDecline'))) } finally { setLoading(false) }
67
71
  }
68
72
  if (!expanded) return <Button variant="danger" onClick={() => setExpanded(true)}>{t('booking.bookingDetail.decline')}</Button>
69
73
  return (
@@ -72,7 +76,7 @@ function DeclineForm ({ booking, onSuccess, onError, t }) {
72
76
  <Textarea value={reason} onChange={e => setReason(e.target.value)} rows={3} placeholder={t('booking.bookingDetail.declinePlaceholder')} />
73
77
  <Text variant="small" color="secondary">{t('booking.bookingDetail.declineReasonHint')}</Text>
74
78
  <View layout="row" gap="s">
75
- <Button variant="danger" disabled={loading} onClick={handleDecline}>{loading ? t('booking.bookingDetail.declining') : t('booking.bookingDetail.declineConfirm')}</Button>
79
+ <Button data-action={DeclineBooking.id} variant="danger" disabled={loading} onClick={handleDecline}>{loading ? t('booking.bookingDetail.declining') : t('booking.bookingDetail.declineConfirm')}</Button>
76
80
  <Button variant="neutral" onClick={() => setExpanded(false)}>{t('booking.bookingDetail.cancelButton')}</Button>
77
81
  </View>
78
82
  </View>
@@ -80,16 +84,15 @@ function DeclineForm ({ booking, onSuccess, onError, t }) {
80
84
  )
81
85
  }
82
86
 
83
- function CancelForm ({ booking, onSuccess, onError, t }) {
87
+ function CancelForm ({ booking, onSuccess, onError, t, sdk }) {
84
88
  const [loading, setLoading] = useState(false)
85
89
  const [expanded, setExpanded] = useState(false)
86
90
  const handleCancel = async () => {
87
91
  setLoading(true)
88
92
  try {
89
- const res = await fetch('/actions/booking/cancel', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bookingId: booking.id }) })
90
- if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data?.message ?? t('booking.bookingDetail.errorCancel')) }
93
+ await sdk.invoke(CancelBooking, { bookingId: booking.id })
91
94
  onSuccess()
92
- } catch (err) { onError(err.message) } finally { setLoading(false) }
95
+ } catch (err) { onError(await invokeErrorMessage(err, t('booking.bookingDetail.errorCancel'))) } finally { setLoading(false) }
93
96
  }
94
97
  if (!expanded) return <Button variant="danger" onClick={() => setExpanded(true)}>{t('booking.bookingDetail.cancelMeeting')}</Button>
95
98
  return (
@@ -97,7 +100,7 @@ function CancelForm ({ booking, onSuccess, onError, t }) {
97
100
  <View gap="s">
98
101
  <Text color="secondary">{t('booking.bookingDetail.cancelBody')}</Text>
99
102
  <View layout="row" gap="s">
100
- <Button variant="danger" disabled={loading} onClick={handleCancel}>{loading ? t('booking.bookingDetail.cancelling') : t('booking.bookingDetail.cancelConfirm')}</Button>
103
+ <Button data-action={CancelBooking.id} variant="danger" disabled={loading} onClick={handleCancel}>{loading ? t('booking.bookingDetail.cancelling') : t('booking.bookingDetail.cancelConfirm')}</Button>
101
104
  <Button variant="neutral" onClick={() => setExpanded(false)}>{t('booking.bookingDetail.cancelButton')}</Button>
102
105
  </View>
103
106
  </View>
@@ -107,6 +110,7 @@ function CancelForm ({ booking, onSuccess, onError, t }) {
107
110
 
108
111
  export default function BookingDetailPage ({ bookingId: bookingIdProp }) {
109
112
  const { t, language } = useLocale()
113
+ const sdk = useSdk()
110
114
  const router = useRouter()
111
115
  const bookingId = bookingIdProp ?? router?.params?.bookingId
112
116
  const [booking, setBooking] = useState(null)
@@ -124,18 +128,19 @@ export default function BookingDetailPage ({ bookingId: bookingIdProp }) {
124
128
  if (!bookingId) return
125
129
  setLoading(true); setError(null)
126
130
  try {
127
- const res = await fetch('/actions/booking/list', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) })
128
- if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data?.message ?? t('booking.bookingDetail.errorStatus', { status: res.status })) }
129
- const all = await res.json()
131
+ const all = await sdk.invoke(ListBookings, {})
130
132
  const found = all.find(b => b.id === bookingId)
131
133
  if (!found) throw new Error(t('booking.bookingDetail.notFound'))
132
134
  setBooking(found)
133
135
  } catch (err) { setError(err.message) } finally { setLoading(false) }
134
- }, [bookingId, t])
136
+ }, [bookingId, sdk, t])
135
137
 
136
138
  useEffect(() => { loadBooking() }, [loadBooking])
137
139
 
138
- const goBack = () => { if (window.history.length > 1) window.history.back(); else window.location.href = '/bokningar' }
140
+ const goBack = () => {
141
+ if (window.history.length > 1) window.history.back()
142
+ else window.location.href = router.getHref('booking/bookings')
143
+ }
139
144
 
140
145
  return (
141
146
  <View gap="m" surface="primary" style={{ padding: 'var(--space-m) var(--space-l)', height: '100%', overflowY: 'auto' }}>
@@ -173,12 +178,12 @@ export default function BookingDetailPage ({ bookingId: bookingIdProp }) {
173
178
  {actionError && <Alert variant="danger">{actionError}</Alert>}
174
179
  {booking.status === 'pending' && (
175
180
  <View gap="s">
176
- <ConfirmForm booking={booking} onSuccess={() => { setActionError(null); loadBooking() }} onError={setActionError} t={t} />
177
- <DeclineForm booking={booking} onSuccess={() => { setActionError(null); loadBooking() }} onError={setActionError} t={t} />
181
+ <ConfirmForm booking={booking} sdk={sdk} onSuccess={() => { setActionError(null); loadBooking() }} onError={setActionError} t={t} />
182
+ <DeclineForm booking={booking} sdk={sdk} onSuccess={() => { setActionError(null); loadBooking() }} onError={setActionError} t={t} />
178
183
  </View>
179
184
  )}
180
185
  {booking.status === 'confirmed' && (
181
- <CancelForm booking={booking} onSuccess={() => { setActionError(null); loadBooking() }} onError={setActionError} t={t} />
186
+ <CancelForm booking={booking} sdk={sdk} onSuccess={() => { setActionError(null); loadBooking() }} onError={setActionError} t={t} />
182
187
  )}
183
188
  </View>
184
189
  )}
@@ -0,0 +1,10 @@
1
+ import { ResourcesQueries } from '@ossy/resources/server'
2
+
3
+ /**
4
+ * List booking resources by type (and optional workspace).
5
+ */
6
+ export function getBookingResources ({ type, belongsTo }) {
7
+ const query = { type }
8
+ if (belongsTo) query.belongsTo = belongsTo
9
+ return ResourcesQueries.GetResources(query)
10
+ }
@@ -1,7 +1,14 @@
1
1
  import React, { useState, useEffect, useCallback, useMemo } from 'react'
2
+ import { useRouter } from '@ossy/router-react'
2
3
  import { View, Title, Text, Tabs, Alert, useLocale } from '@ossy/design-system'
4
+ import { useSdk } from '@ossy/sdk-react'
3
5
  import { Definition } from './Definition.js'
4
6
  import { BookingCard } from './booking-card.component.jsx'
7
+ import { metadata as ConfirmBooking } from './confirm-booking.action.js'
8
+ import { metadata as DeclineBooking } from './decline-booking.action.js'
9
+ import { metadata as CancelBooking } from './cancel-booking.action.js'
10
+ import { metadata as ListBookings } from './list-bookings.action.js'
11
+ import { invokeErrorMessage } from './invoke-error-message.js'
5
12
 
6
13
  export const metadata = {
7
14
  id: 'booking/bookings',
@@ -13,6 +20,8 @@ export const metadata = {
13
20
 
14
21
  export default function BookingsPage () {
15
22
  const { t } = useLocale()
23
+ const sdk = useSdk()
24
+ const router = useRouter()
16
25
  const [activeTab, setActiveTab] = useState('pending')
17
26
  const [bookings, setBookings] = useState([])
18
27
  const [loading, setLoading] = useState(true)
@@ -43,23 +52,14 @@ export default function BookingsPage () {
43
52
  setLoading(true)
44
53
  setError(null)
45
54
  try {
46
- const res = await fetch('/actions/booking/list', {
47
- method: 'POST',
48
- headers: { 'Content-Type': 'application/json' },
49
- body: JSON.stringify({}),
50
- })
51
- if (!res.ok) {
52
- const data = await res.json().catch(() => ({}))
53
- throw new Error(data?.message ?? t('booking.bookings.errorStatus', { status: res.status }))
54
- }
55
- const data = await res.json()
55
+ const data = await sdk.invoke(ListBookings, {})
56
56
  setBookings(data)
57
57
  } catch (err) {
58
58
  setError(err.message)
59
59
  } finally {
60
60
  setLoading(false)
61
61
  }
62
- }, [t])
62
+ }, [sdk, t])
63
63
 
64
64
  useEffect(() => {
65
65
  loadBookings()
@@ -70,46 +70,34 @@ export default function BookingsPage () {
70
70
  const visible = activeTab === 'pending' ? pending : activeTab === 'confirmed' ? confirmed : bookings
71
71
 
72
72
  const handleConfirm = async (booking) => {
73
- const res = await fetch('/actions/booking/confirm', {
74
- method: 'POST',
75
- headers: { 'Content-Type': 'application/json' },
76
- body: JSON.stringify({ bookingId: booking.id }),
77
- })
78
- if (!res.ok) {
79
- const data = await res.json().catch(() => ({}))
80
- throw new Error(data?.message ?? t('booking.bookings.errorConfirm'))
73
+ try {
74
+ await sdk.invoke(ConfirmBooking, { bookingId: booking.id })
75
+ await loadBookings()
76
+ } catch (err) {
77
+ throw new Error(await invokeErrorMessage(err, t('booking.bookings.errorConfirm')))
81
78
  }
82
- await loadBookings()
83
79
  }
84
80
 
85
81
  const handleDecline = async (booking) => {
86
- const res = await fetch('/actions/booking/decline', {
87
- method: 'POST',
88
- headers: { 'Content-Type': 'application/json' },
89
- body: JSON.stringify({ bookingId: booking.id }),
90
- })
91
- if (!res.ok) {
92
- const data = await res.json().catch(() => ({}))
93
- throw new Error(data?.message ?? t('booking.bookings.errorDecline'))
82
+ try {
83
+ await sdk.invoke(DeclineBooking, { bookingId: booking.id })
84
+ await loadBookings()
85
+ } catch (err) {
86
+ throw new Error(await invokeErrorMessage(err, t('booking.bookings.errorDecline')))
94
87
  }
95
- await loadBookings()
96
88
  }
97
89
 
98
90
  const handleCancel = async (booking) => {
99
- const res = await fetch('/actions/booking/cancel', {
100
- method: 'POST',
101
- headers: { 'Content-Type': 'application/json' },
102
- body: JSON.stringify({ bookingId: booking.id }),
103
- })
104
- if (!res.ok) {
105
- const data = await res.json().catch(() => ({}))
106
- throw new Error(data?.message ?? t('booking.bookings.errorCancel'))
91
+ try {
92
+ await sdk.invoke(CancelBooking, { bookingId: booking.id })
93
+ await loadBookings()
94
+ } catch (err) {
95
+ throw new Error(await invokeErrorMessage(err, t('booking.bookings.errorCancel')))
107
96
  }
108
- await loadBookings()
109
97
  }
110
98
 
111
99
  const handleView = (booking) => {
112
- window.location.href = `/bokningar/${booking.id}`
100
+ window.location.href = router.getHref({ id: 'booking/booking-detail', params: { bookingId: booking.id } })
113
101
  }
114
102
 
115
103
  const empty = EMPTY_MESSAGES[activeTab] ?? EMPTY_MESSAGES.all
@@ -1,61 +1 @@
1
- import { Aggregate } from '@ossy/event-store'
2
- import { Resource, ResourcesEvents } from '@ossy/resources'
3
- import BookingCancellationEmail from './booking-cancellation.email.jsx'
4
-
5
- export const id = 'booking/cancel'
6
- export const access = 'authenticated'
7
-
8
- export async function run({ payload, req, log, integrations }) {
9
- const bookingId = payload?.bookingId ?? req?.params?.bookingId
10
- const workspaceId = payload?.workspaceId ?? req?.workspaceId
11
-
12
- if (!bookingId) throw Object.assign(new Error('bookingId is required'), { status: 400 })
13
-
14
- log?.info(`[booking/cancel] Cancelling booking ${bookingId}`)
15
-
16
- const booking = await Aggregate.Of(Resource, bookingId).then(Aggregate.View())
17
- if (!booking?.id) throw Object.assign(new Error('Booking not found'), { status: 404 })
18
-
19
- if (booking.belongsTo !== workspaceId) {
20
- throw Object.assign(new Error('You do not have permission to cancel this booking'), { status: 403 })
21
- }
22
-
23
- if (booking.content?.status === 'cancelled') {
24
- throw Object.assign(new Error('Booking is already cancelled'), { status: 409 })
25
- }
26
-
27
- const updated = await Aggregate.Of(Resource, bookingId)
28
- .then(Aggregate.Add(
29
- ResourcesEvents.ContentUpdated({
30
- createdBy: req?.userId ?? 'system',
31
- content: { ...booking.content, status: 'cancelled' },
32
- }),
33
- ))
34
- .then(Aggregate.Save())
35
-
36
- log?.info(`[booking/cancel] Booking ${bookingId} cancelled`)
37
-
38
- // Send cancellation email to client
39
- const emailClient = integrations?.get?.('email')
40
- if (emailClient && booking.content?.clientEmail) {
41
- try {
42
- await emailClient.sendTemplate(
43
- BookingCancellationEmail,
44
- {
45
- clientName: booking.content.clientName,
46
- startAt: booking.content.startsAt,
47
- duration: booking.content.duration,
48
- },
49
- {
50
- to: booking.content.clientEmail,
51
- from: 'noreply@ossy.se',
52
- subject: 'Din bokning har avbokats',
53
- },
54
- )
55
- } catch (err) {
56
- log?.error('[booking/cancel] Failed to send cancellation email', err)
57
- }
58
- }
59
-
60
- return { id: bookingId, status: 'cancelled' }
61
- }
1
+ export const metadata = { id: 'booking/cancel', access: 'authenticated' }
@@ -0,0 +1,60 @@
1
+ import { Aggregate } from '@ossy/event-store'
2
+ import { Resource, ResourcesEvents } from '@ossy/resources/server'
3
+ import BookingCancellationEmail from './booking-cancellation.email.jsx'
4
+
5
+ export const metadata = { id: 'booking/cancel' }
6
+
7
+ export async function run({ payload, req, log, integrations }) {
8
+ const bookingId = payload?.bookingId ?? req?.params?.bookingId
9
+ const workspaceId = payload?.workspaceId ?? req?.workspaceId
10
+
11
+ if (!bookingId) throw Object.assign(new Error('bookingId is required'), { status: 400 })
12
+
13
+ log?.info(`[booking/cancel] Cancelling booking ${bookingId}`)
14
+
15
+ const booking = await Aggregate.Of(Resource, bookingId).then(Aggregate.View())
16
+ if (!booking?.id) throw Object.assign(new Error('Booking not found'), { status: 404 })
17
+
18
+ if (booking.belongsTo !== workspaceId) {
19
+ throw Object.assign(new Error('You do not have permission to cancel this booking'), { status: 403 })
20
+ }
21
+
22
+ if (booking.content?.status === 'cancelled') {
23
+ throw Object.assign(new Error('Booking is already cancelled'), { status: 409 })
24
+ }
25
+
26
+ const updated = await Aggregate.Of(Resource, bookingId)
27
+ .then(Aggregate.Add(
28
+ ResourcesEvents.ContentUpdated({
29
+ createdBy: req?.userId ?? 'system',
30
+ content: { ...booking.content, status: 'cancelled' },
31
+ }),
32
+ ))
33
+ .then(Aggregate.Save())
34
+
35
+ log?.info(`[booking/cancel] Booking ${bookingId} cancelled`)
36
+
37
+ // Send cancellation email to client
38
+ const emailClient = integrations?.get?.('email')
39
+ if (emailClient && booking.content?.clientEmail) {
40
+ try {
41
+ await emailClient.sendTemplate(
42
+ BookingCancellationEmail,
43
+ {
44
+ clientName: booking.content.clientName,
45
+ startAt: booking.content.startsAt,
46
+ duration: booking.content.duration,
47
+ },
48
+ {
49
+ to: booking.content.clientEmail,
50
+ from: 'noreply@ossy.se',
51
+ subject: 'Din bokning har avbokats',
52
+ },
53
+ )
54
+ } catch (err) {
55
+ log?.error('[booking/cancel] Failed to send cancellation email', err)
56
+ }
57
+ }
58
+
59
+ return { id: bookingId, status: 'cancelled' }
60
+ }