@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.
Files changed (92) hide show
  1. package/README.md +48 -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 +2 -2
  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 +26 -25
  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/open-availability-setup.action.js +5 -0
  56. package/src/open-bookings.action.js +5 -0
  57. package/src/open-create-service.action.js +5 -0
  58. package/src/open-services.action.js +5 -0
  59. package/src/provider-availability.flow.js +39 -0
  60. package/src/provider-fields.js +34 -0
  61. package/src/provider-onboard.flow.js +25 -0
  62. package/src/public-booking-form.form.js +7 -0
  63. package/src/public-booking-form.schema.js +12 -0
  64. package/src/public-booking.page.jsx +60 -57
  65. package/src/resolve-workspace-contact.js +55 -0
  66. package/src/save-availability.action.js +5 -1
  67. package/src/save-availability.task.js +26 -28
  68. package/src/schema-ids.js +7 -0
  69. package/src/select-duration-30.action.js +4 -0
  70. package/src/select-duration-60.action.js +4 -0
  71. package/src/select-duration-90.action.js +4 -0
  72. package/src/send-booking-reminder.task.js +39 -14
  73. package/src/service-card-slot.component.jsx +4 -4
  74. package/src/service-detail.component.jsx +4 -4
  75. package/src/service.form.js +7 -0
  76. package/src/service.schema.js +14 -0
  77. package/src/services.page.jsx +62 -58
  78. package/src/stories/fixtures.js +149 -0
  79. package/src/sv.translations.json +83 -37
  80. package/src/time.js +27 -0
  81. package/src/toggle-availability-day-0.action.js +4 -0
  82. package/src/toggle-availability-day-1.action.js +4 -0
  83. package/src/toggle-availability-day-2.action.js +4 -0
  84. package/src/toggle-availability-day-3.action.js +4 -0
  85. package/src/toggle-availability-day-4.action.js +4 -0
  86. package/src/toggle-availability-day-5.action.js +4 -0
  87. package/src/toggle-availability-day-6.action.js +4 -0
  88. package/src/update-service.action.js +1 -1
  89. package/src/update-service.task.js +13 -16
  90. package/src/workspace-services.js +5 -0
  91. package/src/moduleStatus.js +0 -2
  92. package/src/service.resource.js +0 -32
@@ -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,13 @@ 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
+ 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">{t('publicBooking.noServices')}</Text>
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
- variant={selectedService?.id === service.id ? 'tab-active' : 'tab'}
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 ({ consultantSlug }) {
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 = consultantSlug ?? router?.params?.consultantSlug
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, { consultantId: slug })
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
- consultantId: slug,
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" slot="content" gap="l" style={{ maxWidth: 720, width: '100%' }}>
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
- <Title variant="secondary">{t('publicBooking.title')}</Title>
258
- <Text color="secondary">{t('publicBooking.description')}</Text>
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} consultantSlug={slug} onSuccess={() => setStep('confirmed')} t={t} />
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)' }}>{t('publicBooking.loadingSlots')}</Text>
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
- <Title variant="tertiary">
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
- </Title>
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' }}>{t('publicBooking.pickDate')}</Text>
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
- <Title variant="tertiary">{t('publicBooking.chooseService')}</Title>
320
+ <Text variant="heading-tertiary" as="h3" text="publicBooking.chooseService" />
318
321
  {loadingServices ? (
319
- <Text color="secondary">{t('publicBooking.loadingServices')}</Text>
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 +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
+ }