@ossy/booking 1.15.7 → 1.16.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.
- package/README.md +26 -19
- package/package.json +7 -7
- package/src/AvailabilityEditor.jsx +22 -15
- package/src/Definition.js +1 -23
- package/src/SalesSection.jsx +9 -4
- package/src/availability-setup.page.jsx +94 -126
- package/src/booking-card.component.jsx +30 -33
- package/src/booking-detail.page.jsx +65 -204
- package/src/booking-resources.js +10 -0
- package/src/bookings.page.jsx +54 -56
- package/src/cancel-booking.action.js +1 -61
- package/src/cancel-booking.task.js +60 -0
- package/src/confirm-booking.action.js +1 -82
- package/src/confirm-booking.task.js +81 -0
- package/src/create-booking.action.js +1 -168
- package/src/create-booking.task.js +170 -0
- package/src/create-service.action.js +1 -0
- package/src/create-service.task.js +46 -0
- package/src/decline-booking.action.js +1 -68
- package/src/decline-booking.task.js +67 -0
- package/src/delete-service.action.js +1 -0
- package/src/delete-service.task.js +41 -0
- package/src/en.translations.json +246 -0
- package/src/get-availability.action.js +1 -52
- package/src/get-availability.task.js +51 -0
- package/src/get-available-slots.action.js +1 -89
- package/src/get-available-slots.task.js +99 -0
- package/src/get-services.action.js +1 -0
- package/src/get-services.task.js +37 -0
- package/src/home.page.jsx +48 -5
- package/src/index.js +20 -1
- package/src/invoke-error-message.js +10 -0
- package/src/list-bookings.action.js +1 -36
- package/src/list-bookings.task.js +35 -0
- package/src/locations.js +14 -0
- package/src/public-booking.page.jsx +200 -245
- package/src/save-availability.action.js +1 -73
- package/src/save-availability.task.js +74 -0
- package/src/send-booking-reminder.task.js +2 -2
- package/src/services.page.jsx +167 -46
- package/src/sv.translations.json +246 -0
- package/src/update-service.action.js +1 -0
- package/src/update-service.task.js +52 -0
- package/src/BookingForm.jsx +0 -82
- package/src/availability.page.jsx +0 -41
- package/src/booking-page.page.jsx +0 -42
package/README.md
CHANGED
|
@@ -1,39 +1,46 @@
|
|
|
1
1
|
# @ossy/booking
|
|
2
2
|
|
|
3
|
-
Booking feature package for the Ossy platform. Lets consultants define services, set weekly availability, and accept client bookings
|
|
3
|
+
Booking feature package for the Ossy platform. Lets consultants define services, set weekly availability, and accept client bookings.
|
|
4
4
|
|
|
5
5
|
## Pages
|
|
6
6
|
|
|
7
7
|
| Page | Path | Audience |
|
|
8
8
|
|------|------|----------|
|
|
9
|
-
| `booking
|
|
10
|
-
| `services` | `/services` | Consultant (authenticated) |
|
|
11
|
-
| `bookings` | `/bookings` | Consultant (authenticated) |
|
|
12
|
-
| `availability` | `/availability` | Consultant (authenticated) |
|
|
9
|
+
| `public-booking` | `/book/:consultantSlug` · `/boka/:consultantSlug` | Public (clients) |
|
|
10
|
+
| `services` | `/services` · `/tjanster` | Consultant (authenticated) |
|
|
11
|
+
| `bookings` | `/bookings` · `/bokningar` | Consultant (authenticated) |
|
|
12
|
+
| `availability-setup` | `/availability` · `/tillganglighet` | Consultant (authenticated) |
|
|
13
|
+
| `booking-detail` | `/bookings/:bookingId` · `/bokningar/:bookingId` | Consultant (authenticated) |
|
|
14
|
+
|
|
15
|
+
## Public booking flow
|
|
16
|
+
|
|
17
|
+
1. Client opens `public-booking` (no auth; `metadata.public: true`)
|
|
18
|
+
2. Picks a service → calendar slot → contact details
|
|
19
|
+
3. `booking/create` stores a pending booking and sends notification emails
|
|
20
|
+
|
|
21
|
+
## Service actions
|
|
22
|
+
|
|
23
|
+
| Action | Access |
|
|
24
|
+
|--------|--------|
|
|
25
|
+
| `booking/get-services` | public |
|
|
26
|
+
| `booking/create-service` | workspace |
|
|
27
|
+
| `booking/update-service` | workspace |
|
|
28
|
+
| `booking/delete-service` | workspace |
|
|
13
29
|
|
|
14
30
|
## Resource templates
|
|
15
31
|
|
|
16
|
-
|
|
32
|
+
- `@ossy/booking/service` — what a consultant sells (`/@ossy/booking/services/`)
|
|
33
|
+
- `@ossy/booking/booking` — an appointment (`/@ossy/booking/bookings/`)
|
|
34
|
+
- `@ossy/booking/availability` — weekly availability config (`/@ossy/booking/availability/`)
|
|
17
35
|
|
|
18
|
-
|
|
19
|
-
- `@ossy/booking/booking` — a confirmed appointment
|
|
20
|
-
- `@ossy/booking/availability` — recurring weekly availability window
|
|
36
|
+
Import locations from `@ossy/booking` (`services`, `bookings`, `availability`).
|
|
21
37
|
|
|
22
38
|
## Resource view slots
|
|
23
39
|
|
|
24
|
-
Register custom UI for resource types via `*.component.jsx` with `metadata.id` matching the slot key (see `@ossy/design-system` [SLOTS.md](../design-system/docs/SLOTS.md)):
|
|
25
|
-
|
|
26
40
|
| Component | Slot key |
|
|
27
41
|
|-----------|----------|
|
|
28
42
|
| `service-detail.component.jsx` | `resource:booking/service/detail` |
|
|
29
43
|
| `service-card-slot.component.jsx` | `resource:booking/service/card` |
|
|
30
44
|
| `booking-card.component.jsx` | `resource:booking/booking/card` |
|
|
31
45
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
## What still needs implementation
|
|
35
|
-
|
|
36
|
-
- **Slot generation** — derive available time slots from `availability` records minus existing `booking` records
|
|
37
|
-
- **Stripe integration** — create a PaymentIntent on booking submit, webhook to flip `paymentStatus`
|
|
38
|
-
- **Confirmation emails** — `booking.send-confirmation-email` task
|
|
39
|
-
- **Public workspace resolution** — resolve `workspaceId` param on the public booking page without auth
|
|
46
|
+
See [SPEC.md](./SPEC.md) for the full product specification.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/booking",
|
|
3
3
|
"description": "Booking feature package — services, availability, and appointment management for Ossy consultants",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.16.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"module": "./src/index.js",
|
|
@@ -14,11 +14,11 @@
|
|
|
14
14
|
"src": "./src"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@ossy/email": "^1.
|
|
18
|
-
"@ossy/event-store": "^1.
|
|
19
|
-
"@ossy/observability": "^1.
|
|
20
|
-
"@ossy/platform": "^1.
|
|
21
|
-
"@ossy/resources": "^1.
|
|
17
|
+
"@ossy/email": "^1.6.1",
|
|
18
|
+
"@ossy/event-store": "^1.8.1",
|
|
19
|
+
"@ossy/observability": "^1.8.1",
|
|
20
|
+
"@ossy/platform": "^1.39.1",
|
|
21
|
+
"@ossy/resources": "^1.12.1"
|
|
22
22
|
},
|
|
23
23
|
"peerDependencies": {
|
|
24
24
|
"@ossy/design-system": ">=1.0.0",
|
|
@@ -34,5 +34,5 @@
|
|
|
34
34
|
"/src",
|
|
35
35
|
"README.md"
|
|
36
36
|
],
|
|
37
|
-
"gitHead": "
|
|
37
|
+
"gitHead": "c0ba5d90749690634e4dc2705178ff8d89dd3070"
|
|
38
38
|
}
|
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
import React, { useState } from 'react'
|
|
2
|
-
import { View,
|
|
3
|
-
|
|
4
|
-
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
|
1
|
+
import React, { useState, useMemo } from 'react'
|
|
2
|
+
import { View, Text, Button, Input, useLocale } from '@ossy/design-system'
|
|
5
3
|
|
|
6
4
|
const DEFAULT_WINDOW = { enabled: false, startTime: '09:00', endTime: '17:00', bufferMinutes: 0 }
|
|
7
5
|
|
|
8
6
|
const buildInitialState = (availability = []) => {
|
|
9
|
-
const state =
|
|
7
|
+
const state = Array.from({ length: 7 }, () => ({ ...DEFAULT_WINDOW }))
|
|
10
8
|
for (const window of availability) {
|
|
11
9
|
if (window.dayOfWeek >= 0 && window.dayOfWeek <= 6) {
|
|
12
10
|
state[window.dayOfWeek] = {
|
|
@@ -21,8 +19,19 @@ const buildInitialState = (availability = []) => {
|
|
|
21
19
|
}
|
|
22
20
|
|
|
23
21
|
export const AvailabilityEditor = ({ availability = [], onSave }) => {
|
|
22
|
+
const { t } = useLocale()
|
|
24
23
|
const [days, setDays] = useState(() => buildInitialState(availability))
|
|
25
24
|
|
|
25
|
+
const dayKeys = useMemo(() => [
|
|
26
|
+
'booking.availabilityEditor.day.sun',
|
|
27
|
+
'booking.availabilityEditor.day.mon',
|
|
28
|
+
'booking.availabilityEditor.day.tue',
|
|
29
|
+
'booking.availabilityEditor.day.wed',
|
|
30
|
+
'booking.availabilityEditor.day.thu',
|
|
31
|
+
'booking.availabilityEditor.day.fri',
|
|
32
|
+
'booking.availabilityEditor.day.sat',
|
|
33
|
+
], [])
|
|
34
|
+
|
|
26
35
|
const update = (idx, patch) =>
|
|
27
36
|
setDays((prev) => prev.map((d, i) => (i === idx ? { ...d, ...patch } : d)))
|
|
28
37
|
|
|
@@ -37,16 +46,15 @@ export const AvailabilityEditor = ({ availability = [], onSave }) => {
|
|
|
37
46
|
bufferMinutes: Number(bufferMinutes),
|
|
38
47
|
}))
|
|
39
48
|
|
|
40
|
-
// TODO: dispatch booking.set-availability action with `windows`
|
|
41
49
|
onSave?.(windows)
|
|
42
50
|
}
|
|
43
51
|
|
|
44
52
|
return (
|
|
45
53
|
<View gap="m">
|
|
46
54
|
<View gap="s">
|
|
47
|
-
{
|
|
55
|
+
{dayKeys.map((key, idx) => (
|
|
48
56
|
<View
|
|
49
|
-
key={
|
|
57
|
+
key={key}
|
|
50
58
|
layout="row"
|
|
51
59
|
gap="m"
|
|
52
60
|
alignItems="center"
|
|
@@ -62,7 +70,7 @@ export const AvailabilityEditor = ({ availability = [], onSave }) => {
|
|
|
62
70
|
style={{ width: 16, height: 16, accentColor: 'var(--color-accent)' }}
|
|
63
71
|
/>
|
|
64
72
|
</label>
|
|
65
|
-
<Text weight="medium" style={{ width: '2.5rem', flexShrink: 0 }}>{
|
|
73
|
+
<Text weight="medium" style={{ width: '2.5rem', flexShrink: 0 }}>{t(key)}</Text>
|
|
66
74
|
|
|
67
75
|
{days[idx].enabled ? (
|
|
68
76
|
<View layout="row" gap="s" alignItems="center" style={{ flex: 1 }}>
|
|
@@ -72,14 +80,14 @@ export const AvailabilityEditor = ({ availability = [], onSave }) => {
|
|
|
72
80
|
onChange={(v) => update(idx, { startTime: v })}
|
|
73
81
|
size="s"
|
|
74
82
|
/>
|
|
75
|
-
<Text color="secondary">to</Text>
|
|
83
|
+
<Text color="secondary">{t('booking.availabilityEditor.to')}</Text>
|
|
76
84
|
<Input
|
|
77
85
|
type="time"
|
|
78
86
|
value={days[idx].endTime}
|
|
79
87
|
onChange={(v) => update(idx, { endTime: v })}
|
|
80
88
|
size="s"
|
|
81
89
|
/>
|
|
82
|
-
<Text color="secondary" size="s">
|
|
90
|
+
<Text color="secondary" size="s">{t('booking.availabilityEditor.buffer')}</Text>
|
|
83
91
|
<Input
|
|
84
92
|
type="number"
|
|
85
93
|
value={String(days[idx].bufferMinutes)}
|
|
@@ -87,17 +95,16 @@ export const AvailabilityEditor = ({ availability = [], onSave }) => {
|
|
|
87
95
|
size="s"
|
|
88
96
|
style={{ width: '4rem' }}
|
|
89
97
|
/>
|
|
90
|
-
<Text color="secondary" size="s">min</Text>
|
|
98
|
+
<Text color="secondary" size="s">{t('booking.availabilityEditor.min')}</Text>
|
|
91
99
|
</View>
|
|
92
100
|
) : (
|
|
93
|
-
<Text color="secondary" size="s">
|
|
101
|
+
<Text color="secondary" size="s">{t('booking.availabilityEditor.unavailable')}</Text>
|
|
94
102
|
)}
|
|
95
103
|
</View>
|
|
96
104
|
))}
|
|
97
105
|
</View>
|
|
98
106
|
|
|
99
|
-
|
|
100
|
-
<Button variant="primary" onClick={handleSave}>Save availability</Button>
|
|
107
|
+
<Button variant="primary" onClick={handleSave}>{t('booking.availabilityEditor.save')}</Button>
|
|
101
108
|
</View>
|
|
102
109
|
)
|
|
103
110
|
}
|
package/src/Definition.js
CHANGED
|
@@ -2,28 +2,6 @@ export const Definition = {
|
|
|
2
2
|
id: 'booking',
|
|
3
3
|
title: 'Booking',
|
|
4
4
|
description: 'Services, availability windows, and client appointment management for consultants.',
|
|
5
|
-
|
|
6
|
-
id: 'booking',
|
|
7
|
-
enabled: true,
|
|
8
|
-
},
|
|
5
|
+
icon: 'calendar',
|
|
9
6
|
statuses: ['beta'],
|
|
10
|
-
actions: [
|
|
11
|
-
'booking.create-service',
|
|
12
|
-
'booking.update-service',
|
|
13
|
-
'booking.delete-service',
|
|
14
|
-
'booking.create-booking',
|
|
15
|
-
'booking.confirm-booking',
|
|
16
|
-
'booking.cancel-booking',
|
|
17
|
-
'booking.set-availability',
|
|
18
|
-
],
|
|
19
|
-
views: [
|
|
20
|
-
'booking.get-services',
|
|
21
|
-
'booking.get-bookings',
|
|
22
|
-
'booking.get-availability',
|
|
23
|
-
'booking.get-available-slots',
|
|
24
|
-
],
|
|
25
|
-
tasks: [
|
|
26
|
-
'booking.send-confirmation-email',
|
|
27
|
-
'booking.sync-stripe-payment-status',
|
|
28
|
-
],
|
|
29
7
|
}
|
package/src/SalesSection.jsx
CHANGED
|
@@ -6,20 +6,24 @@ import {
|
|
|
6
6
|
Icon,
|
|
7
7
|
PageSection,
|
|
8
8
|
MarkdownViewer,
|
|
9
|
+
useLocale,
|
|
9
10
|
} from '@ossy/design-system'
|
|
10
11
|
import { HeroCover } from './HeroCover.jsx'
|
|
11
12
|
|
|
12
13
|
export default ({
|
|
13
14
|
cover,
|
|
14
15
|
features,
|
|
15
|
-
}) =>
|
|
16
|
+
}) => {
|
|
17
|
+
const { t } = useLocale()
|
|
18
|
+
|
|
19
|
+
return (
|
|
16
20
|
<PageSection maxWidth="xl" gap="l">
|
|
17
21
|
<View gap="l" surface="primary" style={{ padding: 'var(--space-m) var(--space-l)', height: '100%', overflowY: 'auto' }}>
|
|
18
22
|
<HeroCover {...cover} />
|
|
19
23
|
|
|
20
24
|
<View gap="m" inset="s">
|
|
21
25
|
<Title variant="secondary">
|
|
22
|
-
|
|
26
|
+
{t('booking.sales.overview')}
|
|
23
27
|
</Title>
|
|
24
28
|
|
|
25
29
|
<View gap="m" layout="row-wrap">
|
|
@@ -47,7 +51,7 @@ export default ({
|
|
|
47
51
|
|
|
48
52
|
<View gap="m" inset="s">
|
|
49
53
|
<Title variant="secondary">
|
|
50
|
-
|
|
54
|
+
{t('booking.sales.features')}
|
|
51
55
|
</Title>
|
|
52
56
|
|
|
53
57
|
{features.map(x => (
|
|
@@ -69,4 +73,5 @@ export default ({
|
|
|
69
73
|
</View>
|
|
70
74
|
</View>
|
|
71
75
|
</PageSection>
|
|
72
|
-
)
|
|
76
|
+
)
|
|
77
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useEffect } from 'react'
|
|
1
|
+
import React, { useState, useEffect, useMemo } from 'react'
|
|
2
2
|
import {
|
|
3
3
|
View,
|
|
4
4
|
Title,
|
|
@@ -7,23 +7,19 @@ import {
|
|
|
7
7
|
Input,
|
|
8
8
|
Select,
|
|
9
9
|
Alert,
|
|
10
|
+
useLocale,
|
|
10
11
|
} from '@ossy/design-system'
|
|
12
|
+
import { useRouter } from '@ossy/router-react'
|
|
13
|
+
import { useSdk } from '@ossy/sdk-react'
|
|
14
|
+
import { metadata as SaveAvailability } from './save-availability.action.js'
|
|
15
|
+
import { metadata as GetAvailability } from './get-availability.action.js'
|
|
16
|
+
import { invokeErrorMessage } from './invoke-error-message.js'
|
|
11
17
|
|
|
12
18
|
export const metadata = {
|
|
13
19
|
id: 'booking/availability-setup',
|
|
14
20
|
path: { sv: '/tillganglighet', en: '/availability' },
|
|
15
21
|
}
|
|
16
22
|
|
|
17
|
-
const DAYS = [
|
|
18
|
-
{ label: 'Måndag', dayOfWeek: 1 },
|
|
19
|
-
{ label: 'Tisdag', dayOfWeek: 2 },
|
|
20
|
-
{ label: 'Onsdag', dayOfWeek: 3 },
|
|
21
|
-
{ label: 'Torsdag', dayOfWeek: 4 },
|
|
22
|
-
{ label: 'Fredag', dayOfWeek: 5 },
|
|
23
|
-
{ label: 'Lördag', dayOfWeek: 6 },
|
|
24
|
-
{ label: 'Söndag', dayOfWeek: 0 },
|
|
25
|
-
]
|
|
26
|
-
|
|
27
23
|
const SESSION_DURATIONS = [30, 60, 90]
|
|
28
24
|
const BUFFER_OPTIONS = [0, 15, 30]
|
|
29
25
|
const TIMEZONE_OPTIONS = [
|
|
@@ -45,15 +41,22 @@ function buildDayState (weeklyWindows = []) {
|
|
|
45
41
|
endTime: w.endTime ?? '17:00',
|
|
46
42
|
}
|
|
47
43
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
44
|
+
const dayOrder = [
|
|
45
|
+
{ dayOfWeek: 1, key: 'booking.availabilitySetup.day.monday' },
|
|
46
|
+
{ dayOfWeek: 2, key: 'booking.availabilitySetup.day.tuesday' },
|
|
47
|
+
{ dayOfWeek: 3, key: 'booking.availabilitySetup.day.wednesday' },
|
|
48
|
+
{ dayOfWeek: 4, key: 'booking.availabilitySetup.day.thursday' },
|
|
49
|
+
{ dayOfWeek: 5, key: 'booking.availabilitySetup.day.friday' },
|
|
50
|
+
{ dayOfWeek: 6, key: 'booking.availabilitySetup.day.saturday' },
|
|
51
|
+
{ dayOfWeek: 0, key: 'booking.availabilitySetup.day.sunday' },
|
|
52
|
+
]
|
|
53
|
+
return dayOrder.map(({ dayOfWeek }) => ({
|
|
54
|
+
dayOfWeek,
|
|
55
|
+
...(byDay[dayOfWeek] ?? { enabled: false, startTime: '09:00', endTime: '17:00' }),
|
|
56
|
+
}))
|
|
54
57
|
}
|
|
55
58
|
|
|
56
|
-
function DayRow ({ label, day, onToggle, onTimeChange }) {
|
|
59
|
+
function DayRow ({ label, day, onToggle, onTimeChange, t }) {
|
|
57
60
|
return (
|
|
58
61
|
<View
|
|
59
62
|
layout="row"
|
|
@@ -76,26 +79,31 @@ function DayRow ({ label, day, onToggle, onTimeChange }) {
|
|
|
76
79
|
|
|
77
80
|
{day.enabled ? (
|
|
78
81
|
<View layout="row" gap="s" alignItems="center" style={{ flex: 1, flexWrap: 'wrap' }}>
|
|
79
|
-
<Input
|
|
80
|
-
type="time"
|
|
81
|
-
value={day.startTime}
|
|
82
|
-
onChange={e => onTimeChange('startTime', e.target.value)}
|
|
83
|
-
/>
|
|
82
|
+
<Input type="time" value={day.startTime} onChange={e => onTimeChange('startTime', e.target.value)} />
|
|
84
83
|
<Text color="secondary">–</Text>
|
|
85
|
-
<Input
|
|
86
|
-
type="time"
|
|
87
|
-
value={day.endTime}
|
|
88
|
-
onChange={e => onTimeChange('endTime', e.target.value)}
|
|
89
|
-
/>
|
|
84
|
+
<Input type="time" value={day.endTime} onChange={e => onTimeChange('endTime', e.target.value)} />
|
|
90
85
|
</View>
|
|
91
86
|
) : (
|
|
92
|
-
<Text color="secondary" size="s">
|
|
87
|
+
<Text color="secondary" size="s">{t('booking.availabilitySetup.unavailable')}</Text>
|
|
93
88
|
)}
|
|
94
89
|
</View>
|
|
95
90
|
)
|
|
96
91
|
}
|
|
97
92
|
|
|
98
93
|
export default function AvailabilitySetupPage ({ workspaceId }) {
|
|
94
|
+
const { t } = useLocale()
|
|
95
|
+
const sdk = useSdk()
|
|
96
|
+
const router = useRouter()
|
|
97
|
+
const dayDefs = useMemo(() => [
|
|
98
|
+
{ dayOfWeek: 1, key: 'booking.availabilitySetup.day.monday' },
|
|
99
|
+
{ dayOfWeek: 2, key: 'booking.availabilitySetup.day.tuesday' },
|
|
100
|
+
{ dayOfWeek: 3, key: 'booking.availabilitySetup.day.wednesday' },
|
|
101
|
+
{ dayOfWeek: 4, key: 'booking.availabilitySetup.day.thursday' },
|
|
102
|
+
{ dayOfWeek: 5, key: 'booking.availabilitySetup.day.friday' },
|
|
103
|
+
{ dayOfWeek: 6, key: 'booking.availabilitySetup.day.saturday' },
|
|
104
|
+
{ dayOfWeek: 0, key: 'booking.availabilitySetup.day.sunday' },
|
|
105
|
+
], [])
|
|
106
|
+
|
|
99
107
|
const [dayState, setDayState] = useState(() => buildDayState())
|
|
100
108
|
const [sessionDurations, setSessionDurations] = useState([60])
|
|
101
109
|
const [bufferMinutes, setBufferMinutes] = useState(0)
|
|
@@ -106,11 +114,15 @@ export default function AvailabilitySetupPage ({ workspaceId }) {
|
|
|
106
114
|
const [error, setError] = useState(null)
|
|
107
115
|
const [copied, setCopied] = useState(false)
|
|
108
116
|
|
|
109
|
-
const
|
|
117
|
+
const bookingPath = workspaceId
|
|
118
|
+
? router.getHref({ id: 'public-booking', params: { consultantSlug: workspaceId } })
|
|
119
|
+
: ''
|
|
120
|
+
const bookingUrl = typeof window !== 'undefined' && bookingPath
|
|
121
|
+
? `${window.location.origin}${bookingPath}`
|
|
122
|
+
: bookingPath
|
|
110
123
|
|
|
111
124
|
useEffect(() => {
|
|
112
|
-
|
|
113
|
-
.then(r => (r.ok ? r.json() : null))
|
|
125
|
+
sdk.invoke(GetAvailability, {})
|
|
114
126
|
.then(data => {
|
|
115
127
|
if (data) {
|
|
116
128
|
setDayState(buildDayState(data.weeklyWindows ?? []))
|
|
@@ -126,21 +138,17 @@ export default function AvailabilitySetupPage ({ workspaceId }) {
|
|
|
126
138
|
})
|
|
127
139
|
.catch(() => {})
|
|
128
140
|
.finally(() => setLoading(false))
|
|
129
|
-
}, [])
|
|
141
|
+
}, [sdk])
|
|
130
142
|
|
|
131
143
|
const toggleDay = (dayOfWeek) => {
|
|
132
|
-
setDayState(prev => (
|
|
133
|
-
...
|
|
134
|
-
|
|
135
|
-
}))
|
|
144
|
+
setDayState(prev => prev.map(d => d.dayOfWeek === dayOfWeek
|
|
145
|
+
? { ...d, enabled: !d.enabled }
|
|
146
|
+
: d))
|
|
136
147
|
setSaved(false)
|
|
137
148
|
}
|
|
138
149
|
|
|
139
150
|
const updateDayTime = (dayOfWeek, field, value) => {
|
|
140
|
-
setDayState(prev => ({
|
|
141
|
-
...prev,
|
|
142
|
-
[dayOfWeek]: { ...prev[dayOfWeek], [field]: value },
|
|
143
|
-
}))
|
|
151
|
+
setDayState(prev => prev.map(d => d.dayOfWeek === dayOfWeek ? { ...d, [field]: value } : d))
|
|
144
152
|
setSaved(false)
|
|
145
153
|
}
|
|
146
154
|
|
|
@@ -159,27 +167,15 @@ export default function AvailabilitySetupPage ({ workspaceId }) {
|
|
|
159
167
|
setSaving(true)
|
|
160
168
|
setError(null)
|
|
161
169
|
|
|
162
|
-
const weeklyWindows =
|
|
163
|
-
dayOfWeek
|
|
164
|
-
startTime: dayState[d.dayOfWeek].startTime,
|
|
165
|
-
endTime: dayState[d.dayOfWeek].endTime,
|
|
170
|
+
const weeklyWindows = dayState.filter(d => d.enabled).map(({ dayOfWeek, startTime, endTime }) => ({
|
|
171
|
+
dayOfWeek, startTime, endTime,
|
|
166
172
|
}))
|
|
167
173
|
|
|
168
174
|
try {
|
|
169
|
-
|
|
170
|
-
method: 'POST',
|
|
171
|
-
headers: { 'Content-Type': 'application/json' },
|
|
172
|
-
body: JSON.stringify({ weeklyWindows, sessionDurations, bufferMinutes, timezone }),
|
|
173
|
-
})
|
|
174
|
-
|
|
175
|
-
if (!res.ok) {
|
|
176
|
-
const data = await res.json().catch(() => ({}))
|
|
177
|
-
throw new Error(data?.message ?? `Kunde inte spara (${res.status})`)
|
|
178
|
-
}
|
|
179
|
-
|
|
175
|
+
await sdk.invoke(SaveAvailability, { weeklyWindows, sessionDurations, bufferMinutes, timezone })
|
|
180
176
|
setSaved(true)
|
|
181
177
|
} catch (err) {
|
|
182
|
-
setError(err.
|
|
178
|
+
setError(await invokeErrorMessage(err, t('booking.availabilitySetup.errorSave', { status: '' })))
|
|
183
179
|
} finally {
|
|
184
180
|
setSaving(false)
|
|
185
181
|
}
|
|
@@ -195,7 +191,7 @@ export default function AvailabilitySetupPage ({ workspaceId }) {
|
|
|
195
191
|
if (loading) {
|
|
196
192
|
return (
|
|
197
193
|
<View surface="primary" inset="l" style={{ height: '100%', overflowY: 'auto' }}>
|
|
198
|
-
<Text color="secondary">
|
|
194
|
+
<Text color="secondary">{t('booking.availabilitySetup.loading')}</Text>
|
|
199
195
|
</View>
|
|
200
196
|
)
|
|
201
197
|
}
|
|
@@ -207,114 +203,86 @@ export default function AvailabilitySetupPage ({ workspaceId }) {
|
|
|
207
203
|
style={{ padding: 'var(--space-m) var(--space-l)', height: '100%', overflowY: 'auto', maxWidth: 620 }}
|
|
208
204
|
>
|
|
209
205
|
<View inset="s" gap="xs">
|
|
210
|
-
<Title variant="secondary">
|
|
211
|
-
<Text style={{ maxWidth: 480 }}>
|
|
212
|
-
Ange när du är tillgänglig för bokningar. Klienter kan bara boka tider inom dessa fönster.
|
|
213
|
-
</Text>
|
|
206
|
+
<Title variant="secondary">{t('booking.availabilitySetup.title')}</Title>
|
|
207
|
+
<Text style={{ maxWidth: 480 }}>{t('booking.availabilitySetup.description')}</Text>
|
|
214
208
|
</View>
|
|
215
209
|
|
|
216
210
|
<View gap="s" surface="secondary" roundness="m" inset="m">
|
|
217
|
-
<Title variant="tertiary">
|
|
211
|
+
<Title variant="tertiary">{t('booking.availabilitySetup.weeklySchedule')}</Title>
|
|
218
212
|
<View gap="xs">
|
|
219
|
-
{
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
213
|
+
{dayDefs.map(({ dayOfWeek, key }) => {
|
|
214
|
+
const day = dayState.find(d => d.dayOfWeek === dayOfWeek)
|
|
215
|
+
return (
|
|
216
|
+
<DayRow
|
|
217
|
+
key={dayOfWeek}
|
|
218
|
+
label={t(key)}
|
|
219
|
+
day={day}
|
|
220
|
+
onToggle={() => toggleDay(dayOfWeek)}
|
|
221
|
+
onTimeChange={(field, value) => updateDayTime(dayOfWeek, field, value)}
|
|
222
|
+
t={t}
|
|
223
|
+
/>
|
|
224
|
+
)
|
|
225
|
+
})}
|
|
228
226
|
</View>
|
|
229
227
|
</View>
|
|
230
228
|
|
|
231
229
|
<View gap="s" surface="secondary" roundness="m" inset="m">
|
|
232
|
-
<Title variant="tertiary">
|
|
230
|
+
<Title variant="tertiary">{t('booking.availabilitySetup.sessionDuration')}</Title>
|
|
233
231
|
<View layout="row" gap="s" style={{ flexWrap: 'wrap' }}>
|
|
234
|
-
{SESSION_DURATIONS.map(dur =>
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
</Button>
|
|
244
|
-
)
|
|
245
|
-
})}
|
|
232
|
+
{SESSION_DURATIONS.map(dur => (
|
|
233
|
+
<Button
|
|
234
|
+
key={dur}
|
|
235
|
+
variant={sessionDurations.includes(dur) ? 'tab-active' : 'tab'}
|
|
236
|
+
onClick={() => toggleDuration(dur)}
|
|
237
|
+
>
|
|
238
|
+
{t('booking.availabilitySetup.durationMin', { minutes: dur })}
|
|
239
|
+
</Button>
|
|
240
|
+
))}
|
|
246
241
|
</View>
|
|
247
|
-
<Text size="s" color="secondary">
|
|
248
|
-
Välj de sessionslängder klienter kan välja bland.
|
|
249
|
-
</Text>
|
|
242
|
+
<Text size="s" color="secondary">{t('booking.availabilitySetup.sessionDurationHint')}</Text>
|
|
250
243
|
</View>
|
|
251
244
|
|
|
252
245
|
<View gap="s" surface="secondary" roundness="m" inset="m">
|
|
253
|
-
<Text weight="medium">
|
|
246
|
+
<Text weight="medium">{t('booking.availabilitySetup.bufferLabel')}</Text>
|
|
254
247
|
<Select
|
|
255
248
|
id="buffer-select"
|
|
256
249
|
value={bufferMinutes}
|
|
257
|
-
onChange={e => {
|
|
258
|
-
setBufferMinutes(Number(e.target.value))
|
|
259
|
-
setSaved(false)
|
|
260
|
-
}}
|
|
250
|
+
onChange={e => { setBufferMinutes(Number(e.target.value)); setSaved(false) }}
|
|
261
251
|
>
|
|
262
252
|
{BUFFER_OPTIONS.map(opt => (
|
|
263
253
|
<option key={opt} value={opt}>
|
|
264
|
-
{opt === 0 ? '
|
|
254
|
+
{opt === 0 ? t('booking.availabilitySetup.bufferNone') : t('booking.availabilitySetup.bufferMinutes', { minutes: opt })}
|
|
265
255
|
</option>
|
|
266
256
|
))}
|
|
267
257
|
</Select>
|
|
268
|
-
<Text size="s" color="secondary">
|
|
269
|
-
Automatisk paustid som läggs till efter varje bokning.
|
|
270
|
-
</Text>
|
|
258
|
+
<Text size="s" color="secondary">{t('booking.availabilitySetup.bufferHint')}</Text>
|
|
271
259
|
</View>
|
|
272
260
|
|
|
273
261
|
<View gap="s" surface="secondary" roundness="m" inset="m">
|
|
274
|
-
<Text weight="medium">
|
|
275
|
-
<Select
|
|
276
|
-
id="tz-select"
|
|
277
|
-
value={timezone}
|
|
278
|
-
onChange={e => {
|
|
279
|
-
setTimezone(e.target.value)
|
|
280
|
-
setSaved(false)
|
|
281
|
-
}}
|
|
282
|
-
>
|
|
262
|
+
<Text weight="medium">{t('booking.availabilitySetup.timezone')}</Text>
|
|
263
|
+
<Select id="tz-select" value={timezone} onChange={e => { setTimezone(e.target.value); setSaved(false) }}>
|
|
283
264
|
{TIMEZONE_OPTIONS.map(tz => (
|
|
284
|
-
<option key={tz} value={tz}>
|
|
285
|
-
{tz}
|
|
286
|
-
</option>
|
|
265
|
+
<option key={tz} value={tz}>{tz}</option>
|
|
287
266
|
))}
|
|
288
267
|
</Select>
|
|
289
268
|
</View>
|
|
290
269
|
|
|
291
270
|
{error && <Alert variant="danger">{error}</Alert>}
|
|
292
271
|
|
|
293
|
-
<Button variant="primary" disabled={saving} onClick={handleSave}>
|
|
294
|
-
{saving ? '
|
|
272
|
+
<Button data-action={SaveAvailability.id} variant="primary" disabled={saving} onClick={handleSave}>
|
|
273
|
+
{saving ? t('booking.availabilitySetup.saving') : t('booking.availabilitySetup.save')}
|
|
295
274
|
</Button>
|
|
296
275
|
|
|
297
276
|
{saved && workspaceId && (
|
|
298
|
-
<Alert variant="success" title=
|
|
277
|
+
<Alert variant="success" title={t('booking.availabilitySetup.savedTitle')}>
|
|
299
278
|
<View gap="s">
|
|
300
|
-
<Text color="secondary">
|
|
301
|
-
Din bokningslänk är redo att delas med klienter.
|
|
302
|
-
</Text>
|
|
279
|
+
<Text color="secondary">{t('booking.availabilitySetup.savedBody')}</Text>
|
|
303
280
|
<View layout="row" gap="s" alignItems="center">
|
|
304
|
-
<Text
|
|
305
|
-
as="code"
|
|
306
|
-
style={{
|
|
307
|
-
flex: 1,
|
|
308
|
-
overflow: 'hidden',
|
|
309
|
-
textOverflow: 'ellipsis',
|
|
310
|
-
whiteSpace: 'nowrap',
|
|
311
|
-
fontFamily: 'ui-monospace, monospace',
|
|
312
|
-
}}
|
|
313
|
-
>
|
|
281
|
+
<Text as="code" style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: 'ui-monospace, monospace' }}>
|
|
314
282
|
{bookingUrl}
|
|
315
283
|
</Text>
|
|
316
284
|
<Button variant={copied ? 'cta' : 'primary'} onClick={handleCopyLink}>
|
|
317
|
-
{copied ? '
|
|
285
|
+
{copied ? t('booking.availabilitySetup.copied') : t('booking.availabilitySetup.copyLink')}
|
|
318
286
|
</Button>
|
|
319
287
|
</View>
|
|
320
288
|
</View>
|