@ossy/booking 1.16.2 → 1.16.3
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 +44 -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 +1 -1
- 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 +7 -11
- 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/moduleStatus.js +30 -2
- 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 +58 -56
- 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/AvailabilityEditor.stories.jsx +54 -0
- package/src/stories/AvailabilitySetupPage.stories.jsx +44 -0
- package/src/stories/BookingCard.stories.jsx +57 -0
- package/src/stories/BookingList.stories.jsx +30 -0
- package/src/stories/BookingsComponent.stories.jsx +31 -0
- package/src/stories/BookingsPage.stories.jsx +66 -0
- package/src/stories/HeroCover.stories.jsx +42 -0
- package/src/stories/PublicBookingForm.stories.jsx +66 -0
- package/src/stories/PublicBookingPage.stories.jsx +78 -0
- package/src/stories/ServiceCard.stories.jsx +52 -0
- package/src/stories/ServiceDetail.stories.jsx +52 -0
- package/src/stories/ServiceForm.stories.jsx +64 -0
- package/src/stories/ServicesPage.stories.jsx +53 -0
- package/src/stories/decorators.jsx +68 -0
- package/src/stories/fixtures.js +149 -0
- package/src/stories/story-helpers.jsx +85 -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/service.resource.js +0 -32
package/src/moduleStatus.js
CHANGED
|
@@ -1,2 +1,30 @@
|
|
|
1
|
-
export const
|
|
2
|
-
|
|
1
|
+
export const MODULE_STATUS = {
|
|
2
|
+
beta: 'beta',
|
|
3
|
+
comingSoon: 'coming-soon',
|
|
4
|
+
stable: 'stable',
|
|
5
|
+
hidden: 'hidden',
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const STATUS_TO_TAG_LABEL = {
|
|
9
|
+
[MODULE_STATUS.beta]: 'Beta',
|
|
10
|
+
[MODULE_STATUS.comingSoon]: 'Coming Soon',
|
|
11
|
+
[MODULE_STATUS.stable]: null,
|
|
12
|
+
[MODULE_STATUS.hidden]: null,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function moduleStatuses(definition) {
|
|
16
|
+
const fromArray = Array.isArray(definition?.statuses) ? definition.statuses : []
|
|
17
|
+
const fromLegacy = definition?.status && typeof definition.status === 'string' ? [definition.status] : []
|
|
18
|
+
if (fromArray.length) return [...new Set(fromArray)]
|
|
19
|
+
if (fromLegacy.length) return [...new Set(fromLegacy)]
|
|
20
|
+
return []
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function moduleStatusTags(definition) {
|
|
24
|
+
const labels = []
|
|
25
|
+
for (const s of moduleStatuses(definition)) {
|
|
26
|
+
const label = STATUS_TO_TAG_LABEL[s]
|
|
27
|
+
if (label) labels.push(label)
|
|
28
|
+
}
|
|
29
|
+
return [...new Set(labels)]
|
|
30
|
+
}
|
|
@@ -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,12 @@ function PendingScreen ({ slot, t, language }) {
|
|
|
167
165
|
export const metadata = {
|
|
168
166
|
id: 'public-booking',
|
|
169
167
|
public: true,
|
|
170
|
-
path: { sv: '/boka/:
|
|
168
|
+
path: { sv: '/boka/:providerSlug', en: '/book/:providerSlug' },
|
|
171
169
|
}
|
|
172
170
|
|
|
173
171
|
function ServicePicker ({ services, selectedService, onSelect, t, language }) {
|
|
174
172
|
if (!services?.length) {
|
|
175
|
-
return <Text color="secondary"
|
|
173
|
+
return <Text color="secondary" text="publicBooking.noServices" />
|
|
176
174
|
}
|
|
177
175
|
const formatPrice = (cents, currency) => {
|
|
178
176
|
if (!cents) return t('publicBooking.serviceFree')
|
|
@@ -183,7 +181,8 @@ function ServicePicker ({ services, selectedService, onSelect, t, language }) {
|
|
|
183
181
|
{services.map(service => (
|
|
184
182
|
<Button
|
|
185
183
|
key={service.id}
|
|
186
|
-
|
|
184
|
+
data-action="public-booking/pick-service"
|
|
185
|
+
variant={selectedService?.id === service.id ? 'tag-active' : 'tag'}
|
|
187
186
|
onClick={() => onSelect(service)}
|
|
188
187
|
style={{ justifyContent: 'flex-start', textAlign: 'left' }}
|
|
189
188
|
>
|
|
@@ -199,13 +198,14 @@ function ServicePicker ({ services, selectedService, onSelect, t, language }) {
|
|
|
199
198
|
)
|
|
200
199
|
}
|
|
201
200
|
|
|
202
|
-
export default function PublicBookingPage ({
|
|
201
|
+
export default function PublicBookingPage ({ providerSlug }) {
|
|
203
202
|
const { t, language } = useLocale()
|
|
204
203
|
const sdk = useSdk()
|
|
205
204
|
const router = useRouter()
|
|
206
|
-
const slug =
|
|
205
|
+
const slug = providerSlug ?? router?.params?.providerSlug ?? router?.params?.consultantSlug
|
|
207
206
|
const today = new Date()
|
|
208
207
|
const [services, setServices] = useState([])
|
|
208
|
+
const [providerContact, setProviderContact] = useState(null)
|
|
209
209
|
const [loadingServices, setLoadingServices] = useState(true)
|
|
210
210
|
const [servicesError, setServicesError] = useState(null)
|
|
211
211
|
const [selectedService, setSelectedService] = useState(null)
|
|
@@ -222,10 +222,12 @@ export default function PublicBookingPage ({ consultantSlug }) {
|
|
|
222
222
|
if (!slug) return
|
|
223
223
|
setLoadingServices(true)
|
|
224
224
|
setServicesError(null)
|
|
225
|
-
sdk.invoke(GetServices, {
|
|
225
|
+
sdk.invoke(GetServices, { providerSlug: slug, includeContact: true })
|
|
226
226
|
.then(data => {
|
|
227
|
-
const list = Array.isArray(data) ? data : []
|
|
227
|
+
const list = Array.isArray(data) ? data : (data?.services ?? [])
|
|
228
|
+
const contact = Array.isArray(data) ? null : data?.contact
|
|
228
229
|
setServices(list)
|
|
230
|
+
setProviderContact(contact)
|
|
229
231
|
if (list.length === 1) setSelectedService(list[0])
|
|
230
232
|
setLoadingServices(false)
|
|
231
233
|
})
|
|
@@ -238,7 +240,7 @@ export default function PublicBookingPage ({ consultantSlug }) {
|
|
|
238
240
|
const from = new Date().toISOString()
|
|
239
241
|
const to = new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString()
|
|
240
242
|
sdk.invoke(GetAvailableSlots, {
|
|
241
|
-
|
|
243
|
+
providerSlug: slug,
|
|
242
244
|
serviceId: selectedService.id,
|
|
243
245
|
from,
|
|
244
246
|
to,
|
|
@@ -254,8 +256,8 @@ export default function PublicBookingPage ({ consultantSlug }) {
|
|
|
254
256
|
<View surface="base" layout="off-center-s" inset="s" style={{ minHeight: '100dvh' }}>
|
|
255
257
|
<View inset="m" roundness="l" surface="primary" slot="content" gap="l" style={{ maxWidth: 720, width: '100%' }}>
|
|
256
258
|
<View gap="xs" style={{ borderBottom: '1px solid var(--separator)', paddingBottom: 'var(--space-m)' }}>
|
|
257
|
-
<
|
|
258
|
-
<Text color="secondary"
|
|
259
|
+
<Text variant="heading-secondary" as="h2" text="publicBooking.title" />
|
|
260
|
+
<Text color="secondary" text="publicBooking.description" />
|
|
259
261
|
</View>
|
|
260
262
|
{step === 'confirmed' && selectedSlot ? (
|
|
261
263
|
<PendingScreen slot={selectedSlot} t={t} language={language} />
|
|
@@ -272,7 +274,7 @@ export default function PublicBookingPage ({ consultantSlug }) {
|
|
|
272
274
|
<Text weight="medium">{t('publicBooking.selectedTime', { datetime: formatDate(selectedSlot.startAt, t, language) })}</Text>
|
|
273
275
|
<Text color="secondary">{t('publicBooking.durationMin', { minutes: selectedSlot.duration })}</Text>
|
|
274
276
|
</View>
|
|
275
|
-
<BookingForm slot={selectedSlot} service={selectedService}
|
|
277
|
+
<BookingForm slot={selectedSlot} service={selectedService} providerSlug={slug} providerContact={providerContact} onSuccess={() => setStep('confirmed')} t={t} />
|
|
276
278
|
</View>
|
|
277
279
|
) : step === 'pick-slot' && selectedService ? (
|
|
278
280
|
<View gap="m">
|
|
@@ -284,7 +286,7 @@ export default function PublicBookingPage ({ consultantSlug }) {
|
|
|
284
286
|
<View gap="l" layout="row" style={{ flexWrap: 'wrap' }}>
|
|
285
287
|
<View style={{ flex: 1, minWidth: 260 }}>
|
|
286
288
|
{loadingSlots ? (
|
|
287
|
-
<Text color="secondary" style={{ textAlign: 'center', padding: 'var(--space-l)' }}
|
|
289
|
+
<Text color="secondary" style={{ textAlign: 'center', padding: 'var(--space-l)' }} text="publicBooking.loadingSlots" />
|
|
288
290
|
) : slotsError ? (
|
|
289
291
|
<Alert variant="danger">{slotsError}</Alert>
|
|
290
292
|
) : (
|
|
@@ -298,15 +300,15 @@ export default function PublicBookingPage ({ consultantSlug }) {
|
|
|
298
300
|
<View gap="m" style={{ flex: 1, minWidth: 260 }}>
|
|
299
301
|
{selectedDate ? (
|
|
300
302
|
<>
|
|
301
|
-
<
|
|
303
|
+
<Text variant="heading-tertiary" as="h3">
|
|
302
304
|
{(() => { const [y, m, d] = selectedDate.split('-').map(Number); return `${d} ${t(MONTH_KEYS[m - 1])} ${y}` })()}
|
|
303
|
-
</
|
|
305
|
+
</Text>
|
|
304
306
|
<SlotPicker slots={selectedDateSlots} selectedSlot={selectedSlot} onSelectSlot={setSelectedSlot} t={t} language={language} />
|
|
305
|
-
{selectedSlot && <Button variant="primary" onClick={() => setStep('fill-form')}>{t('publicBooking.continue')}</Button>}
|
|
307
|
+
{selectedSlot && <Button data-action="public-booking/continue" variant="primary" onClick={() => setStep('fill-form')}>{t('publicBooking.continue')}</Button>}
|
|
306
308
|
</>
|
|
307
309
|
) : (
|
|
308
310
|
<View gap="s" alignItems="center" justifyContent="center" inset="l" style={{ minHeight: 180 }}>
|
|
309
|
-
<Text color="secondary" style={{ textAlign: 'center' }}
|
|
311
|
+
<Text color="secondary" style={{ textAlign: 'center' }} text="publicBooking.pickDate" />
|
|
310
312
|
</View>
|
|
311
313
|
)}
|
|
312
314
|
</View>
|
|
@@ -314,9 +316,9 @@ export default function PublicBookingPage ({ consultantSlug }) {
|
|
|
314
316
|
</View>
|
|
315
317
|
) : (
|
|
316
318
|
<View gap="m">
|
|
317
|
-
<
|
|
319
|
+
<Text variant="heading-tertiary" as="h3" text="publicBooking.chooseService" />
|
|
318
320
|
{loadingServices ? (
|
|
319
|
-
<Text color="secondary"
|
|
321
|
+
<Text color="secondary" text="publicBooking.loadingServices" />
|
|
320
322
|
) : servicesError ? (
|
|
321
323
|
<Alert variant="danger">{servicesError}</Alert>
|
|
322
324
|
) : (
|
|
@@ -329,7 +331,7 @@ export default function PublicBookingPage ({ consultantSlug }) {
|
|
|
329
331
|
/>
|
|
330
332
|
)}
|
|
331
333
|
{selectedService && (
|
|
332
|
-
<Button variant="primary" onClick={() => setStep('pick-slot')}>{t('publicBooking.continue')}</Button>
|
|
334
|
+
<Button data-action="public-booking/continue" variant="primary" onClick={() => setStep('pick-slot')}>{t('publicBooking.continue')}</Button>
|
|
333
335
|
)}
|
|
334
336
|
</View>
|
|
335
337
|
)}
|
|
@@ -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
|
+
})
|