@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 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 &amp; 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,6 @@
1
+ export { Definition } from './Definition.js'
2
+
3
+ export { ServiceCard } from './ServiceCard.jsx'
4
+ export { BookingForm } from './BookingForm.jsx'
5
+ export { BookingList } from './BookingList.jsx'
6
+ export { AvailabilityEditor } from './AvailabilityEditor.jsx'
@@ -0,0 +1,2 @@
1
+ export const moduleStatusTags = (definition) =>
2
+ (definition.statuses ?? []).map((status) => ({ label: status }))
@@ -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