@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.
Files changed (107) hide show
  1. package/README.md +44 -21
  2. package/package.json +11 -8
  3. package/src/AvailabilityEditor.jsx +2 -13
  4. package/src/BookingList.jsx +1 -1
  5. package/src/BookingProductHome.jsx +38 -0
  6. package/src/BookingSalesPage.jsx +55 -0
  7. package/src/Definition.js +1 -1
  8. package/src/HeroCover.jsx +5 -5
  9. package/src/SalesSection.jsx +7 -15
  10. package/src/ServiceCard.jsx +2 -2
  11. package/src/availability-action-maps.js +39 -0
  12. package/src/availability-engine.js +18 -14
  13. package/src/availability-engine.spec.js +36 -4
  14. package/src/availability-setup.page.jsx +45 -46
  15. package/src/{availability.resource.js → availability.schema.js} +1 -5
  16. package/src/booking-cancellation.email.jsx +1 -1
  17. package/src/booking-card.component.jsx +3 -3
  18. package/src/booking-confirmation.email.jsx +5 -3
  19. package/src/booking-detail.page.jsx +15 -10
  20. package/src/booking-email-token.js +45 -0
  21. package/src/booking-home-content.js +50 -0
  22. package/src/booking-list.aggregate.js +65 -0
  23. package/src/booking-reminder.email.jsx +4 -3
  24. package/src/booking-request.email.jsx +5 -2
  25. package/src/booking-resources.js +3 -3
  26. package/src/{booking.resource.js → booking.schema.js} +12 -3
  27. package/src/bookings.component.jsx +7 -11
  28. package/src/bookings.page.jsx +29 -16
  29. package/src/cancel-booking.action.js +1 -1
  30. package/src/cancel-booking.task.js +14 -17
  31. package/src/confirm-booking.action.js +1 -1
  32. package/src/confirm-booking.api.js +35 -0
  33. package/src/confirm-booking.task.js +26 -24
  34. package/src/create-booking.action.js +1 -1
  35. package/src/create-booking.task.js +73 -37
  36. package/src/create-service.action.js +5 -1
  37. package/src/create-service.task.js +7 -7
  38. package/src/decline-booking.action.js +1 -1
  39. package/src/decline-booking.api.js +35 -0
  40. package/src/decline-booking.task.js +15 -18
  41. package/src/delete-service.action.js +1 -1
  42. package/src/delete-service.task.js +12 -15
  43. package/src/en.translations.json +83 -37
  44. package/src/get-availability.action.js +1 -1
  45. package/src/get-availability.task.js +5 -4
  46. package/src/get-available-slots.action.js +1 -1
  47. package/src/get-available-slots.task.js +16 -13
  48. package/src/get-services.action.js +1 -1
  49. package/src/get-services.task.js +13 -6
  50. package/src/home.page.jsx +14 -46
  51. package/src/index.js +17 -0
  52. package/src/list-bookings.action.js +1 -1
  53. package/src/list-bookings.task.js +33 -16
  54. package/src/locations.js +6 -4
  55. package/src/moduleStatus.js +30 -2
  56. package/src/open-availability-setup.action.js +5 -0
  57. package/src/open-bookings.action.js +5 -0
  58. package/src/open-create-service.action.js +5 -0
  59. package/src/open-services.action.js +5 -0
  60. package/src/provider-availability.flow.js +39 -0
  61. package/src/provider-fields.js +34 -0
  62. package/src/provider-onboard.flow.js +25 -0
  63. package/src/public-booking-form.form.js +7 -0
  64. package/src/public-booking-form.schema.js +12 -0
  65. package/src/public-booking.page.jsx +58 -56
  66. package/src/resolve-workspace-contact.js +55 -0
  67. package/src/save-availability.action.js +5 -1
  68. package/src/save-availability.task.js +26 -28
  69. package/src/schema-ids.js +7 -0
  70. package/src/select-duration-30.action.js +4 -0
  71. package/src/select-duration-60.action.js +4 -0
  72. package/src/select-duration-90.action.js +4 -0
  73. package/src/send-booking-reminder.task.js +39 -14
  74. package/src/service-card-slot.component.jsx +4 -4
  75. package/src/service-detail.component.jsx +4 -4
  76. package/src/service.form.js +7 -0
  77. package/src/service.schema.js +14 -0
  78. package/src/services.page.jsx +62 -58
  79. package/src/stories/AvailabilityEditor.stories.jsx +54 -0
  80. package/src/stories/AvailabilitySetupPage.stories.jsx +44 -0
  81. package/src/stories/BookingCard.stories.jsx +57 -0
  82. package/src/stories/BookingList.stories.jsx +30 -0
  83. package/src/stories/BookingsComponent.stories.jsx +31 -0
  84. package/src/stories/BookingsPage.stories.jsx +66 -0
  85. package/src/stories/HeroCover.stories.jsx +42 -0
  86. package/src/stories/PublicBookingForm.stories.jsx +66 -0
  87. package/src/stories/PublicBookingPage.stories.jsx +78 -0
  88. package/src/stories/ServiceCard.stories.jsx +52 -0
  89. package/src/stories/ServiceDetail.stories.jsx +52 -0
  90. package/src/stories/ServiceForm.stories.jsx +64 -0
  91. package/src/stories/ServicesPage.stories.jsx +53 -0
  92. package/src/stories/decorators.jsx +68 -0
  93. package/src/stories/fixtures.js +149 -0
  94. package/src/stories/story-helpers.jsx +85 -0
  95. package/src/sv.translations.json +83 -37
  96. package/src/time.js +27 -0
  97. package/src/toggle-availability-day-0.action.js +4 -0
  98. package/src/toggle-availability-day-1.action.js +4 -0
  99. package/src/toggle-availability-day-2.action.js +4 -0
  100. package/src/toggle-availability-day-3.action.js +4 -0
  101. package/src/toggle-availability-day-4.action.js +4 -0
  102. package/src/toggle-availability-day-5.action.js +4 -0
  103. package/src/toggle-availability-day-6.action.js +4 -0
  104. package/src/update-service.action.js +1 -1
  105. package/src/update-service.task.js +13 -16
  106. package/src/workspace-services.js +5 -0
  107. package/src/service.resource.js +0 -32
