@ossy/booking 1.16.2 → 1.16.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -21
- package/package.json +11 -8
- package/src/AvailabilityEditor.jsx +2 -13
- package/src/BookingList.jsx +1 -1
- package/src/BookingProductHome.jsx +38 -0
- package/src/BookingSalesPage.jsx +55 -0
- package/src/Definition.js +1 -1
- package/src/HeroCover.jsx +5 -5
- package/src/SalesSection.jsx +7 -15
- package/src/ServiceCard.jsx +2 -2
- package/src/availability-action-maps.js +39 -0
- package/src/availability-engine.js +18 -14
- package/src/availability-engine.spec.js +36 -4
- package/src/availability-setup.page.jsx +45 -46
- package/src/{availability.resource.js → availability.schema.js} +1 -5
- package/src/booking-cancellation.email.jsx +1 -1
- package/src/booking-card.component.jsx +3 -3
- package/src/booking-confirmation.email.jsx +5 -3
- package/src/booking-detail.page.jsx +15 -10
- package/src/booking-email-token.js +45 -0
- package/src/booking-home-content.js +50 -0
- package/src/booking-list.aggregate.js +65 -0
- package/src/booking-reminder.email.jsx +4 -3
- package/src/booking-request.email.jsx +5 -2
- package/src/booking-resources.js +3 -3
- package/src/{booking.resource.js → booking.schema.js} +12 -3
- package/src/bookings.component.jsx +7 -11
- package/src/bookings.page.jsx +29 -16
- package/src/cancel-booking.action.js +1 -1
- package/src/cancel-booking.task.js +14 -17
- package/src/confirm-booking.action.js +1 -1
- package/src/confirm-booking.api.js +35 -0
- package/src/confirm-booking.task.js +26 -24
- package/src/create-booking.action.js +1 -1
- package/src/create-booking.task.js +73 -37
- package/src/create-service.action.js +5 -1
- package/src/create-service.task.js +7 -7
- package/src/decline-booking.action.js +1 -1
- package/src/decline-booking.api.js +35 -0
- package/src/decline-booking.task.js +15 -18
- package/src/delete-service.action.js +1 -1
- package/src/delete-service.task.js +12 -15
- package/src/en.translations.json +83 -37
- package/src/get-availability.action.js +1 -1
- package/src/get-availability.task.js +5 -4
- package/src/get-available-slots.action.js +1 -1
- package/src/get-available-slots.task.js +16 -13
- package/src/get-services.action.js +1 -1
- package/src/get-services.task.js +13 -6
- package/src/home.page.jsx +14 -46
- package/src/index.js +17 -0
- package/src/list-bookings.action.js +1 -1
- package/src/list-bookings.task.js +33 -16
- package/src/locations.js +6 -4
- package/src/moduleStatus.js +30 -2
- package/src/open-availability-setup.action.js +5 -0
- package/src/open-bookings.action.js +5 -0
- package/src/open-create-service.action.js +5 -0
- package/src/open-services.action.js +5 -0
- package/src/provider-availability.flow.js +39 -0
- package/src/provider-fields.js +34 -0
- package/src/provider-onboard.flow.js +25 -0
- package/src/public-booking-form.form.js +7 -0
- package/src/public-booking-form.schema.js +12 -0
- package/src/public-booking.page.jsx +58 -56
- package/src/resolve-workspace-contact.js +55 -0
- package/src/save-availability.action.js +5 -1
- package/src/save-availability.task.js +26 -28
- package/src/schema-ids.js +7 -0
- package/src/select-duration-30.action.js +4 -0
- package/src/select-duration-60.action.js +4 -0
- package/src/select-duration-90.action.js +4 -0
- package/src/send-booking-reminder.task.js +39 -14
- package/src/service-card-slot.component.jsx +4 -4
- package/src/service-detail.component.jsx +4 -4
- package/src/service.form.js +7 -0
- package/src/service.schema.js +14 -0
- package/src/services.page.jsx +62 -58
- package/src/stories/AvailabilityEditor.stories.jsx +54 -0
- package/src/stories/AvailabilitySetupPage.stories.jsx +44 -0
- package/src/stories/BookingCard.stories.jsx +57 -0
- package/src/stories/BookingList.stories.jsx +30 -0
- package/src/stories/BookingsComponent.stories.jsx +31 -0
- package/src/stories/BookingsPage.stories.jsx +66 -0
- package/src/stories/HeroCover.stories.jsx +42 -0
- package/src/stories/PublicBookingForm.stories.jsx +66 -0
- package/src/stories/PublicBookingPage.stories.jsx +78 -0
- package/src/stories/ServiceCard.stories.jsx +52 -0
- package/src/stories/ServiceDetail.stories.jsx +52 -0
- package/src/stories/ServiceForm.stories.jsx +64 -0
- package/src/stories/ServicesPage.stories.jsx +53 -0
- package/src/stories/decorators.jsx +68 -0
- package/src/stories/fixtures.js +149 -0
- package/src/stories/story-helpers.jsx +85 -0
- package/src/sv.translations.json +83 -37
- package/src/time.js +27 -0
- package/src/toggle-availability-day-0.action.js +4 -0
- package/src/toggle-availability-day-1.action.js +4 -0
- package/src/toggle-availability-day-2.action.js +4 -0
- package/src/toggle-availability-day-3.action.js +4 -0
- package/src/toggle-availability-day-4.action.js +4 -0
- package/src/toggle-availability-day-5.action.js +4 -0
- package/src/toggle-availability-day-6.action.js +4 -0
- package/src/update-service.action.js +1 -1
- package/src/update-service.task.js +13 -16
- package/src/workspace-services.js +5 -0
- package/src/service.resource.js +0 -32
|
@@ -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 ({
|
|
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
|
|
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 }}
|
|
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"
|
|
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: {
|
|
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
|
-
<
|
|
194
|
-
<Text color="secondary"
|
|
195
|
-
</
|
|
188
|
+
<Page>
|
|
189
|
+
<Text color="secondary" text="booking.availabilitySetup.loading" />
|
|
190
|
+
</Page>
|
|
196
191
|
)
|
|
197
192
|
}
|
|
198
193
|
|
|
199
194
|
return (
|
|
200
|
-
<
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
|
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"
|
|
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
|
|
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"
|
|
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
|
|
273
|
-
{
|
|
274
|
-
|
|
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"
|
|
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
|
|
285
|
-
{copied ?
|
|
286
|
-
|
|
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
|
-
</
|
|
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,10 +1,10 @@
|
|
|
1
1
|
import React, { useState } from 'react'
|
|
2
|
-
import { View,
|
|
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: '
|
|
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
|
-
<
|
|
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
|
|
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>{
|
|
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,
|
|
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"
|
|
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"
|
|
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
|
-
<
|
|
147
|
-
|
|
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)' }}
|
|
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
|
-
<
|
|
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"
|
|
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
|
-
<
|
|
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
|
-
|
|
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>{
|
|
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 {
|
|
49
|
+
Hej {name},
|
|
47
50
|
</EmailText>
|
|
48
51
|
|
|
49
52
|
<EmailText>
|
package/src/booking-resources.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { ResourcesQueries } from '@ossy/resources/server'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* List booking resources by
|
|
4
|
+
* List booking resources by schema id (and optional workspace).
|
|
5
5
|
*/
|
|
6
|
-
export function getBookingResources ({
|
|
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: '
|
|
21
|
+
type: 'timestamp',
|
|
22
22
|
},
|
|
23
23
|
{
|
|
24
24
|
name: 'endsAt',
|
|
25
|
-
type: '
|
|
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',
|