@ossy/booking 1.16.2 → 3.0.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 +48 -21
- package/package.json +11 -8
- package/src/AvailabilityEditor.jsx +2 -13
- package/src/BookingList.jsx +1 -1
- package/src/BookingProductHome.jsx +38 -0
- package/src/BookingSalesPage.jsx +55 -0
- package/src/Definition.js +2 -2
- package/src/HeroCover.jsx +5 -5
- package/src/SalesSection.jsx +7 -15
- package/src/ServiceCard.jsx +2 -2
- package/src/availability-action-maps.js +39 -0
- package/src/availability-engine.js +18 -14
- package/src/availability-engine.spec.js +36 -4
- package/src/availability-setup.page.jsx +45 -46
- package/src/{availability.resource.js → availability.schema.js} +1 -5
- package/src/booking-cancellation.email.jsx +1 -1
- package/src/booking-card.component.jsx +3 -3
- package/src/booking-confirmation.email.jsx +5 -3
- package/src/booking-detail.page.jsx +15 -10
- package/src/booking-email-token.js +45 -0
- package/src/booking-home-content.js +50 -0
- package/src/booking-list.aggregate.js +65 -0
- package/src/booking-reminder.email.jsx +4 -3
- package/src/booking-request.email.jsx +5 -2
- package/src/booking-resources.js +3 -3
- package/src/{booking.resource.js → booking.schema.js} +12 -3
- package/src/bookings.component.jsx +26 -25
- package/src/bookings.page.jsx +29 -16
- package/src/cancel-booking.action.js +1 -1
- package/src/cancel-booking.task.js +14 -17
- package/src/confirm-booking.action.js +1 -1
- package/src/confirm-booking.api.js +35 -0
- package/src/confirm-booking.task.js +26 -24
- package/src/create-booking.action.js +1 -1
- package/src/create-booking.task.js +73 -37
- package/src/create-service.action.js +5 -1
- package/src/create-service.task.js +7 -7
- package/src/decline-booking.action.js +1 -1
- package/src/decline-booking.api.js +35 -0
- package/src/decline-booking.task.js +15 -18
- package/src/delete-service.action.js +1 -1
- package/src/delete-service.task.js +12 -15
- package/src/en.translations.json +83 -37
- package/src/get-availability.action.js +1 -1
- package/src/get-availability.task.js +5 -4
- package/src/get-available-slots.action.js +1 -1
- package/src/get-available-slots.task.js +16 -13
- package/src/get-services.action.js +1 -1
- package/src/get-services.task.js +13 -6
- package/src/home.page.jsx +14 -46
- package/src/index.js +17 -0
- package/src/list-bookings.action.js +1 -1
- package/src/list-bookings.task.js +33 -16
- package/src/locations.js +6 -4
- package/src/open-availability-setup.action.js +5 -0
- package/src/open-bookings.action.js +5 -0
- package/src/open-create-service.action.js +5 -0
- package/src/open-services.action.js +5 -0
- package/src/provider-availability.flow.js +39 -0
- package/src/provider-fields.js +34 -0
- package/src/provider-onboard.flow.js +25 -0
- package/src/public-booking-form.form.js +7 -0
- package/src/public-booking-form.schema.js +12 -0
- package/src/public-booking.page.jsx +60 -57
- package/src/resolve-workspace-contact.js +55 -0
- package/src/save-availability.action.js +5 -1
- package/src/save-availability.task.js +26 -28
- package/src/schema-ids.js +7 -0
- package/src/select-duration-30.action.js +4 -0
- package/src/select-duration-60.action.js +4 -0
- package/src/select-duration-90.action.js +4 -0
- package/src/send-booking-reminder.task.js +39 -14
- package/src/service-card-slot.component.jsx +4 -4
- package/src/service-detail.component.jsx +4 -4
- package/src/service.form.js +7 -0
- package/src/service.schema.js +14 -0
- package/src/services.page.jsx +62 -58
- package/src/stories/fixtures.js +149 -0
- package/src/sv.translations.json +83 -37
- package/src/time.js +27 -0
- package/src/toggle-availability-day-0.action.js +4 -0
- package/src/toggle-availability-day-1.action.js +4 -0
- package/src/toggle-availability-day-2.action.js +4 -0
- package/src/toggle-availability-day-3.action.js +4 -0
- package/src/toggle-availability-day-4.action.js +4 -0
- package/src/toggle-availability-day-5.action.js +4 -0
- package/src/toggle-availability-day-6.action.js +4 -0
- package/src/update-service.action.js +1 -1
- package/src/update-service.task.js +13 -16
- package/src/workspace-services.js +5 -0
- package/src/moduleStatus.js +0 -2
- package/src/service.resource.js +0 -32
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import providerOnboard from './provider-onboard.flow.js'
|
|
2
|
+
import { metadata as SaveAvailability } from './save-availability.action.js'
|
|
3
|
+
import { metadata as OpenAvailabilitySetup } from './open-availability-setup.action.js'
|
|
4
|
+
import {
|
|
5
|
+
ToggleAvailabilityDay1,
|
|
6
|
+
ToggleAvailabilityDay2,
|
|
7
|
+
ToggleAvailabilityDay3,
|
|
8
|
+
ToggleAvailabilityDay4,
|
|
9
|
+
ToggleAvailabilityDay5,
|
|
10
|
+
SelectDuration60,
|
|
11
|
+
} from './availability-action-maps.js'
|
|
12
|
+
|
|
13
|
+
export const metadata = {
|
|
14
|
+
id: '@ossy/booking/flows/provider-availability',
|
|
15
|
+
feature: 'booking',
|
|
16
|
+
requires: ['server', 'database'],
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Provider signs up, enables booking, and configures weekly availability.
|
|
21
|
+
*/
|
|
22
|
+
export default {
|
|
23
|
+
title: 'Set up availability',
|
|
24
|
+
description:
|
|
25
|
+
'New service provider registers, verifies email, enables booking, and saves weekly availability',
|
|
26
|
+
steps: [
|
|
27
|
+
...providerOnboard.steps,
|
|
28
|
+
{ action: OpenAvailabilitySetup },
|
|
29
|
+
{ result: { action: SaveAvailability, timeout: 10000 } },
|
|
30
|
+
{ action: ToggleAvailabilityDay1 },
|
|
31
|
+
{ action: ToggleAvailabilityDay2 },
|
|
32
|
+
{ action: ToggleAvailabilityDay3 },
|
|
33
|
+
{ action: ToggleAvailabilityDay4 },
|
|
34
|
+
{ action: ToggleAvailabilityDay5 },
|
|
35
|
+
{ action: SelectDuration60 },
|
|
36
|
+
{ action: SaveAvailability },
|
|
37
|
+
{ result: { selector: '[data-availability-status="saved"]', timeout: 10000 } },
|
|
38
|
+
],
|
|
39
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Read provider/workspace fields; accept legacy consultant* names during transition. */
|
|
2
|
+
|
|
3
|
+
export function workspaceIdFromPayload (payload) {
|
|
4
|
+
return payload?.workspaceId ?? payload?.providerId ?? payload?.consultantId
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function workspaceIdFromContent (content) {
|
|
8
|
+
return content?.workspaceId ?? content?.providerId ?? content?.consultantId
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** @deprecated Prefer workspaceIdFromPayload — providerId is the legacy alias for workspaceId. */
|
|
12
|
+
export function resolveProviderId (payload) {
|
|
13
|
+
return workspaceIdFromPayload(payload)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function resolveProviderSlug (payload, routerParams) {
|
|
17
|
+
return payload?.providerSlug
|
|
18
|
+
?? payload?.consultantSlug
|
|
19
|
+
?? routerParams?.providerSlug
|
|
20
|
+
?? routerParams?.consultantSlug
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolveProviderEmail (payload) {
|
|
24
|
+
return payload?.providerEmail ?? payload?.consultantEmail
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function resolveProviderName (payload) {
|
|
28
|
+
return payload?.providerName ?? payload?.consultantName
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** @deprecated Prefer workspaceIdFromContent. */
|
|
32
|
+
export function providerIdFromContent (content) {
|
|
33
|
+
return workspaceIdFromContent(content)
|
|
34
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import signUpFlow from '@ossy/authentication/sign-up.flow.js'
|
|
2
|
+
import { EnableService } from '@ossy/workspaces'
|
|
3
|
+
|
|
4
|
+
export const metadata = {
|
|
5
|
+
id: '@ossy/booking/flows/provider-onboard',
|
|
6
|
+
feature: 'booking',
|
|
7
|
+
requires: ['server', 'database'],
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Step 1 — provider signs up and enables the booking capability for their workspace.
|
|
12
|
+
*
|
|
13
|
+
* Next flows (catalog setup, client booking, confirm) will compose after this.
|
|
14
|
+
*/
|
|
15
|
+
export default {
|
|
16
|
+
title: 'Enable booking',
|
|
17
|
+
description: 'New service provider registers, verifies email, and activates booking from the booking home sales page',
|
|
18
|
+
steps: [
|
|
19
|
+
...signUpFlow.steps,
|
|
20
|
+
{ page: '@booking/home' },
|
|
21
|
+
{ result: { action: { ...EnableService, service: '@ossy/booking' }, timeout: 10000 } },
|
|
22
|
+
{ action: { ...EnableService, service: '@ossy/booking' } },
|
|
23
|
+
{ result: { selector: '[data-booking-status="on"]', timeout: 10000 } },
|
|
24
|
+
],
|
|
25
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Public booking client-details form — UI fields and flow faker schema. */
|
|
2
|
+
export default {
|
|
3
|
+
id: '@ossy/booking/schema/public-booking-form',
|
|
4
|
+
name: 'Public booking',
|
|
5
|
+
icon: 'calendar-check',
|
|
6
|
+
fields: [
|
|
7
|
+
{ name: 'clientName', type: 'text', required: true },
|
|
8
|
+
{ name: 'clientEmail', type: 'email', required: true },
|
|
9
|
+
{ name: 'clientMessage', type: 'textarea' },
|
|
10
|
+
{ name: 'attachments', type: 'file', max: 5, accept: 'image/*,.pdf' },
|
|
11
|
+
],
|
|
12
|
+
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import React, { useState, useEffect
|
|
1
|
+
import React, { useState, useEffect } from 'react'
|
|
2
2
|
import { useRouter } from '@ossy/router-react'
|
|
3
|
-
import { View,
|
|
3
|
+
import { View, Text, Button, Alert, Form, FieldFactory, FormStatus, useLocale } from '@ossy/design-system'
|
|
4
4
|
import { useSdk } from '@ossy/sdk-react'
|
|
5
|
+
import { useDocumentValidator } from '@ossy/resources'
|
|
5
6
|
import { metadata as CreateBooking } from './create-booking.action.js'
|
|
7
|
+
import { metadata as PublicBookingForm } from './public-booking-form.form.js'
|
|
8
|
+
import publicBookingTemplate from './public-booking-form.schema.js'
|
|
6
9
|
import { metadata as GetServices } from './get-services.action.js'
|
|
7
10
|
import { metadata as GetAvailableSlots } from './get-available-slots.action.js'
|
|
8
11
|
import { invokeErrorMessage } from './invoke-error-message.js'
|
|
@@ -81,7 +84,7 @@ function MonthCalendar ({ year, month, slotsGrouped, selectedDate, onSelectDate,
|
|
|
81
84
|
const isPast = dateKey < todayKey
|
|
82
85
|
const disabled = !hasSlots || isPast
|
|
83
86
|
return (
|
|
84
|
-
<Button key={dateKey} variant={isSelected ? '
|
|
87
|
+
<Button key={dateKey} data-action="public-booking/pick-date" variant={isSelected ? 'tag-active' : hasSlots && !isPast ? 'tag' : 'neutral'} disabled={disabled}
|
|
85
88
|
onClick={() => !disabled && onSelectDate(dateKey)} style={{ padding: '8px 4px', minWidth: 0 }}>{day}</Button>
|
|
86
89
|
)
|
|
87
90
|
})}
|
|
@@ -91,11 +94,11 @@ function MonthCalendar ({ year, month, slotsGrouped, selectedDate, onSelectDate,
|
|
|
91
94
|
}
|
|
92
95
|
|
|
93
96
|
function SlotPicker ({ slots, selectedSlot, onSelectSlot, t, language }) {
|
|
94
|
-
if (!slots?.length) return <Text color="secondary"
|
|
97
|
+
if (!slots?.length) return <Text color="secondary" text="publicBooking.noSlots" />
|
|
95
98
|
return (
|
|
96
99
|
<View layout="row" gap="s" style={{ flexWrap: 'wrap' }}>
|
|
97
100
|
{slots.map(slot => (
|
|
98
|
-
<Button key={slot.startAt} variant={selectedSlot?.startAt === slot.startAt ? '
|
|
101
|
+
<Button key={slot.startAt} data-action="public-booking/pick-slot" variant={selectedSlot?.startAt === slot.startAt ? 'tag-active' : 'tag'} onClick={() => onSelectSlot(slot)}>
|
|
99
102
|
{formatSlotRange(slot.startAt, slot.duration, language)}
|
|
100
103
|
</Button>
|
|
101
104
|
))}
|
|
@@ -103,59 +106,54 @@ function SlotPicker ({ slots, selectedSlot, onSelectSlot, t, language }) {
|
|
|
103
106
|
)
|
|
104
107
|
}
|
|
105
108
|
|
|
106
|
-
function BookingForm ({ slot, service,
|
|
107
|
-
const sdk = useSdk()
|
|
108
|
-
const [clientName, setClientName] = useState('')
|
|
109
|
-
const [clientEmail, setClientEmail] = useState('')
|
|
110
|
-
const [clientMessage, setClientMessage] = useState('')
|
|
109
|
+
function BookingForm ({ slot, service, providerSlug, providerContact, onSuccess, t }) {
|
|
110
|
+
const sdk = useSdk()
|
|
111
111
|
const [submitting, setSubmitting] = useState(false)
|
|
112
112
|
const [error, setError] = useState(null)
|
|
113
|
+
const validate = useDocumentValidator(publicBookingTemplate)
|
|
113
114
|
|
|
114
|
-
const handleSubmit = async (
|
|
115
|
-
e.preventDefault()
|
|
116
|
-
if (!clientName.trim() || !clientEmail.trim()) return
|
|
115
|
+
const handleSubmit = async (data) => {
|
|
117
116
|
setSubmitting(true); setError(null)
|
|
118
117
|
try {
|
|
119
118
|
await sdk.invoke(CreateBooking, {
|
|
120
|
-
|
|
119
|
+
providerSlug,
|
|
120
|
+
workspaceId: providerContact?.workspaceId,
|
|
121
|
+
providerEmail: providerContact?.providerEmail,
|
|
122
|
+
providerName: providerContact?.providerName,
|
|
121
123
|
startAt: slot.startAt,
|
|
122
124
|
duration: slot.duration,
|
|
123
125
|
serviceId: service?.id,
|
|
124
|
-
clientName: clientName.trim(),
|
|
125
|
-
clientEmail: clientEmail.trim(),
|
|
126
|
-
clientMessage: clientMessage.trim(),
|
|
126
|
+
clientName: String(data.clientName ?? '').trim(),
|
|
127
|
+
clientEmail: String(data.clientEmail ?? '').trim(),
|
|
128
|
+
clientMessage: String(data.clientMessage ?? '').trim(),
|
|
129
|
+
attachments: Array.isArray(data.attachments) ? data.attachments : undefined,
|
|
127
130
|
})
|
|
128
131
|
onSuccess()
|
|
129
132
|
} catch (err) { setError(await invokeErrorMessage(err, t('publicBooking.form.error', { status: '' }))) } finally { setSubmitting(false) }
|
|
130
133
|
}
|
|
131
134
|
|
|
132
135
|
return (
|
|
133
|
-
<
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
</View>
|
|
147
|
-
{error && <Alert variant="danger">{error}</Alert>}
|
|
148
|
-
<Button data-action={CreateBooking.id} type="submit" variant="primary" disabled={submitting}>{submitting ? t('publicBooking.form.submitting') : t('publicBooking.form.submit')}</Button>
|
|
149
|
-
</View>
|
|
150
|
-
</form>
|
|
136
|
+
<Form
|
|
137
|
+
id={PublicBookingForm.id}
|
|
138
|
+
schemaId={PublicBookingForm.schemaId}
|
|
139
|
+
fields={publicBookingTemplate.fields}
|
|
140
|
+
defaultData={{}}
|
|
141
|
+
onSubmit={handleSubmit}
|
|
142
|
+
validate={validate}
|
|
143
|
+
status={submitting ? [FormStatus.Submitting] : []}
|
|
144
|
+
>
|
|
145
|
+
<FieldFactory />
|
|
146
|
+
{error && <Alert variant="danger">{error}</Alert>}
|
|
147
|
+
<Button data-action={CreateBooking.id} type="submit" variant="primary" disabled={submitting}>{submitting ? t('publicBooking.form.submitting') : t('publicBooking.form.submit')}</Button>
|
|
148
|
+
</Form>
|
|
151
149
|
)
|
|
152
150
|
}
|
|
153
151
|
|
|
154
152
|
function PendingScreen ({ slot, t, language }) {
|
|
155
153
|
return (
|
|
156
154
|
<View gap="m" alignItems="center" style={{ textAlign: 'center' }}>
|
|
157
|
-
<
|
|
158
|
-
<Text color="secondary"
|
|
155
|
+
<Text variant="heading-secondary" as="h2" text="publicBooking.pending.title" />
|
|
156
|
+
<Text color="secondary" text="publicBooking.pending.body" />
|
|
159
157
|
<Alert variant="warning" title={t('publicBooking.pending.requestedTime')}>
|
|
160
158
|
<Text>{formatDate(slot.startAt, t, language)}</Text>
|
|
161
159
|
<Text color="secondary">{t('publicBooking.pending.duration', { minutes: slot.duration })}</Text>
|
|
@@ -167,12 +165,13 @@ function PendingScreen ({ slot, t, language }) {
|
|
|
167
165
|
export const metadata = {
|
|
168
166
|
id: 'public-booking',
|
|
169
167
|
public: true,
|
|
170
|
-
|
|
168
|
+
layout: '@ossy/app/layout/blank',
|
|
169
|
+
path: { sv: '/boka/:providerSlug', en: '/book/:providerSlug' },
|
|
171
170
|
}
|
|
172
171
|
|
|
173
172
|
function ServicePicker ({ services, selectedService, onSelect, t, language }) {
|
|
174
173
|
if (!services?.length) {
|
|
175
|
-
return <Text color="secondary"
|
|
174
|
+
return <Text color="secondary" text="publicBooking.noServices" />
|
|
176
175
|
}
|
|
177
176
|
const formatPrice = (cents, currency) => {
|
|
178
177
|
if (!cents) return t('publicBooking.serviceFree')
|
|
@@ -183,7 +182,8 @@ function ServicePicker ({ services, selectedService, onSelect, t, language }) {
|
|
|
183
182
|
{services.map(service => (
|
|
184
183
|
<Button
|
|
185
184
|
key={service.id}
|
|
186
|
-
|
|
185
|
+
data-action="public-booking/pick-service"
|
|
186
|
+
variant={selectedService?.id === service.id ? 'tag-active' : 'tag'}
|
|
187
187
|
onClick={() => onSelect(service)}
|
|
188
188
|
style={{ justifyContent: 'flex-start', textAlign: 'left' }}
|
|
189
189
|
>
|
|
@@ -199,13 +199,14 @@ function ServicePicker ({ services, selectedService, onSelect, t, language }) {
|
|
|
199
199
|
)
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
-
export default function PublicBookingPage ({
|
|
202
|
+
export default function PublicBookingPage ({ providerSlug }) {
|
|
203
203
|
const { t, language } = useLocale()
|
|
204
204
|
const sdk = useSdk()
|
|
205
205
|
const router = useRouter()
|
|
206
|
-
const slug =
|
|
206
|
+
const slug = providerSlug ?? router?.params?.providerSlug ?? router?.params?.consultantSlug
|
|
207
207
|
const today = new Date()
|
|
208
208
|
const [services, setServices] = useState([])
|
|
209
|
+
const [providerContact, setProviderContact] = useState(null)
|
|
209
210
|
const [loadingServices, setLoadingServices] = useState(true)
|
|
210
211
|
const [servicesError, setServicesError] = useState(null)
|
|
211
212
|
const [selectedService, setSelectedService] = useState(null)
|
|
@@ -222,10 +223,12 @@ export default function PublicBookingPage ({ consultantSlug }) {
|
|
|
222
223
|
if (!slug) return
|
|
223
224
|
setLoadingServices(true)
|
|
224
225
|
setServicesError(null)
|
|
225
|
-
sdk.invoke(GetServices, {
|
|
226
|
+
sdk.invoke(GetServices, { providerSlug: slug, includeContact: true })
|
|
226
227
|
.then(data => {
|
|
227
|
-
const list = Array.isArray(data) ? data : []
|
|
228
|
+
const list = Array.isArray(data) ? data : (data?.services ?? [])
|
|
229
|
+
const contact = Array.isArray(data) ? null : data?.contact
|
|
228
230
|
setServices(list)
|
|
231
|
+
setProviderContact(contact)
|
|
229
232
|
if (list.length === 1) setSelectedService(list[0])
|
|
230
233
|
setLoadingServices(false)
|
|
231
234
|
})
|
|
@@ -238,7 +241,7 @@ export default function PublicBookingPage ({ consultantSlug }) {
|
|
|
238
241
|
const from = new Date().toISOString()
|
|
239
242
|
const to = new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString()
|
|
240
243
|
sdk.invoke(GetAvailableSlots, {
|
|
241
|
-
|
|
244
|
+
providerSlug: slug,
|
|
242
245
|
serviceId: selectedService.id,
|
|
243
246
|
from,
|
|
244
247
|
to,
|
|
@@ -252,10 +255,10 @@ export default function PublicBookingPage ({ consultantSlug }) {
|
|
|
252
255
|
|
|
253
256
|
return (
|
|
254
257
|
<View surface="base" layout="off-center-s" inset="s" style={{ minHeight: '100dvh' }}>
|
|
255
|
-
<View inset="m" roundness="l" surface="primary"
|
|
258
|
+
<View inset="m" roundness="l" surface="primary" data-region="content" gap="l" style={{ maxWidth: 720, width: '100%' }}>
|
|
256
259
|
<View gap="xs" style={{ borderBottom: '1px solid var(--separator)', paddingBottom: 'var(--space-m)' }}>
|
|
257
|
-
<
|
|
258
|
-
<Text color="secondary"
|
|
260
|
+
<Text variant="heading-secondary" as="h2" text="publicBooking.title" />
|
|
261
|
+
<Text color="secondary" text="publicBooking.description" />
|
|
259
262
|
</View>
|
|
260
263
|
{step === 'confirmed' && selectedSlot ? (
|
|
261
264
|
<PendingScreen slot={selectedSlot} t={t} language={language} />
|
|
@@ -272,7 +275,7 @@ export default function PublicBookingPage ({ consultantSlug }) {
|
|
|
272
275
|
<Text weight="medium">{t('publicBooking.selectedTime', { datetime: formatDate(selectedSlot.startAt, t, language) })}</Text>
|
|
273
276
|
<Text color="secondary">{t('publicBooking.durationMin', { minutes: selectedSlot.duration })}</Text>
|
|
274
277
|
</View>
|
|
275
|
-
<BookingForm slot={selectedSlot} service={selectedService}
|
|
278
|
+
<BookingForm slot={selectedSlot} service={selectedService} providerSlug={slug} providerContact={providerContact} onSuccess={() => setStep('confirmed')} t={t} />
|
|
276
279
|
</View>
|
|
277
280
|
) : step === 'pick-slot' && selectedService ? (
|
|
278
281
|
<View gap="m">
|
|
@@ -284,7 +287,7 @@ export default function PublicBookingPage ({ consultantSlug }) {
|
|
|
284
287
|
<View gap="l" layout="row" style={{ flexWrap: 'wrap' }}>
|
|
285
288
|
<View style={{ flex: 1, minWidth: 260 }}>
|
|
286
289
|
{loadingSlots ? (
|
|
287
|
-
<Text color="secondary" style={{ textAlign: 'center', padding: 'var(--space-l)' }}
|
|
290
|
+
<Text color="secondary" style={{ textAlign: 'center', padding: 'var(--space-l)' }} text="publicBooking.loadingSlots" />
|
|
288
291
|
) : slotsError ? (
|
|
289
292
|
<Alert variant="danger">{slotsError}</Alert>
|
|
290
293
|
) : (
|
|
@@ -298,15 +301,15 @@ export default function PublicBookingPage ({ consultantSlug }) {
|
|
|
298
301
|
<View gap="m" style={{ flex: 1, minWidth: 260 }}>
|
|
299
302
|
{selectedDate ? (
|
|
300
303
|
<>
|
|
301
|
-
<
|
|
304
|
+
<Text variant="heading-tertiary" as="h3">
|
|
302
305
|
{(() => { const [y, m, d] = selectedDate.split('-').map(Number); return `${d} ${t(MONTH_KEYS[m - 1])} ${y}` })()}
|
|
303
|
-
</
|
|
306
|
+
</Text>
|
|
304
307
|
<SlotPicker slots={selectedDateSlots} selectedSlot={selectedSlot} onSelectSlot={setSelectedSlot} t={t} language={language} />
|
|
305
|
-
{selectedSlot && <Button variant="primary" onClick={() => setStep('fill-form')}>{t('publicBooking.continue')}</Button>}
|
|
308
|
+
{selectedSlot && <Button data-action="public-booking/continue" variant="primary" onClick={() => setStep('fill-form')}>{t('publicBooking.continue')}</Button>}
|
|
306
309
|
</>
|
|
307
310
|
) : (
|
|
308
311
|
<View gap="s" alignItems="center" justifyContent="center" inset="l" style={{ minHeight: 180 }}>
|
|
309
|
-
<Text color="secondary" style={{ textAlign: 'center' }}
|
|
312
|
+
<Text color="secondary" style={{ textAlign: 'center' }} text="publicBooking.pickDate" />
|
|
310
313
|
</View>
|
|
311
314
|
)}
|
|
312
315
|
</View>
|
|
@@ -314,9 +317,9 @@ export default function PublicBookingPage ({ consultantSlug }) {
|
|
|
314
317
|
</View>
|
|
315
318
|
) : (
|
|
316
319
|
<View gap="m">
|
|
317
|
-
<
|
|
320
|
+
<Text variant="heading-tertiary" as="h3" text="publicBooking.chooseService" />
|
|
318
321
|
{loadingServices ? (
|
|
319
|
-
<Text color="secondary"
|
|
322
|
+
<Text color="secondary" text="publicBooking.loadingServices" />
|
|
320
323
|
) : servicesError ? (
|
|
321
324
|
<Alert variant="danger">{servicesError}</Alert>
|
|
322
325
|
) : (
|
|
@@ -329,7 +332,7 @@ export default function PublicBookingPage ({ consultantSlug }) {
|
|
|
329
332
|
/>
|
|
330
333
|
)}
|
|
331
334
|
{selectedService && (
|
|
332
|
-
<Button variant="primary" onClick={() => setStep('pick-slot')}>{t('publicBooking.continue')}</Button>
|
|
335
|
+
<Button data-action="public-booking/continue" variant="primary" onClick={() => setStep('pick-slot')}>{t('publicBooking.continue')}</Button>
|
|
333
336
|
)}
|
|
334
337
|
</View>
|
|
335
338
|
)}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Aggregate } from '@ossy/event-store'
|
|
2
|
+
import { Workspace } from '@ossy/workspaces/server'
|
|
3
|
+
import { User } from '@ossy/users/server'
|
|
4
|
+
import { resolveProviderSlug, workspaceIdFromPayload } from './provider-fields.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve tenancy id from payload. Public routes pass providerSlug (workspace slug alias);
|
|
8
|
+
* slug may equal workspace id until dedicated slug lookup exists.
|
|
9
|
+
*/
|
|
10
|
+
export async function resolveWorkspaceId (payload) {
|
|
11
|
+
const direct = workspaceIdFromPayload(payload)
|
|
12
|
+
if (direct) return direct
|
|
13
|
+
|
|
14
|
+
const slug = resolveProviderSlug(payload)
|
|
15
|
+
if (!slug) return null
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const workspace = await Aggregate.Of(Workspace, slug).then(Aggregate.View())
|
|
19
|
+
return workspace?.id ?? slug
|
|
20
|
+
} catch {
|
|
21
|
+
return slug
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Workspace owner / booking contact for provider-facing emails. */
|
|
26
|
+
export async function resolveWorkspaceContact (workspaceId) {
|
|
27
|
+
if (!workspaceId) return null
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const workspace = await Aggregate.Of(Workspace, workspaceId).then(Aggregate.View())
|
|
31
|
+
if (!workspace?.id) return null
|
|
32
|
+
|
|
33
|
+
const ownerId = workspace.createdBy ?? workspace.users?.[0]
|
|
34
|
+
if (!ownerId) {
|
|
35
|
+
return {
|
|
36
|
+
workspaceId: workspace.id,
|
|
37
|
+
providerName: workspace.name ?? null,
|
|
38
|
+
providerEmail: null,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const user = await Aggregate.Of(User, ownerId).then(Aggregate.View(User.UserForWorkspace))
|
|
43
|
+
const providerName = (workspace.name
|
|
44
|
+
?? [user?.firstName, user?.lastName].filter(Boolean).join(' '))
|
|
45
|
+
|| null
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
workspaceId: workspace.id,
|
|
49
|
+
providerName,
|
|
50
|
+
providerEmail: user?.email ?? null,
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { nanoid } from 'nanoid'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { ResourcesEvents, commitResource, mutateResource } from '@ossy/resources/server'
|
|
3
|
+
import { BookingSchema } from './schema-ids.js'
|
|
4
4
|
import { getBookingResources } from './booking-resources.js'
|
|
5
5
|
import { availability } from './locations.js'
|
|
6
6
|
|
|
7
|
-
export const metadata = { id: 'booking/save-availability' }
|
|
7
|
+
export const metadata = { id: '@ossy/booking/tasks/save-availability' }
|
|
8
8
|
|
|
9
|
-
export async function run({ payload, req, log }) {
|
|
9
|
+
export async function run ({ payload, req, log }) {
|
|
10
10
|
const workspaceId = req?.workspaceId
|
|
11
11
|
|
|
12
12
|
if (!workspaceId) {
|
|
@@ -15,10 +15,14 @@ export async function run({ payload, req, log }) {
|
|
|
15
15
|
|
|
16
16
|
const { weeklyWindows, sessionDurations, bufferMinutes, timezone } = payload ?? {}
|
|
17
17
|
|
|
18
|
-
log?.info(`[booking/save-availability] Saving availability for workspace ${workspaceId}`)
|
|
18
|
+
log?.info(`[booking/tasks/save-availability] Saving availability for workspace ${workspaceId}`)
|
|
19
19
|
|
|
20
20
|
const content = {
|
|
21
|
-
weeklyWindows: weeklyWindows ?? [],
|
|
21
|
+
weeklyWindows: (weeklyWindows ?? []).map(({ dayOfWeek, startTime, endTime }) => ({
|
|
22
|
+
dayOfWeek,
|
|
23
|
+
startTime,
|
|
24
|
+
endTime,
|
|
25
|
+
})),
|
|
22
26
|
sessionDurations: Array.isArray(sessionDurations) && sessionDurations.length
|
|
23
27
|
? sessionDurations
|
|
24
28
|
: [60],
|
|
@@ -26,49 +30,43 @@ export async function run({ payload, req, log }) {
|
|
|
26
30
|
timezone: timezone ?? 'Europe/Stockholm',
|
|
27
31
|
}
|
|
28
32
|
|
|
29
|
-
// Check if a config resource already exists for this workspace
|
|
30
33
|
const existing = await getBookingResources({
|
|
31
|
-
|
|
34
|
+
schemaId: BookingSchema.availability,
|
|
32
35
|
belongsTo: workspaceId,
|
|
33
36
|
})
|
|
34
37
|
|
|
35
|
-
// Identify the single config resource (new format has weeklyWindows array in content)
|
|
36
38
|
const configResource = existing.find(r => Array.isArray(r.content?.weeklyWindows))
|
|
37
39
|
|
|
38
40
|
if (configResource) {
|
|
39
|
-
log?.info(`[booking/save-availability] Updating existing resource ${configResource.id}`)
|
|
40
|
-
|
|
41
|
-
await
|
|
42
|
-
.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
),
|
|
49
|
-
)
|
|
50
|
-
.then(Aggregate.Save())
|
|
41
|
+
log?.info(`[booking/tasks/save-availability] Updating existing resource ${configResource.id}`)
|
|
42
|
+
|
|
43
|
+
await mutateResource(
|
|
44
|
+
configResource.id,
|
|
45
|
+
ResourcesEvents.Patched({
|
|
46
|
+
createdBy: req?.userId ?? 'system',
|
|
47
|
+
content,
|
|
48
|
+
}),
|
|
49
|
+
)
|
|
51
50
|
|
|
52
51
|
return { id: configResource.id, content }
|
|
53
52
|
}
|
|
54
53
|
|
|
55
|
-
// No config resource found — create a new one
|
|
56
54
|
const resourceId = nanoid()
|
|
57
|
-
log?.info(`[booking/save-availability] Creating new resource ${resourceId}`)
|
|
55
|
+
log?.info(`[booking/tasks/save-availability] Creating new resource ${resourceId}`)
|
|
58
56
|
|
|
59
57
|
const event = ResourcesEvents.Created({
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
resourceId,
|
|
59
|
+
schemaId: BookingSchema.availability,
|
|
62
60
|
createdBy: req?.userId ?? 'system',
|
|
63
61
|
belongsTo: workspaceId,
|
|
64
62
|
location: availability,
|
|
65
|
-
name:
|
|
63
|
+
name: 'availability-config.json',
|
|
66
64
|
content,
|
|
67
65
|
})
|
|
68
66
|
|
|
69
|
-
await
|
|
67
|
+
await commitResource(event)
|
|
70
68
|
|
|
71
|
-
log?.info(`[booking/save-availability] Resource ${resourceId} created`)
|
|
69
|
+
log?.info(`[booking/tasks/save-availability] Resource ${resourceId} created`)
|
|
72
70
|
|
|
73
71
|
return { id: resourceId, content }
|
|
74
72
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Canonical schema ids for @ossy/booking (ADR 0006). */
|
|
2
|
+
export const BookingSchema = Object.freeze({
|
|
3
|
+
availability: '@ossy/booking/schema/availability',
|
|
4
|
+
booking: '@ossy/booking/schema/booking',
|
|
5
|
+
publicBookingForm: '@ossy/booking/schema/public-booking-form',
|
|
6
|
+
service: '@ossy/booking/schema/service',
|
|
7
|
+
})
|