@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.
- 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
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
18
|
-
"@ossy/event-store": "^1.8.
|
|
19
|
-
"@ossy/observability": "^1.8.
|
|
20
|
-
"@ossy/platform": "^1.39.
|
|
21
|
-
"@ossy/resources": "^1.12.
|
|
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": "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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 = () => {
|
|
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
|
+
}
|
package/src/bookings.page.jsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|