@@ -1,2 +1,30 @@
1
- export const moduleStatusTags = (definition) =>
2
- (definition.statuses ?? []).map((status) => ({ label: status }))
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,5 @@
1
+ export const metadata = {
2
+ id: '@ossy/booking/actions/open-availability-setup',
3
+ access: 'workspace',
4
+ label: '@ossy/booking/actions/open-availability-setup.label',
5
+ }
@@ -0,0 +1,5 @@
1
+ export const metadata = {
2
+ id: '@ossy/booking/actions/open-bookings',
3
+ access: 'workspace',
4
+ label: '@ossy/booking/actions/open-bookings.label',
5
+ }
@@ -0,0 +1,5 @@
1
+ export const metadata = {
2
+ id: '@ossy/booking/actions/open-create-service',
3
+ access: 'workspace',
4
+ label: '@ossy/booking/actions/open-create-service.label',
5
+ }
@@ -0,0 +1,5 @@
1
+ export const metadata = {
2
+ id: '@ossy/booking/actions/open-services',
3
+ access: 'workspace',
4
+ label: '@ossy/booking/actions/open-services.label',
5
+ }
@@ -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,7 @@
1
+ import { BookingSchema } from './schema-ids.js'
2
+
3
+ export const metadata = {
4
+ id: '@ossy/booking/form/public-booking',
5
+ schemaId: BookingSchema.publicBookingForm,
6
+ label: '@ossy/booking/form/public-booking.label',
7
+ }
@@ -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, useMemo } from 'react'
1
+ import React, { useState, useEffect } from 'react'
2
2
  import { useRouter } from '@ossy/router-react'
