@ossy/booking 1.15.6 → 1.16.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 CHANGED
@@ -19,6 +19,18 @@ Three resource schemas are registered under the `@ossy/booking` category:
19
19
  - `@ossy/booking/booking` — a confirmed appointment
20
20
  - `@ossy/booking/availability` — recurring weekly availability window
21
21
 
22
+ ## Resource view slots
23
+
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
+ | Component | Slot key |
27
+ |-----------|----------|
28
+ | `service-detail.component.jsx` | `resource:booking/service/detail` |
29
+ | `service-card-slot.component.jsx` | `resource:booking/service/card` |
30
+ | `booking-card.component.jsx` | `resource:booking/booking/card` |
31
+
32
+ Dedicated pages (`services.page.jsx`, `booking-detail.page.jsx`, etc.) continue to work alongside generic `@ossy/resources` slot hosts.
33
+
22
34
  ## What still needs implementation
23
35
 
24
36
  - **Slot generation** — derive available time slots from `availability` records minus existing `booking` records
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.15.6",
4
+ "version": "1.16.0",
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.5.6",
18
- "@ossy/event-store": "^1.7.6",
19
- "@ossy/observability": "^1.7.6",
20
- "@ossy/platform": "^1.38.6",
21
- "@ossy/resources": "^1.11.6"
17
+ "@ossy/email": "^1.6.0",
18
+ "@ossy/event-store": "^1.8.0",
19
+ "@ossy/observability": "^1.8.0",
20
+ "@ossy/platform": "^1.39.0",
21
+ "@ossy/resources": "^1.12.0"
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": "72d150cf20ff7ba25b328bc5b429b065c2840b2b"
37
+ "gitHead": "d9d31182be64a448d575da432af2830d909ad4b9"
38
38
  }
