@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
@@ -1,18 +1,10 @@
1
1
  import React, { useState, useEffect, useMemo } from 'react'
2
- import {
3
- View,
4
- Title,
5
- Text,
6
- Button,
7
- Input,
8
- Select,
9
- Alert,
10
- useLocale,
11
- } from '@ossy/design-system'
2
+ import { View, Text, Button, Input, Select, Alert, Page, useLocale } from '@ossy/design-system'
12
3
  import { useRouter } from '@ossy/router-react'
13
4
  import { useSdk } from '@ossy/sdk-react'
14
5
  import { metadata as SaveAvailability } from './save-availability.action.js'
15
6
  import { metadata as GetAvailability } from './get-availability.action.js'
7
+ import { SELECT_DURATION, TOGGLE_AVAILABILITY_DAY } from './availability-action-maps.js'
16
8
  import { invokeErrorMessage } from './invoke-error-message.js'
17
9
 
18
10
  export const metadata = {
@@ -56,7 +48,7 @@ function buildDayState (weeklyWindows = []) {
56
48
  }))
57
49
  }
58
50
 
59
- function DayRow ({ label, day, onToggle, onTimeChange, t }) {
51
+ function DayRow ({ labelKey, day, toggleAction, onToggle, onTimeChange }) {
60
52
  return (
61
53
  <View
62
54
  layout="row"
@@ -67,7 +59,10 @@ function DayRow ({ label, day, onToggle, onTimeChange, t }) {
67
59
  inset="s"
68
60
  style={{ border: '1px solid var(--separator)' }}
69
61
  >
70
- <label style={{ display: 'flex', alignItems: 'center', flexShrink: 0, cursor: 'pointer' }}>
62
+ <label
63
+ data-action={toggleAction.id}
64
+ style={{ display: 'flex', alignItems: 'center', flexShrink: 0, cursor: 'pointer' }}
65
+ >
71
66
  <input
72
67
  type="checkbox"
73
68
  checked={day.enabled}
@@ -75,7 +70,7 @@ function DayRow ({ label, day, onToggle, onTimeChange, t }) {
75
70
  style={{ width: 16, height: 16, accentColor: 'var(--color-accent)' }}
76
71
  />
77
72
  </label>
78
- <Text weight="medium" style={{ width: '4.5rem', flexShrink: 0 }}>{label}</Text>
73
+ <Text weight="medium" text={labelKey} style={{ width: '4.5rem', flexShrink: 0 }} />
79
74
 
80
75
  {day.enabled ? (
81
76
  <View layout="row" gap="s" alignItems="center" style={{ flex: 1, flexWrap: 'wrap' }}>
@@ -84,7 +79,7 @@ function DayRow ({ label, day, onToggle, onTimeChange, t }) {
84
79
  <Input type="time" value={day.endTime} onChange={e => onTimeChange('endTime', e.target.value)} />
85
80
  </View>
86
81
  ) : (
87
- <Text color="secondary" size="s">{t('booking.availabilitySetup.unavailable')}</Text>
82
+ <Text color="secondary" variant="small" text="booking.availabilitySetup.unavailable" />
88
83
  )}
89
84
  </View>
90
85
  )
@@ -115,7 +110,7 @@ export default function AvailabilitySetupPage ({ workspaceId }) {
115
110
  const [copied, setCopied] = useState(false)
116
111
 
117
112
  const bookingPath = workspaceId
118
- ? router.getHref({ id: 'public-booking', params: { consultantSlug: workspaceId } })
113
+ ? router.getHref({ id: 'public-booking', params: { providerSlug: workspaceId } })
119
114
  : ''
120
115
  const bookingUrl = typeof window !== 'undefined' && bookingPath
121
116
  ? `${window.location.origin}${bookingPath}`
@@ -190,36 +185,33 @@ export default function AvailabilitySetupPage ({ workspaceId }) {
190
185
 
191
186
  if (loading) {
192
187
  return (
193
- <View surface="primary" inset="l" style={{ height: '100%', overflowY: 'auto' }}>
194
- <Text color="secondary">{t('booking.availabilitySetup.loading')}</Text>
195
- </View>
188
+ <Page>
189
+ <Text color="secondary" text="booking.availabilitySetup.loading" />
190
+ </Page>
196
191
  )
197
192
  }
198
193
 
199
194
  return (
200
- <View
201
- gap="m"
202
- surface="primary"
203
- style={{ padding: 'var(--space-m) var(--space-l)', height: '100%', overflowY: 'auto', maxWidth: 620 }}
195
+ <Page
196
+ maxWidth="s"
197
+ title="booking.availabilitySetup.title"
198
+ description="booking.availabilitySetup.description"
199
+ data-availability-status={saved ? 'saved' : 'draft'}
200
+ {...(workspaceId ? { 'data-provider-slug': workspaceId } : {})}
204
201
  >
205
- <View inset="s" gap="xs">
206
- <Title variant="secondary">{t('booking.availabilitySetup.title')}</Title>
207
- <Text style={{ maxWidth: 480 }}>{t('booking.availabilitySetup.description')}</Text>
208
- </View>
209
-
210
202
  <View gap="s" surface="secondary" roundness="m" inset="m">
211
- <Title variant="tertiary">{t('booking.availabilitySetup.weeklySchedule')}</Title>
203
+ <Text variant="heading-tertiary" as="h3" text="booking.availabilitySetup.weeklySchedule" />
212
204
  <View gap="xs">
213
205
  {dayDefs.map(({ dayOfWeek, key }) => {
214
206
  const day = dayState.find(d => d.dayOfWeek === dayOfWeek)
215
207
  return (
216
208
  <DayRow
217
209
  key={dayOfWeek}
218
- label={t(key)}
210
+ labelKey={key}
219
211
  day={day}
212
+ toggleAction={TOGGLE_AVAILABILITY_DAY[dayOfWeek]}
220
213
  onToggle={() => toggleDay(dayOfWeek)}
221
214
  onTimeChange={(field, value) => updateDayTime(dayOfWeek, field, value)}
222
- t={t}
223
215
  />
224
216
  )
225
217
  })}
@@ -227,23 +219,24 @@ export default function AvailabilitySetupPage ({ workspaceId }) {
227
219
  </View>
228
220
 
229
221
  <View gap="s" surface="secondary" roundness="m" inset="m">
230
- <Title variant="tertiary">{t('booking.availabilitySetup.sessionDuration')}</Title>
222
+ <Text variant="heading-tertiary" as="h3" text="booking.availabilitySetup.sessionDuration" />
231
223
  <View layout="row" gap="s" style={{ flexWrap: 'wrap' }}>
232
224
  {SESSION_DURATIONS.map(dur => (
233
225
  <Button
234
226
  key={dur}
227
+ {...SELECT_DURATION[dur]}
235
228
  variant={sessionDurations.includes(dur) ? 'tab-active' : 'tab'}
229
+ label="booking.availabilitySetup.durationMin"
230
+ params={{ minutes: dur }}
236
231
  onClick={() => toggleDuration(dur)}
237
- >
238
- {t('booking.availabilitySetup.durationMin', { minutes: dur })}
239
- </Button>
232
+ />
240
233
  ))}
241
234
  </View>
242
- <Text size="s" color="secondary">{t('booking.availabilitySetup.sessionDurationHint')}</Text>
235
+ <Text variant="small" color="secondary" text="booking.availabilitySetup.sessionDurationHint" />
243
236
  </View>
244
237
 
245
238
  <View gap="s" surface="secondary" roundness="m" inset="m">
246
- <Text weight="medium">{t('booking.availabilitySetup.bufferLabel')}</Text>
239
+ <Text weight="medium" text="booking.availabilitySetup.bufferLabel" />
247
240
  <Select
248
241
  id="buffer-select"
249
242
  value={bufferMinutes}
@@ -255,11 +248,11 @@ export default function AvailabilitySetupPage ({ workspaceId }) {
255
248
  </option>
256
249
  ))}
257
250
  </Select>
258
- <Text size="s" color="secondary">{t('booking.availabilitySetup.bufferHint')}</Text>
251
+ <Text variant="small" color="secondary" text="booking.availabilitySetup.bufferHint" />
259
252
  </View>
260
253
 
261
254
  <View gap="s" surface="secondary" roundness="m" inset="m">
262
- <Text weight="medium">{t('booking.availabilitySetup.timezone')}</Text>
255
+ <Text weight="medium" text="booking.availabilitySetup.timezone" />
263
256
  <Select id="tz-select" value={timezone} onChange={e => { setTimezone(e.target.value); setSaved(false) }}>
264
257
  {TIMEZONE_OPTIONS.map(tz => (
265
258
  <option key={tz} value={tz}>{tz}</option>
@@ -269,25 +262,31 @@ export default function AvailabilitySetupPage ({ workspaceId }) {
269
262
 
270
263
  {error && <Alert variant="danger">{error}</Alert>}
271
264
 
272
- <Button data-action={SaveAvailability.id} variant="primary" disabled={saving} onClick={handleSave}>
273
- {saving ? t('booking.availabilitySetup.saving') : t('booking.availabilitySetup.save')}
274
- </Button>
265
+ <Button
266
+ {...SaveAvailability}
267
+ variant="primary"
268
+ disabled={saving}
269
+ onClick={handleSave}
270
+ label={saving ? 'booking.availabilitySetup.saving' : 'booking.availabilitySetup.save'}
271
+ />
275
272
 
276
273
  {saved && workspaceId && (
277
274
  <Alert variant="success" title={t('booking.availabilitySetup.savedTitle')}>
278
275
  <View gap="s">
279
- <Text color="secondary">{t('booking.availabilitySetup.savedBody')}</Text>
276
+ <Text color="secondary" text="booking.availabilitySetup.savedBody" />
280
277
  <View layout="row" gap="s" alignItems="center">
281
278
  <Text as="code" style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: 'ui-monospace, monospace' }}>
282
279
  {bookingUrl}
283
280
  </Text>
284
- <Button variant={copied ? 'cta' : 'primary'} onClick={handleCopyLink}>
285
- {copied ? t('booking.availabilitySetup.copied') : t('booking.availabilitySetup.copyLink')}
286
- </Button>
281
+ <Button
282
+ variant={copied ? 'cta' : 'primary'}
283
+ onClick={handleCopyLink}
284
+ label={copied ? 'booking.availabilitySetup.copied' : 'booking.availabilitySetup.copyLink'}
285
+ />
287
286
  </View>
288
287
  </View>
289
288
  </Alert>
290
289
  )}
291
- </View>
290
+ </Page>
292
291
  )
293
292
  }
@@ -1,6 +1,6 @@
1
1
  export default {
2
2
  name: 'Availability',
3
- id: '@ossy/booking/availability',
3
+ id: '@ossy/booking/schema/availability',
4
4
  categoryName: 'Booking',
5
5
  icon: 'clock',
6
6
  fields: [
@@ -19,10 +19,6 @@ export default {
19
19
  name: 'endTime',
20
20
  type: 'text',
21
21
  },
22
- {
23
- name: 'bufferMinutes',
24
- type: 'number',
25
- },
26
22
  {
27
23
  // IANA timezone name, e.g. 'Europe/Stockholm'. Default applied at runtime.
28
24
  name: 'timezone',
@@ -1,7 +1,7 @@
1
1
  import React from 'react'
2
2
  import { EmailLayout, EmailText } from '@ossy/email'
3
3
 
4
- export const id = 'booking/cancellation'
4
+ export const id = '@ossy/booking/emails/cancellation'
5
5
  export const subject = 'Din bokning har avbokats'
6
6
 
7
7
  const SWEDISH_MONTHS = [
@@ -1,10 +1,10 @@
1
1
  import React, { useState } from 'react'
2
- import { View, Title, Text, Button, Badge, Alert, useLocale } from '@ossy/design-system'
2
+ import { View, Text, Button, Badge, Alert, useLocale } from '@ossy/design-system'
3
3
  import { metadata as ConfirmBooking } from './confirm-booking.action.js'
4
4
  import { metadata as DeclineBooking } from './decline-booking.action.js'
5
5
  import { metadata as CancelBooking } from './cancel-booking.action.js'
6
6
 
7
- export const metadata = { id: 'resource:booking/booking/card' }
7
+ export const metadata = { id: '@ossy/booking/view/booking/card' }
8
8
 
9
9
  const STATUS_VARIANT = {
10
10
  pending: 'warning',
@@ -73,7 +73,7 @@ export function BookingCard ({ booking, resource, onConfirm, onDecline, onCancel
73
73
  <View layout="row" justifyContent="space-between" alignItems="flex-start" gap="m">
74
74
  <View gap="xs" style={{ flex: 1, minWidth: 0 }}>
75
75
  <View layout="row" gap="s" alignItems="center" style={{ flexWrap: 'wrap' }}>
76
- <Title variant="tertiary">{data.clientName ?? '—'}</Title>
76
+ <Text variant="heading-tertiary" as="h3">{data.clientName ?? '—'}</Text>
77
77
  <Badge
78
78
  label={statusLabel[data.status] ?? data.status}
79
79
  variant={STATUS_VARIANT[data.status] ?? 'neutral'}
@@ -1,7 +1,7 @@
1
1
  import React from 'react'
2
2
  import { EmailLayout, EmailText, EmailButton } from '@ossy/email'
3
3
 
4
- export const id = 'booking/confirmation'
4
+ export const id = '@ossy/booking/emails/confirmation'
5
5
  export const subject = 'Bokningsbekräftelse'
6
6
 
7
7
  const SWEDISH_MONTHS = [
@@ -47,13 +47,15 @@ function buildIcsUrl(startAt, duration, title, baseUrl) {
47
47
 
48
48
  export default function BookingConfirmationEmail({
49
49
  clientName,
50
+ providerName,
50
51
  consultantName,
51
52
  startAt,
52
53
  duration,
53
54
  baseUrl = '',
54
55
  }) {
55
56
  const formattedDate = formatSwedishDateTime(startAt)
56
- const icsUrl = buildIcsUrl(startAt, duration, `Möte med ${consultantName}`, baseUrl)
57
+ const name = providerName ?? consultantName
58
+ const icsUrl = buildIcsUrl(startAt, duration, `Möte med ${name}`, baseUrl)
57
59
 
58
60
  return (
59
61
  <EmailLayout>
@@ -66,7 +68,7 @@ export default function BookingConfirmationEmail({
66
68
  </EmailText>
67
69
 
68
70
  <EmailText>
69
- Din bokning hos <strong>{consultantName}</strong> är nu bekräftad.
71
+ Din bokning hos <strong>{name}</strong> är nu bekräftad.
70
72
  </EmailText>
71
73
 
72
74
  <table cellPadding={0} cellSpacing={0} style={{ marginBottom: 32, width: '100%' }}>
@@ -1,6 +1,6 @@
1
1
  import React, { useState, useEffect, useCallback, useMemo } from 'react'
2
2
  import { useRouter } from '@ossy/router-react'
3
- import { View, Title, Text, Button, Badge, Alert, Textarea, useLocale } from '@ossy/design-system'
3
+ import { View, Text, Button, Badge, Alert, Textarea, Page, useLocale } from '@ossy/design-system'
4
4
  import { useSdk } from '@ossy/sdk-react'
5
5
  import { metadata as ConfirmBooking } from './confirm-booking.action.js'
6
6
  import { metadata as DeclineBooking } from './decline-booking.action.js'
@@ -74,7 +74,7 @@ function DeclineForm ({ booking, onSuccess, onError, t, sdk }) {
74
74
  <Alert variant="danger" title={t('booking.bookingDetail.declineTitle')}>
75
75
  <View gap="s">
76
76
  <Textarea value={reason} onChange={e => setReason(e.target.value)} rows={3} placeholder={t('booking.bookingDetail.declinePlaceholder')} />
77
- <Text variant="small" color="secondary">{t('booking.bookingDetail.declineReasonHint')}</Text>
77
+ <Text variant="small" color="secondary" text="booking.bookingDetail.declineReasonHint" />
78
78
  <View layout="row" gap="s">
79
79
  <Button data-action={DeclineBooking.id} variant="danger" disabled={loading} onClick={handleDecline}>{loading ? t('booking.bookingDetail.declining') : t('booking.bookingDetail.declineConfirm')}</Button>
80
80
  <Button variant="neutral" onClick={() => setExpanded(false)}>{t('booking.bookingDetail.cancelButton')}</Button>
@@ -98,7 +98,7 @@ function CancelForm ({ booking, onSuccess, onError, t, sdk }) {
98
98
  return (
99
99
  <Alert variant="danger" title={t('booking.bookingDetail.cancelTitle')}>
100
100
  <View gap="s">
101
- <Text color="secondary">{t('booking.bookingDetail.cancelBody')}</Text>
101
+ <Text color="secondary" text="booking.bookingDetail.cancelBody" />
102
102
  <View layout="row" gap="s">
103
103
  <Button data-action={CancelBooking.id} variant="danger" disabled={loading} onClick={handleCancel}>{loading ? t('booking.bookingDetail.cancelling') : t('booking.bookingDetail.cancelConfirm')}</Button>
104
104
  <Button variant="neutral" onClick={() => setExpanded(false)}>{t('booking.bookingDetail.cancelButton')}</Button>
@@ -143,10 +143,14 @@ export default function BookingDetailPage ({ bookingId: bookingIdProp }) {
143
143
  }
144
144
 
145
145
  return (
146
- <View gap="m" surface="primary" style={{ padding: 'var(--space-m) var(--space-l)', height: '100%', overflowY: 'auto' }}>
147
- <Button variant="link" onClick={goBack}>{t('booking.bookingDetail.back')}</Button>
146
+ <Page
147
+ title="booking.bookingDetail.pageTitle"
148
+ description="booking.bookingDetail.pageDescription"
149
+ >
150
+ <View gap="m">
151
+ <Button variant="link" onClick={goBack} label="booking.bookingDetail.back" />
148
152
  {loading ? (
149
- <Text color="secondary" style={{ textAlign: 'center', padding: 'var(--space-l)' }}>{t('booking.bookingDetail.loading')}</Text>
153
+ <Text color="secondary" style={{ textAlign: 'center', padding: 'var(--space-l)' }} text="booking.bookingDetail.loading" />
150
154
  ) : error ? (
151
155
  <Alert variant="danger">{error}</Alert>
152
156
  ) : booking ? (
@@ -154,7 +158,7 @@ export default function BookingDetailPage ({ bookingId: bookingIdProp }) {
154
158
  <View gap="m" surface="secondary" roundness="m" inset="l">
155
159
  <View layout="row" justifyContent="space-between" alignItems="flex-start" gap="m" style={{ flexWrap: 'wrap' }}>
156
160
  <View gap="xs">
157
- <Title variant="secondary">{booking.clientName ?? '—'}</Title>
161
+ <Text variant="heading-secondary" as="h2">{booking.clientName ?? '—'}</Text>
158
162
  {booking.clientEmail && <Text color="secondary">{booking.clientEmail}</Text>}
159
163
  </View>
160
164
  <Badge label={statusLabel[booking.status] ?? booking.status} variant={STATUS_VARIANT[booking.status] ?? 'neutral'} />
@@ -164,7 +168,7 @@ export default function BookingDetailPage ({ bookingId: bookingIdProp }) {
164
168
  <DetailRow label={t('booking.bookingDetail.label.duration')} value={formatDuration(booking.duration, t)} />
165
169
  {booking.clientMessage && (
166
170
  <View gap="xs">
167
- <Text weight="medium" color="secondary">{t('booking.bookingDetail.label.message')}</Text>
171
+ <Text weight="medium" color="secondary" text="booking.bookingDetail.label.message" />
168
172
  <View surface="primary" roundness="s" inset="s" style={{ borderLeft: '3px solid var(--separator)' }}>
169
173
  <Text style={{ whiteSpace: 'pre-wrap' }}>{booking.clientMessage}</Text>
170
174
  </View>
@@ -174,7 +178,7 @@ export default function BookingDetailPage ({ bookingId: bookingIdProp }) {
174
178
  </View>
175
179
  {(booking.status === 'pending' || booking.status === 'confirmed') && (
176
180
  <View gap="s" surface="secondary" roundness="m" inset="l">
177
- <Title variant="tertiary">{booking.status === 'pending' ? t('booking.bookingDetail.manageRequest') : t('booking.bookingDetail.manageBooking')}</Title>
181
+ <Text variant="heading-tertiary" as="h3">{booking.status === 'pending' ? t('booking.bookingDetail.manageRequest') : t('booking.bookingDetail.manageBooking')}</Text>
178
182
  {actionError && <Alert variant="danger">{actionError}</Alert>}
179
183
  {booking.status === 'pending' && (
180
184
  <View gap="s">
@@ -189,6 +193,7 @@ export default function BookingDetailPage ({ bookingId: bookingIdProp }) {
189
193
  )}
190
194
  </View>
191
195
  ) : null}
192
- </View>
196
+ </View>
197
+ </Page>
193
198
  )
194
199
  }
@@ -0,0 +1,45 @@
1
+ import jwt from 'jsonwebtoken'
2
+ import { ConfigService } from '@ossy/platform'
3
+
4
+ const TOKEN_TYPES = {
5
+ confirm: 'BookingConfirm',
6
+ decline: 'BookingDecline',
7
+ }
8
+
9
+ const BOOKING_TOKEN_VALIDITY = '7d'
10
+
11
+ /**
12
+ * Signed token for provider email links (confirm / decline pending bookings).
13
+ * @param {{ bookingId: string, workspaceId: string, purpose: 'confirm' | 'decline' }}
14
+ */
15
+ export function createBookingEmailToken ({ bookingId, workspaceId, purpose }) {
16
+ const type = TOKEN_TYPES[purpose]
17
+ if (!type) throw new Error(`Unknown booking email token purpose: ${purpose}`)
18
+ return jwt.sign(
19
+ { sub: bookingId, workspaceId, type },
20
+ ConfigService.TokenSecret,
21
+ { expiresIn: BOOKING_TOKEN_VALIDITY, algorithm: 'HS256' },
22
+ )
23
+ }
24
+
25
+ /**
26
+ * @param {string} token
27
+ * @param {'confirm' | 'decline'} purpose
28
+ * @returns {Promise<{ bookingId: string, workspaceId: string }>}
29
+ */
30
+ export function verifyBookingEmailToken (token, purpose) {
31
+ const expectedType = TOKEN_TYPES[purpose]
32
+ return new Promise((resolve, reject) => {
33
+ if (!token) return reject(Object.assign(new Error('No token provided'), { status: 400 }))
34
+ jwt.verify(token, ConfigService.TokenSecret, { algorithms: ['HS256'] }, (err, payload) => {
35
+ if (err) return reject(Object.assign(new Error('Invalid or expired token'), { status: 401 }))
36
+ if (payload?.type !== expectedType) {
37
+ return reject(Object.assign(new Error('Invalid token type'), { status: 401 }))
38
+ }
39
+ if (!payload?.sub || !payload?.workspaceId) {
40
+ return reject(Object.assign(new Error('Invalid token payload'), { status: 401 }))
41
+ }
42
+ resolve({ bookingId: payload.sub, workspaceId: payload.workspaceId })
43
+ })
44
+ })
45
+ }
@@ -0,0 +1,50 @@
1
+ import { useLocale } from '@ossy/design-system'
2
+ import { OpenSignUp } from '@ossy/authentication'
3
+
4
+ export function useBookingHomeContent () {
5
+ const { t } = useLocale()
6
+
7
+ const cover = {
8
+ title: t('booking.home.cover.title'),
9
+ text: t('booking.home.cover.text'),
10
+ actions: [
11
+ {
12
+ ...OpenSignUp,
13
+ variant: 'cta',
14
+ href: '/sign-up',
15
+ label: t('booking.home.cover.ctaPrimary'),
16
+ },
17
+ { label: t('booking.home.cover.ctaSecondary'), variant: 'secondary', href: '/boka/demo' },
18
+ ],
19
+ }
20
+
21
+ const features = [
22
+ {
23
+ title: t('booking.home.features.page.title'),
24
+ icon: 'share',
25
+ text: t('booking.home.features.page.text'),
26
+ },
27
+ {
28
+ title: t('booking.home.features.confirmations.title'),
29
+ icon: 'check-circle',
30
+ text: t('booking.home.features.confirmations.text'),
31
+ },
32
+ {
33
+ title: t('booking.home.features.availability.title'),
34
+ icon: 'calendar',
35
+ text: t('booking.home.features.availability.text'),
36
+ },
37
+ {
38
+ title: t('booking.home.features.email.title'),
39
+ icon: 'mail',
40
+ text: t('booking.home.features.email.text'),
41
+ },
42
+ {
43
+ title: t('booking.home.features.doubleBook.title'),
44
+ icon: 'warning',
45
+ text: t('booking.home.features.doubleBook.text'),
46
+ },
47
+ ]
48
+
49
+ return { cover, features }
50
+ }
@@ -0,0 +1,65 @@
1
+ import { BookingSchema } from './schema-ids.js'
2
+
3
+ /**
4
+ * ADR 0008 projection — workspace booking list derived from booking resource events.
5
+ */
6
+ export class BookingListProjection {
7
+
8
+ static kind = 'projection'
9
+ static ProjectionId = '@ossy/booking/data/booking-list'
10
+
11
+ static sources = [
12
+ { type: BookingSchema.booking, event: 'Created' },
13
+ { type: BookingSchema.booking, event: 'Patched' },
14
+ { type: BookingSchema.booking, event: 'Deleted' },
15
+ ]
16
+
17
+ static scopeFromEvent (event) {
18
+ return event.payload?.belongsTo ?? null
19
+ }
20
+
21
+ static cacheKeys (scopeId) {
22
+ return [
23
+ `projection:${BookingListProjection.ProjectionId}:${scopeId}`,
24
+ 'action:@ossy/booking/actions/list',
25
+ ]
26
+ }
27
+
28
+ static initialState (scopeId) {
29
+ return { workspaceId: scopeId, bookings: [] }
30
+ }
31
+
32
+ static Apply (event, state) {
33
+ const workspaceId = event.payload?.belongsTo ?? state.workspaceId
34
+ const entry = {
35
+ id: event.resourceId,
36
+ ...(event.payload?.content ?? {}),
37
+ updatedAt: event.created,
38
+ }
39
+
40
+ if (event.event === 'Created') {
41
+ entry.createdAt = event.created
42
+ }
43
+
44
+ const bookings = [...(state.bookings ?? [])]
45
+
46
+ if (event.event === 'Deleted') {
47
+ return {
48
+ workspaceId,
49
+ bookings: bookings.filter(b => b.id !== event.resourceId),
50
+ }
51
+ }
52
+
53
+ const idx = bookings.findIndex(b => b.id === event.resourceId)
54
+ if (idx >= 0) {
55
+ bookings[idx] = { ...bookings[idx], ...entry }
56
+ } else {
57
+ bookings.push(entry)
58
+ }
59
+
60
+ return { workspaceId, bookings }
61
+ }
62
+ }
63
+
64
+ export { BookingListProjection as Aggregate }
65
+ export const id = 'booking/data/booking-list'
@@ -1,7 +1,7 @@
1
1
  import React from 'react'
2
2
  import { EmailLayout, EmailText } from '@ossy/email'
3
3
 
4
- export const id = 'booking/reminder'
4
+ export const id = '@ossy/booking/emails/reminder'
5
5
  export const subject = 'Påminnelse: din bokning imorgon'
6
6
 
7
7
  const SWEDISH_MONTHS = [
@@ -24,8 +24,9 @@ function formatSwedishDateTime(isoString) {
24
24
  return `${weekday} ${day} ${month} ${year} kl. ${hours}:${minutes}`
25
25
  }
26
26
 
27
- export default function BookingReminderEmail({ clientName, consultantName, startAt, duration }) {
27
+ export default function BookingReminderEmail({ clientName, providerName, consultantName, startAt, duration }) {
28
28
  const formattedDate = formatSwedishDateTime(startAt)
29
+ const name = providerName ?? consultantName
29
30
 
30
31
  return (
31
32
  <EmailLayout>
@@ -37,7 +38,7 @@ export default function BookingReminderEmail({ clientName, consultantName, start
37
38
 
38
39
  <EmailText>
39
40
  Det här är en påminnelse om att du har en bokning hos{' '}
40
- <strong>{consultantName ?? 'din konsult'}</strong> imorgon.
41
+ <strong>{name ?? 'din utförare'}</strong> imorgon.
41
42
  </EmailText>
42
43
 
43
44
  <table cellPadding={0} cellSpacing={0} style={{ marginBottom: 32 }}>
@@ -1,7 +1,7 @@
1
1
  import React from 'react'
2
2
  import { EmailLayout, EmailText, EmailButton } from '@ossy/email'
3
3
 
4
- export const id = 'booking/request'
4
+ export const id = '@ossy/booking/emails/request'
5
5
  export const subject = 'Ny bokningsförfrågan'
6
6
 
7
7
  const SWEDISH_MONTHS = [
@@ -25,6 +25,7 @@ function formatSwedishDateTime(isoString) {
25
25
  }
26
26
 
27
27
  export default function BookingRequestEmail({
28
+ providerName,
28
29
  consultantName,
29
30
  clientName,
30
31
  clientEmail,
@@ -36,6 +37,8 @@ export default function BookingRequestEmail({
36
37
  }) {
37
38
  const formattedDate = formatSwedishDateTime(startAt)
38
39
 
40
+ const name = providerName ?? consultantName
41
+
39
42
  return (
40
43
  <EmailLayout>
41
44
  <h1 style={{ color: '#111111', fontSize: 24, fontWeight: 700, margin: '0 0 24px' }}>
@@ -43,7 +46,7 @@ export default function BookingRequestEmail({
43
46
  </h1>
44
47
 
45
48
  <EmailText>
46
- Hej {consultantName},
49
+ Hej {name},
47
50
  </EmailText>
48
51
 
49
52
  <EmailText>
@@ -1,10 +1,10 @@
1
1
  import { ResourcesQueries } from '@ossy/resources/server'
2
2
 
3
3
  /**
4
- * List booking resources by type (and optional workspace).
4
+ * List booking resources by schema id (and optional workspace).
5
5
  */
6
- export function getBookingResources ({ type, belongsTo }) {
7
- const query = { type }
6
+ export function getBookingResources ({ schemaId, belongsTo }) {
7
+ const query = { type: schemaId }
8
8
  if (belongsTo) query.belongsTo = belongsTo
9
9
  return ResourcesQueries.GetResources(query)
10
10
  }
@@ -1,6 +1,6 @@
1
1
  export default {
2
2
  name: 'Booking',
3
- id: '@ossy/booking/booking',
3
+ id: '@ossy/booking/schema/booking',
4
4
  categoryName: 'Booking',
5
5
  icon: 'calendar-check',
6
6
  fields: [
@@ -18,11 +18,11 @@ export default {
18
18
  },
19
19
  {
20
20
  name: 'startsAt',
21
- type: 'text',
21
+ type: 'timestamp',
22
22
  },
23
23
  {
24
24
  name: 'endsAt',
25
- type: 'text',
25
+ type: 'timestamp',
26
26
  },
27
27
  {
28
28
  // pending | confirmed | cancelled
@@ -38,6 +38,15 @@ export default {
38
38
  name: 'stripePaymentIntentId',
39
39
  type: 'text',
40
40
  },
41
+ {
42
+ name: 'workspaceId',
43
+ type: 'text',
44
+ },
45
+ {
46
+ // Legacy alias — reads accept providerId during transition
47
+ name: 'providerId',
48
+ type: 'text',
49
+ },
41
50
  {
42
51
  name: 'notes',
43
52
  type: 'textarea',