3
- import { View, Title, Text, Button, Input, Textarea, Alert, useLocale } from '@ossy/design-system'
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 ? 'tab-active' : hasSlots && !isPast ? 'tab' : 'neutral'} disabled={disabled}
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">{t('publicBooking.noSlots')}</Text>
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 ? 'tab-active' : 'tab'} onClick={() => onSelectSlot(slot)}>
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, consultantSlug, onSuccess, t }) {
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 (e) => {
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
- consultantId: consultantSlug,
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
- <form onSubmit={handleSubmit}>
134
- <View gap="m">
135
- <View gap="xs">
136
- <Text weight="medium">{t('publicBooking.form.name')}</Text>
137
- <Input type="text" required value={clientName} onChange={e => setClientName(e.target.value)} placeholder={t('publicBooking.form.namePlaceholder')} />
138
- </View>
139
- <View gap="xs">
140
- <Text weight="medium">{t('publicBooking.form.email')}</Text>
141
- <Input type="email" required value={clientEmail} onChange={e => setClientEmail(e.target.value)} placeholder={t('publicBooking.form.emailPlaceholder')} />
142
- </View>
143
- <View gap="xs">
144
- <Text weight="medium">{t('publicBooking.form.message')}</Text>
145
- <Textarea value={clientMessage} onChange={e => setClientMessage(e.target.value)} placeholder={t('publicBooking.form.messagePlaceholder')} rows={3} />
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
- <Title variant="secondary">{t('publicBooking.pending.title')}</Title>
158
- <Text color="secondary">{t('publicBooking.pending.body')}</Text>
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/:consultantSlug', en: '/book/:consultantSlug' },
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">{t('publicBooking.noServices')}</Text>
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
- variant={selectedService?.id === service.id ? 'tab-active' : 'tab'}
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 ({ consultantSlug }) {
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 = consultantSlug ?? router?.params?.consultantSlug
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, { consultantId: slug })
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
- consultantId: slug,
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
- <Title variant="secondary">{t('publicBooking.title')}</Title>
258
- <Text color="secondary">{t('publicBooking.description')}</Text>
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} consultantSlug={slug} onSuccess={() => setStep('confirmed')} t={t} />
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)' }}>{t('publicBooking.loadingSlots')}</Text>
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
- <Title variant="tertiary">
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
- </Title>
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' }}>{t('publicBooking.pickDate')}</Text>
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
- <Title variant="tertiary">{t('publicBooking.chooseService')}</Title>
319
+ <Text variant="heading-tertiary" as="h3" text="publicBooking.chooseService" />
318
320
  {loadingServices ? (
319
- <Text color="secondary">{t('publicBooking.loadingServices')}</Text>
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 +1,5 @@
1
- export const metadata = { id: 'booking/save-availability', access: 'workspace' }
1
+ export const metadata = {
2
+ id: '@ossy/booking/actions/save-availability',
3
+ access: 'workspace',
4
+ label: 'booking.availabilitySetup.save',
5
+ }
@@ -1,12 +1,12 @@
1
1
  import { nanoid } from 'nanoid'
2
- import { Aggregate } from '@ossy/event-store'
3
- import { Resource, ResourcesEvents } from '@ossy/resources/server'
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
- type: '@ossy/booking/availability',
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 Aggregate.Of(Resource, configResource.id)
42
- .then(
43
- Aggregate.Add(
44
- ResourcesEvents.ContentUpdated({
45
- createdBy: req?.userId ?? 'system',
46
- content,
47
- }),
48
- ),
49
- )
50
- .then(Aggregate.Save())
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
- aggregateId: resourceId,
61
- type: '@ossy/booking/availability',
58
+ resourceId,
59
+ schemaId: BookingSchema.availability,
62
60
  createdBy: req?.userId ?? 'system',
63
61
  belongsTo: workspaceId,
64
62
  location: availability,
65
- name: `availability-config.json`,
63
+ name: 'availability-config.json',
66
64
  content,
67
65
  })
68
66
 
69
- await Aggregate.Of(Resource, event).then(Aggregate.View())
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
+ })
@@ -0,0 +1,4 @@
1
+ export const metadata = {
2
+ id: '@ossy/booking/actions/select-duration-30',
3
+ access: 'workspace',
4
+ }
@@ -0,0 +1,4 @@
1
+ export const metadata = {
2
+ id: '@ossy/booking/actions/select-duration-60',
3
+ access: 'workspace',
4
+ }
@@ -0,0 +1,4 @@
1
+ export const metadata = {
2
+ id: '@ossy/booking/actions/select-duration-90',
3
+ access: 'workspace',
4
+ }