@@ -1,12 +1,10 @@
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']
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 = DAY_LABELS.map(() => ({ ...DEFAULT_WINDOW }))
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
- {DAY_LABELS.map((label, idx) => (
55
+ {dayKeys.map((key, idx) => (
48
56
  <View
49
- key={label}
57
+ key={key}
50
58
  layout="row"
51
59
  gap="m"
52
60
  alignItems="center"
@@ -54,11 +62,15 @@ export const AvailabilityEditor = ({ availability = [], onSave }) => {
54
62
  roundness="m"
55
63
  inset="s"
56
64
  >
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>
65
+ <label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
66
+ <input
67
+ type="checkbox"
68
+ checked={days[idx].enabled}
69
+ onChange={e => update(idx, { enabled: e.target.checked })}
70
+ style={{ width: 16, height: 16, accentColor: 'var(--color-accent)' }}
71
+ />
72
+ </label>
73
+ <Text weight="medium" style={{ width: '2.5rem', flexShrink: 0 }}>{t(key)}</Text>
62
74
 
63
75
  {days[idx].enabled ? (
64
76
  <View layout="row" gap="s" alignItems="center" style={{ flex: 1 }}>
@@ -68,14 +80,14 @@ export const AvailabilityEditor = ({ availability = [], onSave }) => {
68
80
  onChange={(v) => update(idx, { startTime: v })}
69
81
  size="s"
70
82
  />
71
- <Text color="secondary">to</Text>
83
+ <Text color="secondary">{t('booking.availabilityEditor.to')}</Text>
72
84
  <Input
73
85
  type="time"
74
86
  value={days[idx].endTime}
75
87
  onChange={(v) => update(idx, { endTime: v })}
76
88
  size="s"
77
89
  />
78
- <Text color="secondary" size="s">Buffer</Text>
90
+ <Text color="secondary" size="s">{t('booking.availabilityEditor.buffer')}</Text>
79
91
  <Input
80
92
  type="number"
81
93
  value={String(days[idx].bufferMinutes)}
@@ -83,17 +95,16 @@ export const AvailabilityEditor = ({ availability = [], onSave }) => {
83
95
  size="s"
84
96
  style={{ width: '4rem' }}
85
97
  />
86
- <Text color="secondary" size="s">min</Text>
98
+ <Text color="secondary" size="s">{t('booking.availabilityEditor.min')}</Text>
87
99
  </View>
88
100
  ) : (
89
- <Text color="secondary" size="s">Unavailable</Text>
101
+ <Text color="secondary" size="s">{t('booking.availabilityEditor.unavailable')}</Text>
90
102
  )}
91
103
  </View>
92
104
  ))}
93
105
  </View>
94
106
 
95
- {/* TODO: wire to booking.set-availability action */}
96
- <Button variant="primary" onPress={handleSave}>Save availability</Button>
107
+ <Button variant="primary" onClick={handleSave}>{t('booking.availabilityEditor.save')}</Button>
97
108
  </View>
98
109
  )
99
110
  }
@@ -1,15 +1,8 @@
1
1
  import React, { useState } from 'react'
2
- import { View, Title, Text, Button, Input, Select } from '@ossy/design-system'
2
+ import { View, Title, Text, Button, Input, useLocale } from '@ossy/design-system'
3
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
4
  export const BookingForm = ({ workspaceId }) => {
5
+ const { t } = useLocale()
13
6
  const [step, setStep] = useState('pick-service')
14
7
  const [selectedService, setSelectedService] = useState(null)
15
8
  const [selectedSlot, setSelectedSlot] = useState(null)
@@ -19,11 +12,10 @@ export const BookingForm = ({ workspaceId }) => {
19
12
  if (step === 'pick-service') {
20
13
  return (
21
14
  <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)
15
+ <Title variant="secondary">{t('booking.book.form.chooseService')}</Title>
16
+ <Text color="secondary">{t('booking.book.form.loadingServices', { workspaceId })}</Text>
17
+ <Button variant="secondary" onClick={() => setStep('pick-slot')}>
18
+ {t('booking.book.form.continuePlaceholder')}
27
19
  </Button>
28
20
  </View>
29
21
  )
@@ -32,13 +24,12 @@ export const BookingForm = ({ workspaceId }) => {
32
24
  if (step === 'pick-slot') {
33
25
  return (
34
26
  <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>
27
+ <Title variant="secondary">{t('booking.book.form.pickTime')}</Title>
28
+ <Text color="secondary">{t('booking.book.form.slotsPlaceholder')}</Text>
38
29
  <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)
30
+ <Button variant="ghost" onClick={() => setStep('pick-service')}>{t('booking.book.form.back')}</Button>
31
+ <Button variant="secondary" onClick={() => setStep('enter-details')}>
32
+ {t('booking.book.form.continuePlaceholder')}
42
33
  </Button>
43
34
  </View>
44
35
  </View>
@@ -48,25 +39,24 @@ export const BookingForm = ({ workspaceId }) => {
48
39
  if (step === 'enter-details') {
49
40
  return (
50
41
  <View gap="m">
51
- <Title variant="secondary">Your details</Title>
42
+ <Title variant="secondary">{t('booking.book.form.yourDetails')}</Title>
52
43
  <Input
53
- label="Name"
44
+ label={t('booking.book.form.nameLabel')}
54
45
  value={clientName}
55
46
  onChange={setClientName}
56
- placeholder="Jane Smith"
47
+ placeholder={t('booking.book.form.namePlaceholder')}
57
48
  />
58
49
  <Input
59
- label="Email"
50
+ label={t('booking.book.form.emailLabel')}
60
51
  type="email"
61
52
  value={clientEmail}
62
53
  onChange={setClientEmail}
63
- placeholder="jane@example.com"
54
+ placeholder={t('booking.book.form.emailPlaceholder')}
64
55
  />
65
56
  <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
57
+ <Button variant="ghost" onClick={() => setStep('pick-slot')}>{t('booking.book.form.back')}</Button>
58
+ <Button variant="primary" onClick={() => setStep('confirm')}>
59
+ {t('booking.book.form.confirmPay')}
70
60
  </Button>
71
61
  </View>
72
62
  </View>
@@ -75,8 +65,8 @@ export const BookingForm = ({ workspaceId }) => {
75
65
 
76
66
  return (
77
67
  <View gap="m">
78
- <Title variant="secondary">Booking confirmed!</Title>
79
- <Text>Check your email for a confirmation.</Text>
68
+ <Title variant="secondary">{t('booking.book.form.confirmed')}</Title>
69
+ <Text>{t('booking.book.form.checkEmail')}</Text>
80
70
  </View>
81
71
  )
82
72
  }
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
- module: {
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
  }
@@ -0,0 +1,99 @@
1
+ import React from 'react'
2
+ import { Title, Button, View, PageSection } from '@ossy/design-system'
3
+
4
+ const Cover = ({
5
+ title,
6
+ titleMaxWidth = '1100px',
7
+ text,
8
+ actions = [],
9
+ }) => (
10
+ <View
11
+ gap="l"
12
+ placeContent="center"
13
+ layout="column"
14
+ justifyContent="center"
15
+ style={{
16
+ height: '100%',
17
+ borderRadius: 'var(--space-m)',
18
+ padding: 'var(--space-xl) var(--space-s)',
19
+ }}
20
+ >
21
+ <View gap="m" alignItems="center">
22
+ <Title as="h1" variant="display" style={{ maxWidth: titleMaxWidth, textWrap: 'balance' }}>
23
+ {title}
24
+ </Title>
25
+
26
+ <Title as="h2" variant="title-tertiary" style={{ maxWidth: '800px' }}>
27
+ {text}
28
+ </Title>
29
+ </View>
30
+
31
+ <View gap="m" layout="row" justifyContent="center" style={{ flexWrap: 'wrap' }}>
32
+ {actions.map(({ label, ...props }) => (
33
+ <Button {...props} key={label}>
34
+ {label}
35
+ </Button>
36
+ ))}
37
+ </View>
38
+ </View>
39
+ )
40
+
41
+ export const HeroCover = ({
42
+ title,
43
+ titleMaxWidth = '1100px',
44
+ text,
45
+ actions = [],
46
+
47
+ id = 'hero-cover',
48
+ surfaceAs = 'div',
49
+ maxWidth = 'l',
50
+ surface = 'hero',
51
+ ...props
52
+ }) => {
53
+ const { style: passthroughStyle, ...pageSectionProps } = props
54
+
55
+ return (
56
+ <PageSection
57
+ id={id}
58
+ surfaceAs={surfaceAs}
59
+ maxWidth={maxWidth}
60
+ surface={surface}
61
+ {...pageSectionProps}
62
+ data-rounded
63
+ data-component="@ossy/website-ossy/cover"
64
+ style={{
65
+ backgroundAttachment: 'scroll',
66
+ boxShadow: 'inset 0 1px 0 color-mix(in srgb, var(--foreground) 6%, transparent)',
67
+ ...passthroughStyle,
68
+ }}
69
+ >
70
+ <style href="@ossy/website-ossy/cover" precedence="high">
71
+ {`
72
+ [data-component="@ossy/website-ossy/cover"] {
73
+ gap: var(--space-m);
74
+ min-height: min(42vh, 420px);
75
+ height: auto;
76
+ }
77
+
78
+ [data-rounded] {
79
+ border-radius: var(--space-m);
80
+ overflow: hidden;
81
+ }
82
+
83
+ @media (min-width: 900px) {
84
+ [data-component="@ossy/website-ossy/cover"] {
85
+ min-height: min(46vh, 520px);
86
+ }
87
+ }
88
+ `}
89
+ </style>
90
+
91
+ <Cover
92
+ title={title}
93
+ titleMaxWidth={titleMaxWidth}
94
+ text={text}
95
+ actions={actions}
96
+ />
97
+ </PageSection>
98
+ )
99
+ }
@@ -0,0 +1,77 @@
1
+ import React from 'react'
2
+ import {
3
+ Title,
4
+ Text,
5
+ View,
6
+ Icon,
7
+ PageSection,
8
+ MarkdownViewer,
9
+ useLocale,
10
+ } from '@ossy/design-system'
11
+ import { HeroCover } from './HeroCover.jsx'
12
+
13
+ export default ({
14
+ cover,
15
+ features,
16
+ }) => {
17
+ const { t } = useLocale()
18
+
19
+ return (
20
+ <PageSection maxWidth="xl" gap="l">
21
+ <View gap="l" surface="primary" style={{ padding: 'var(--space-m) var(--space-l)', height: '100%', overflowY: 'auto' }}>
22
+ <HeroCover {...cover} />
23
+
24
+ <View gap="m" inset="s">
25
+ <Title variant="secondary">
26
+ {t('booking.sales.overview')}
27
+ </Title>
28
+
29
+ <View gap="m" layout="row-wrap">
30
+ {features.map(x => (
31
+ <View
32
+ key={x.title}
33
+ roundness="m"
34
+ surface="primary"
35
+ inset="m"
36
+ gap="m"
37
+ style={{
38
+ width: 200,
39
+ border: '1px solid var(--separator)',
40
+ }}
41
+ >
42
+ <View justifyContent="center" alignItems="center">
43
+ <Icon name={x.icon} size="m" />
44
+ </View>
45
+ <Text variant="small" style={{ fontWeight: 'bold', textAlign: 'center' }}>{x.title}</Text>
46
+ <Text variant="small" style={{ textAlign: 'center' }}>{x.text}</Text>
47
+ </View>
48
+ ))}
49
+ </View>
50
+ </View>
51
+
52
+ <View gap="m" inset="s">
53
+ <Title variant="secondary">
54
+ {t('booking.sales.features')}
55
+ </Title>
56
+
57
+ {features.map(x => (
58
+ <View key={x.title} roundness="m" gap="m" inset="l" style={{ border: '1px solid var(--separator)' }}>
59
+ <View layout="row" gap="s" alignItems="center">
60
+ <View justifyContent="center" alignItems="center">
61
+ <Icon name={x.icon} size="m" />
62
+ </View>
63
+ <Title variant="tertiary">
64
+ {x.title}
65
+ </Title>
66
+ </View>
67
+
68
+ <Text>{x.text}</Text>
69
+
70
+ {x.content && <MarkdownViewer children={x.content} />}
71
+ </View>
72
+ ))}
73
+ </View>
74
+ </View>
75
+ </PageSection>
76
+ )
77
+ }