@ossy/booking 1.1.0
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 +27 -0
- package/package.json +31 -0
- package/src/AvailabilityEditor.jsx +99 -0
- package/src/BookingForm.jsx +82 -0
- package/src/BookingList.jsx +71 -0
- package/src/Definition.js +29 -0
- package/src/ServiceCard.jsx +39 -0
- package/src/availability.page.jsx +41 -0
- package/src/availability.resource.js +27 -0
- package/src/booking-page.page.jsx +42 -0
- package/src/booking.resource.js +46 -0
- package/src/bookings.component.jsx +37 -0
- package/src/bookings.page.jsx +42 -0
- package/src/index.js +6 -0
- package/src/moduleStatus.js +2 -0
- package/src/service.resource.js +32 -0
- package/src/services.page.jsx +64 -0
package/README.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# @ossy/booking
|
|
2
|
+
|
|
3
|
+
Booking feature package for the Ossy platform. Lets consultants define services, set weekly availability, and accept client bookings with optional Stripe payment.
|
|
4
|
+
|
|
5
|
+
## Pages
|
|
6
|
+
|
|
7
|
+
| Page | Path | Audience |
|
|
8
|
+
|------|------|----------|
|
|
9
|
+
| `booking-page` | `/book/:workspaceId` | Public (clients) |
|
|
10
|
+
| `services` | `/services` | Consultant (authenticated) |
|
|
11
|
+
| `bookings` | `/bookings` | Consultant (authenticated) |
|
|
12
|
+
| `availability` | `/availability` | Consultant (authenticated) |
|
|
13
|
+
|
|
14
|
+
## Resource templates
|
|
15
|
+
|
|
16
|
+
Three resource schemas are registered under the `@ossy/booking` category:
|
|
17
|
+
|
|
18
|
+
- `@ossy/booking/service` — what a consultant sells
|
|
19
|
+
- `@ossy/booking/booking` — a confirmed appointment
|
|
20
|
+
- `@ossy/booking/availability` — recurring weekly availability window
|
|
21
|
+
|
|
22
|
+
## What still needs implementation
|
|
23
|
+
|
|
24
|
+
- **Slot generation** — derive available time slots from `availability` records minus existing `booking` records
|
|
25
|
+
- **Stripe integration** — create a PaymentIntent on booking submit, webhook to flip `paymentStatus`
|
|
26
|
+
- **Confirmation emails** — `booking.send-confirmation-email` task
|
|
27
|
+
- **Public workspace resolution** — resolve `workspaceId` param on the public booking page without auth
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ossy/booking",
|
|
3
|
+
"description": "Booking feature package — services, availability, and appointment management for Ossy consultants",
|
|
4
|
+
"version": "1.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"module": "./src/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.js"
|
|
10
|
+
},
|
|
11
|
+
"author": "Ossy <yourfriends@ossy.se> (https://ossy.se)",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"ossy": {
|
|
14
|
+
"src": "./src"
|
|
15
|
+
},
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"@ossy/design-system": ">=1.0.0",
|
|
18
|
+
"@ossy/router-react": ">=1.0.0",
|
|
19
|
+
"@ossy/sdk-react": ">=1.0.0",
|
|
20
|
+
"react": ">=19.0.0 <20.0.0"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public",
|
|
24
|
+
"registry": "https://registry.npmjs.org"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"/src",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"gitHead": "9b116b6a4fadd570c1e6c0c6d9a4867448f7410c"
|
|
31
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import React, { useState } from 'react'
|
|
2
|
+
import { View, Title, Text, Button, Input, Switch } from '@ossy/design-system'
|
|
3
|
+
|
|
4
|
+
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
|
5
|
+
|
|
6
|
+
const DEFAULT_WINDOW = { enabled: false, startTime: '09:00', endTime: '17:00', bufferMinutes: 0 }
|
|
7
|
+
|
|
8
|
+
const buildInitialState = (availability = []) => {
|
|
9
|
+
const state = DAY_LABELS.map(() => ({ ...DEFAULT_WINDOW }))
|
|
10
|
+
for (const window of availability) {
|
|
11
|
+
if (window.dayOfWeek >= 0 && window.dayOfWeek <= 6) {
|
|
12
|
+
state[window.dayOfWeek] = {
|
|
13
|
+
enabled: true,
|
|
14
|
+
startTime: window.startTime ?? '09:00',
|
|
15
|
+
endTime: window.endTime ?? '17:00',
|
|
16
|
+
bufferMinutes: window.bufferMinutes ?? 0,
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return state
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const AvailabilityEditor = ({ availability = [], onSave }) => {
|
|
24
|
+
const [days, setDays] = useState(() => buildInitialState(availability))
|
|
25
|
+
|
|
26
|
+
const update = (idx, patch) =>
|
|
27
|
+
setDays((prev) => prev.map((d, i) => (i === idx ? { ...d, ...patch } : d)))
|
|
28
|
+
|
|
29
|
+
const handleSave = () => {
|
|
30
|
+
const windows = days
|
|
31
|
+
.map((d, idx) => ({ dayOfWeek: idx, ...d }))
|
|
32
|
+
.filter((d) => d.enabled)
|
|
33
|
+
.map(({ dayOfWeek, startTime, endTime, bufferMinutes }) => ({
|
|
34
|
+
dayOfWeek,
|
|
35
|
+
startTime,
|
|
36
|
+
endTime,
|
|
37
|
+
bufferMinutes: Number(bufferMinutes),
|
|
38
|
+
}))
|
|
39
|
+
|
|
40
|
+
// TODO: dispatch booking.set-availability action with `windows`
|
|
41
|
+
onSave?.(windows)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<View gap="m">
|
|
46
|
+
<View gap="s">
|
|
47
|
+
{DAY_LABELS.map((label, idx) => (
|
|
48
|
+
<View
|
|
49
|
+
key={label}
|
|
50
|
+
layout="row"
|
|
51
|
+
gap="m"
|
|
52
|
+
alignItems="center"
|
|
53
|
+
surface="secondary"
|
|
54
|
+
roundness="m"
|
|
55
|
+
inset="s"
|
|
56
|
+
>
|
|
57
|
+
<Switch
|
|
58
|
+
checked={days[idx].enabled}
|
|
59
|
+
onChange={(enabled) => update(idx, { enabled })}
|
|
60
|
+
/>
|
|
61
|
+
<Text weight="medium" style={{ width: '2.5rem', flexShrink: 0 }}>{label}</Text>
|
|
62
|
+
|
|
63
|
+
{days[idx].enabled ? (
|
|
64
|
+
<View layout="row" gap="s" alignItems="center" style={{ flex: 1 }}>
|
|
65
|
+
<Input
|
|
66
|
+
type="time"
|
|
67
|
+
value={days[idx].startTime}
|
|
68
|
+
onChange={(v) => update(idx, { startTime: v })}
|
|
69
|
+
size="s"
|
|
70
|
+
/>
|
|
71
|
+
<Text color="secondary">to</Text>
|
|
72
|
+
<Input
|
|
73
|
+
type="time"
|
|
74
|
+
value={days[idx].endTime}
|
|
75
|
+
onChange={(v) => update(idx, { endTime: v })}
|
|
76
|
+
size="s"
|
|
77
|
+
/>
|
|
78
|
+
<Text color="secondary" size="s">Buffer</Text>
|
|
79
|
+
<Input
|
|
80
|
+
type="number"
|
|
81
|
+
value={String(days[idx].bufferMinutes)}
|
|
82
|
+
onChange={(v) => update(idx, { bufferMinutes: v })}
|
|
83
|
+
size="s"
|
|
84
|
+
style={{ width: '4rem' }}
|
|
85
|
+
/>
|
|
86
|
+
<Text color="secondary" size="s">min</Text>
|
|
87
|
+
</View>
|
|
88
|
+
) : (
|
|
89
|
+
<Text color="secondary" size="s">Unavailable</Text>
|
|
90
|
+
)}
|
|
91
|
+
</View>
|
|
92
|
+
))}
|
|
93
|
+
</View>
|
|
94
|
+
|
|
95
|
+
{/* TODO: wire to booking.set-availability action */}
|
|
96
|
+
<Button variant="primary" onPress={handleSave}>Save availability</Button>
|
|
97
|
+
</View>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import React, { useState } from 'react'
|
|
2
|
+
import { View, Title, Text, Button, Input, Select } from '@ossy/design-system'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Public client-facing form.
|
|
6
|
+
*
|
|
7
|
+
* TODO:
|
|
8
|
+
* - Load services for workspaceId via booking.get-services
|
|
9
|
+
* - Load available slots for selected service via booking.get-available-slots
|
|
10
|
+
* - On submit: call booking.create-booking action, then redirect to Stripe Checkout
|
|
11
|
+
*/
|
|
12
|
+
export const BookingForm = ({ workspaceId }) => {
|
|
13
|
+
const [step, setStep] = useState('pick-service')
|
|
14
|
+
const [selectedService, setSelectedService] = useState(null)
|
|
15
|
+
const [selectedSlot, setSelectedSlot] = useState(null)
|
|
16
|
+
const [clientName, setClientName] = useState('')
|
|
17
|
+
const [clientEmail, setClientEmail] = useState('')
|
|
18
|
+
|
|
19
|
+
if (step === 'pick-service') {
|
|
20
|
+
return (
|
|
21
|
+
<View gap="m">
|
|
22
|
+
<Title variant="secondary">Choose a service</Title>
|
|
23
|
+
{/* TODO: replace with real services loaded from API */}
|
|
24
|
+
<Text color="secondary">Loading services for workspace {workspaceId}…</Text>
|
|
25
|
+
<Button variant="secondary" onPress={() => setStep('pick-slot')}>
|
|
26
|
+
Continue (placeholder)
|
|
27
|
+
</Button>
|
|
28
|
+
</View>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (step === 'pick-slot') {
|
|
33
|
+
return (
|
|
34
|
+
<View gap="m">
|
|
35
|
+
<Title variant="secondary">Pick a time</Title>
|
|
36
|
+
{/* TODO: render a slot grid derived from availability minus existing bookings */}
|
|
37
|
+
<Text color="secondary">Available slots will appear here.</Text>
|
|
38
|
+
<View layout="row" gap="s">
|
|
39
|
+
<Button variant="ghost" onPress={() => setStep('pick-service')}>Back</Button>
|
|
40
|
+
<Button variant="secondary" onPress={() => setStep('enter-details')}>
|
|
41
|
+
Continue (placeholder)
|
|
42
|
+
</Button>
|
|
43
|
+
</View>
|
|
44
|
+
</View>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (step === 'enter-details') {
|
|
49
|
+
return (
|
|
50
|
+
<View gap="m">
|
|
51
|
+
<Title variant="secondary">Your details</Title>
|
|
52
|
+
<Input
|
|
53
|
+
label="Name"
|
|
54
|
+
value={clientName}
|
|
55
|
+
onChange={setClientName}
|
|
56
|
+
placeholder="Jane Smith"
|
|
57
|
+
/>
|
|
58
|
+
<Input
|
|
59
|
+
label="Email"
|
|
60
|
+
type="email"
|
|
61
|
+
value={clientEmail}
|
|
62
|
+
onChange={setClientEmail}
|
|
63
|
+
placeholder="jane@example.com"
|
|
64
|
+
/>
|
|
65
|
+
<View layout="row" gap="s">
|
|
66
|
+
<Button variant="ghost" onPress={() => setStep('pick-slot')}>Back</Button>
|
|
67
|
+
{/* TODO: submit booking action then redirect to Stripe */}
|
|
68
|
+
<Button variant="primary" onPress={() => setStep('confirm')}>
|
|
69
|
+
Confirm & Pay
|
|
70
|
+
</Button>
|
|
71
|
+
</View>
|
|
72
|
+
</View>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<View gap="m">
|
|
78
|
+
<Title variant="secondary">Booking confirmed!</Title>
|
|
79
|
+
<Text>Check your email for a confirmation.</Text>
|
|
80
|
+
</View>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { View, Title, Text, Badge } from '@ossy/design-system'
|
|
3
|
+
|
|
4
|
+
const STATUS_VARIANT = {
|
|
5
|
+
pending: 'warning',
|
|
6
|
+
confirmed: 'success',
|
|
7
|
+
cancelled: 'neutral',
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const PAYMENT_VARIANT = {
|
|
11
|
+
unpaid: 'warning',
|
|
12
|
+
paid: 'success',
|
|
13
|
+
refunded: 'neutral',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const formatDateTime = (iso) => {
|
|
17
|
+
if (!iso) return '—'
|
|
18
|
+
return new Intl.DateTimeFormat('sv-SE', {
|
|
19
|
+
dateStyle: 'medium',
|
|
20
|
+
timeStyle: 'short',
|
|
21
|
+
}).format(new Date(iso))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const BookingRow = ({ booking }) => (
|
|
25
|
+
<View
|
|
26
|
+
surface="secondary"
|
|
27
|
+
roundness="m"
|
|
28
|
+
inset="m"
|
|
29
|
+
layout="row"
|
|
30
|
+
justifyContent="space-between"
|
|
31
|
+
alignItems="center"
|
|
32
|
+
gap="m"
|
|
33
|
+
>
|
|
34
|
+
<View gap="xs" style={{ flex: 1 }}>
|
|
35
|
+
<Text weight="medium">{booking.clientName}</Text>
|
|
36
|
+
<Text size="s" color="secondary">{booking.clientEmail}</Text>
|
|
37
|
+
<Text size="s" color="secondary">{formatDateTime(booking.startsAt)}</Text>
|
|
38
|
+
</View>
|
|
39
|
+
|
|
40
|
+
<View layout="row" gap="s" alignItems="center">
|
|
41
|
+
<Badge
|
|
42
|
+
label={booking.paymentStatus}
|
|
43
|
+
variant={PAYMENT_VARIANT[booking.paymentStatus] ?? 'neutral'}
|
|
44
|
+
size="s"
|
|
45
|
+
/>
|
|
46
|
+
<Badge
|
|
47
|
+
label={booking.status}
|
|
48
|
+
variant={STATUS_VARIANT[booking.status] ?? 'neutral'}
|
|
49
|
+
size="s"
|
|
50
|
+
/>
|
|
51
|
+
</View>
|
|
52
|
+
</View>
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
export const BookingList = ({ bookings = [] }) => {
|
|
56
|
+
if (bookings.length === 0) {
|
|
57
|
+
return (
|
|
58
|
+
<View inset="m" surface="secondary" roundness="m" alignItems="center">
|
|
59
|
+
<Text color="secondary">No bookings yet.</Text>
|
|
60
|
+
</View>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<View gap="s">
|
|
66
|
+
{bookings.map((booking) => (
|
|
67
|
+
<BookingRow key={booking.id ?? booking.startsAt} booking={booking} />
|
|
68
|
+
))}
|
|
69
|
+
</View>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const Definition = {
|
|
2
|
+
id: 'booking',
|
|
3
|
+
title: 'Booking',
|
|
4
|
+
description: 'Services, availability windows, and client appointment management for consultants.',
|
|
5
|
+
module: {
|
|
6
|
+
id: 'booking',
|
|
7
|
+
enabled: true,
|
|
8
|
+
},
|
|
9
|
+
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
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { View, Title, Text, Badge } from '@ossy/design-system'
|
|
3
|
+
|
|
4
|
+
const formatPrice = (cents, currency) => {
|
|
5
|
+
if (cents === 0) return 'Free'
|
|
6
|
+
return new Intl.NumberFormat('sv-SE', { style: 'currency', currency }).format(cents / 100)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const formatDuration = (minutes) => {
|
|
10
|
+
if (minutes < 60) return `${minutes} min`
|
|
11
|
+
const h = Math.floor(minutes / 60)
|
|
12
|
+
const m = minutes % 60
|
|
13
|
+
return m === 0 ? `${h}h` : `${h}h ${m}min`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const ServiceCard = ({ name, duration, price, currency = 'SEK', description, active }) => (
|
|
17
|
+
<View
|
|
18
|
+
surface="secondary"
|
|
19
|
+
roundness="m"
|
|
20
|
+
inset="m"
|
|
21
|
+
gap="xs"
|
|
22
|
+
layout="row"
|
|
23
|
+
justifyContent="space-between"
|
|
24
|
+
alignItems="flex-start"
|
|
25
|
+
>
|
|
26
|
+
<View gap="xs" style={{ flex: 1 }}>
|
|
27
|
+
<View layout="row" gap="s" alignItems="center">
|
|
28
|
+
<Title variant="secondary">{name}</Title>
|
|
29
|
+
{!active && <Badge label="Inactive" variant="neutral" size="s" />}
|
|
30
|
+
</View>
|
|
31
|
+
{description && <Text size="s" color="secondary">{description}</Text>}
|
|
32
|
+
</View>
|
|
33
|
+
|
|
34
|
+
<View gap="xs" alignItems="flex-end" style={{ flexShrink: 0 }}>
|
|
35
|
+
<Text weight="medium">{formatPrice(price, currency)}</Text>
|
|
36
|
+
<Text size="s" color="secondary">{formatDuration(duration)}</Text>
|
|
37
|
+
</View>
|
|
38
|
+
</View>
|
|
39
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { View, Title, Text, Tags } from '@ossy/design-system'
|
|
3
|
+
import { Definition } from './Definition.js'
|
|
4
|
+
import { moduleStatusTags } from './moduleStatus.js'
|
|
5
|
+
import { AvailabilityEditor } from './AvailabilityEditor.jsx'
|
|
6
|
+
|
|
7
|
+
export const metadata = {
|
|
8
|
+
id: 'booking/availability',
|
|
9
|
+
path: {
|
|
10
|
+
sv: '/tillganglighet',
|
|
11
|
+
en: '/availability',
|
|
12
|
+
},
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const AvailabilityPage = () => (
|
|
16
|
+
<View
|
|
17
|
+
gap="m"
|
|
18
|
+
surface="primary"
|
|
19
|
+
style={{ padding: 'var(--space-m) var(--space-l)', height: '100%', overflowY: 'auto' }}
|
|
20
|
+
>
|
|
21
|
+
<View inset="s" gap="s">
|
|
22
|
+
<View layout="row" gap="s" alignItems="center">
|
|
23
|
+
<Title>{Definition.title} — Availability</Title>
|
|
24
|
+
{moduleStatusTags(Definition).length > 0 && (
|
|
25
|
+
<Tags tags={moduleStatusTags(Definition)} size="s" />
|
|
26
|
+
)}
|
|
27
|
+
</View>
|
|
28
|
+
|
|
29
|
+
<Text style={{ maxWidth: '400px' }}>
|
|
30
|
+
Set your weekly availability. Clients can only book slots within these windows.
|
|
31
|
+
</Text>
|
|
32
|
+
</View>
|
|
33
|
+
|
|
34
|
+
<View inset="s">
|
|
35
|
+
{/* TODO: load existing availability via booking.get-availability, save via booking.set-availability */}
|
|
36
|
+
<AvailabilityEditor availability={[]} />
|
|
37
|
+
</View>
|
|
38
|
+
</View>
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
export default AvailabilityPage
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
name: 'Availability',
|
|
3
|
+
id: '@ossy/booking/availability',
|
|
4
|
+
categoryName: 'Booking',
|
|
5
|
+
icon: 'clock',
|
|
6
|
+
fields: [
|
|
7
|
+
{
|
|
8
|
+
// 0 = Sunday … 6 = Saturday
|
|
9
|
+
name: 'dayOfWeek',
|
|
10
|
+
type: 'number',
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
// "HH:MM" 24-hour format
|
|
14
|
+
name: 'startTime',
|
|
15
|
+
type: 'text',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
// "HH:MM" 24-hour format
|
|
19
|
+
name: 'endTime',
|
|
20
|
+
type: 'text',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'bufferMinutes',
|
|
24
|
+
type: 'number',
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { View, Title, Text } from '@ossy/design-system'
|
|
3
|
+
import { useRouter } from '@ossy/router-react'
|
|
4
|
+
import { BookingForm } from './BookingForm.jsx'
|
|
5
|
+
|
|
6
|
+
export const metadata = {
|
|
7
|
+
id: 'booking/book',
|
|
8
|
+
// Public-facing — no auth required
|
|
9
|
+
public: true,
|
|
10
|
+
path: {
|
|
11
|
+
sv: '/boka/:workspaceId',
|
|
12
|
+
en: '/book/:workspaceId',
|
|
13
|
+
},
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const BookingPage = () => {
|
|
17
|
+
const router = useRouter()
|
|
18
|
+
const { workspaceId } = router.params
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<View
|
|
22
|
+
surface="base"
|
|
23
|
+
layout="off-center-s"
|
|
24
|
+
inset="s"
|
|
25
|
+
style={{ minHeight: '100dvh' }}
|
|
26
|
+
>
|
|
27
|
+
<title>Book a session</title>
|
|
28
|
+
|
|
29
|
+
<View inset="m" roundness="l" surface="primary" slot="content" gap="m">
|
|
30
|
+
<View gap="xs">
|
|
31
|
+
<Title>Book a session</Title>
|
|
32
|
+
<Text>Pick a time that works for you and confirm your appointment.</Text>
|
|
33
|
+
</View>
|
|
34
|
+
|
|
35
|
+
{/* TODO: load services for workspaceId, let client pick one */}
|
|
36
|
+
<BookingForm workspaceId={workspaceId} />
|
|
37
|
+
</View>
|
|
38
|
+
</View>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default BookingPage
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
name: 'Booking',
|
|
3
|
+
id: '@ossy/booking/booking',
|
|
4
|
+
categoryName: 'Booking',
|
|
5
|
+
icon: 'calendar-check',
|
|
6
|
+
fields: [
|
|
7
|
+
{
|
|
8
|
+
name: 'serviceId',
|
|
9
|
+
type: 'text',
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
name: 'clientName',
|
|
13
|
+
type: 'text',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: 'clientEmail',
|
|
17
|
+
type: 'text',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: 'startsAt',
|
|
21
|
+
type: 'text',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: 'endsAt',
|
|
25
|
+
type: 'text',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
// pending | confirmed | cancelled
|
|
29
|
+
name: 'status',
|
|
30
|
+
type: 'text',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
// unpaid | paid | refunded
|
|
34
|
+
name: 'paymentStatus',
|
|
35
|
+
type: 'text',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: 'stripePaymentIntentId',
|
|
39
|
+
type: 'text',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'notes',
|
|
43
|
+
type: 'textarea',
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { View, Title, Text, Tags } from '@ossy/design-system'
|
|
3
|
+
import { Definition } from './Definition.js'
|
|
4
|
+
import { moduleStatusTags } from './moduleStatus.js'
|
|
5
|
+
import { BookingList } from './BookingList.jsx'
|
|
6
|
+
|
|
7
|
+
export const metadata = {
|
|
8
|
+
id: 'booking/bookings',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const BookingsContent = () => (
|
|
12
|
+
<View
|
|
13
|
+
gap="m"
|
|
14
|
+
surface="primary"
|
|
15
|
+
style={{ padding: 'var(--space-m) var(--space-l)', height: '100%', overflowY: 'auto' }}
|
|
16
|
+
>
|
|
17
|
+
<View inset="s" gap="s">
|
|
18
|
+
<View layout="row" gap="s" alignItems="center">
|
|
19
|
+
<Title>{Definition.title} — Bookings</Title>
|
|
20
|
+
{moduleStatusTags(Definition).length > 0 && (
|
|
21
|
+
<Tags tags={moduleStatusTags(Definition)} size="s" />
|
|
22
|
+
)}
|
|
23
|
+
</View>
|
|
24
|
+
|
|
25
|
+
<Text style={{ maxWidth: '400px' }}>
|
|
26
|
+
Incoming appointments from your clients.
|
|
27
|
+
</Text>
|
|
28
|
+
</View>
|
|
29
|
+
|
|
30
|
+
<View gap="s" inset="s">
|
|
31
|
+
<Title variant="secondary">Upcoming</Title>
|
|
32
|
+
<BookingList bookings={[]} />
|
|
33
|
+
</View>
|
|
34
|
+
</View>
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
export default BookingsContent
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { View, Title, Text, Tags } from '@ossy/design-system'
|
|
3
|
+
import { Definition } from './Definition.js'
|
|
4
|
+
import { moduleStatusTags } from './moduleStatus.js'
|
|
5
|
+
import { BookingList } from './BookingList.jsx'
|
|
6
|
+
|
|
7
|
+
export const metadata = {
|
|
8
|
+
id: 'booking/bookings',
|
|
9
|
+
path: {
|
|
10
|
+
sv: '/bokningar',
|
|
11
|
+
en: '/bookings',
|
|
12
|
+
},
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const BookingsPage = () => (
|
|
16
|
+
<View
|
|
17
|
+
gap="m"
|
|
18
|
+
surface="primary"
|
|
19
|
+
style={{ padding: 'var(--space-m) var(--space-l)', height: '100%', overflowY: 'auto' }}
|
|
20
|
+
>
|
|
21
|
+
<View inset="s" gap="s">
|
|
22
|
+
<View layout="row" gap="s" alignItems="center">
|
|
23
|
+
<Title>{Definition.title} — Bookings</Title>
|
|
24
|
+
{moduleStatusTags(Definition).length > 0 && (
|
|
25
|
+
<Tags tags={moduleStatusTags(Definition)} size="s" />
|
|
26
|
+
)}
|
|
27
|
+
</View>
|
|
28
|
+
|
|
29
|
+
<Text style={{ maxWidth: '400px' }}>
|
|
30
|
+
Incoming appointments from your clients.
|
|
31
|
+
</Text>
|
|
32
|
+
</View>
|
|
33
|
+
|
|
34
|
+
<View gap="s" inset="s">
|
|
35
|
+
<Title variant="secondary">Upcoming</Title>
|
|
36
|
+
{/* TODO: load bookings via booking.get-bookings view, filtered by status=confirmed */}
|
|
37
|
+
<BookingList bookings={[]} />
|
|
38
|
+
</View>
|
|
39
|
+
</View>
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
export default BookingsPage
|
package/src/index.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
name: 'Service',
|
|
3
|
+
id: '@ossy/booking/service',
|
|
4
|
+
categoryName: 'Booking',
|
|
5
|
+
icon: 'briefcase',
|
|
6
|
+
fields: [
|
|
7
|
+
{
|
|
8
|
+
name: 'name',
|
|
9
|
+
type: 'text',
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
name: 'duration',
|
|
13
|
+
type: 'number',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: 'price',
|
|
17
|
+
type: 'number',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: 'currency',
|
|
21
|
+
type: 'text',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: 'description',
|
|
25
|
+
type: 'textarea',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'active',
|
|
29
|
+
type: 'boolean',
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { View, Title, Text, Button, Tags } from '@ossy/design-system'
|
|
3
|
+
import { Definition } from './Definition.js'
|
|
4
|
+
import { moduleStatusTags } from './moduleStatus.js'
|
|
5
|
+
import { ServiceCard } from './ServiceCard.jsx'
|
|
6
|
+
|
|
7
|
+
export const metadata = {
|
|
8
|
+
id: 'booking/services',
|
|
9
|
+
path: {
|
|
10
|
+
sv: '/tjanster',
|
|
11
|
+
en: '/services',
|
|
12
|
+
},
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const ServicesPage = () => (
|
|
16
|
+
<View
|
|
17
|
+
gap="m"
|
|
18
|
+
surface="primary"
|
|
19
|
+
style={{ padding: 'var(--space-m) var(--space-l)', height: '100%', overflowY: 'auto' }}
|
|
20
|
+
>
|
|
21
|
+
<View inset="s" gap="s">
|
|
22
|
+
<View layout="row" justifyContent="space-between" alignItems="center" style={{ flexShrink: 0 }}>
|
|
23
|
+
<View layout="row" gap="s" alignItems="center">
|
|
24
|
+
<Title>{Definition.title} — Services</Title>
|
|
25
|
+
{moduleStatusTags(Definition).length > 0 && (
|
|
26
|
+
<Tags tags={moduleStatusTags(Definition)} size="s" />
|
|
27
|
+
)}
|
|
28
|
+
</View>
|
|
29
|
+
{/* TODO: wire to booking.create-service action */}
|
|
30
|
+
<Button variant="primary" size="s">Add service</Button>
|
|
31
|
+
</View>
|
|
32
|
+
|
|
33
|
+
<Text style={{ maxWidth: '400px' }}>
|
|
34
|
+
Define what you offer — name, duration, and price. Clients will see these when booking.
|
|
35
|
+
</Text>
|
|
36
|
+
</View>
|
|
37
|
+
|
|
38
|
+
<View gap="s" inset="s">
|
|
39
|
+
<Title variant="secondary">Your services</Title>
|
|
40
|
+
|
|
41
|
+
{/* TODO: load services via booking.get-services view */}
|
|
42
|
+
<View gap="s">
|
|
43
|
+
<ServiceCard
|
|
44
|
+
name="Intro call"
|
|
45
|
+
duration={30}
|
|
46
|
+
price={0}
|
|
47
|
+
currency="SEK"
|
|
48
|
+
description="A free 30-minute intro to see if we're a good fit."
|
|
49
|
+
active
|
|
50
|
+
/>
|
|
51
|
+
<ServiceCard
|
|
52
|
+
name="Strategy session"
|
|
53
|
+
duration={60}
|
|
54
|
+
price={150000}
|
|
55
|
+
currency="SEK"
|
|
56
|
+
description="Deep-dive strategy session."
|
|
57
|
+
active
|
|
58
|
+
/>
|
|
59
|
+
</View>
|
|
60
|
+
</View>
|
|
61
|
+
</View>
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
export default ServicesPage
|