@ossy/booking 1.16.2 → 3.0.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 +48 -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 +2 -2
- 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 +26 -25
- 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/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 +60 -57
- 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/fixtures.js +149 -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/moduleStatus.js +0 -2
- package/src/service.resource.js +0 -32
package/README.md
CHANGED
|
@@ -1,46 +1,73 @@
|
|
|
1
1
|
# @ossy/booking
|
|
2
2
|
|
|
3
|
-
Booking feature package for the Ossy platform. Lets
|
|
3
|
+
Booking feature package for the Ossy platform. Lets providers define services, set weekly availability, and accept client bookings.
|
|
4
4
|
|
|
5
5
|
## Pages
|
|
6
6
|
|
|
7
7
|
| Page | Path | Audience |
|
|
8
8
|
|------|------|----------|
|
|
9
|
-
| `public-booking` | `/book/:
|
|
10
|
-
| `services` | `/services` · `/tjanster` |
|
|
11
|
-
| `bookings` | `/bookings` · `/bokningar` |
|
|
12
|
-
| `availability-setup` | `/availability` · `/tillganglighet` |
|
|
13
|
-
| `booking-detail` | `/bookings/:bookingId` · `/bokningar/:bookingId` |
|
|
9
|
+
| `public-booking` | `/book/:providerSlug` · `/boka/:providerSlug` | Public (clients) |
|
|
10
|
+
| `services` | `/services` · `/tjanster` | Provider (authenticated) |
|
|
11
|
+
| `bookings` | `/bookings` · `/bokningar` | Provider (authenticated) |
|
|
12
|
+
| `availability-setup` | `/availability` · `/tillganglighet` | Provider (authenticated) |
|
|
13
|
+
| `booking-detail` | `/bookings/:bookingId` · `/bokningar/:bookingId` | Provider (authenticated) |
|
|
14
14
|
|
|
15
15
|
## Public booking flow
|
|
16
16
|
|
|
17
17
|
1. Client opens `public-booking` (no auth; `metadata.public: true`)
|
|
18
18
|
2. Picks a service → calendar slot → contact details
|
|
19
|
-
3.
|
|
19
|
+
3. `@ossy/booking/actions/create` stores a pending booking and sends notification emails
|
|
20
20
|
|
|
21
|
-
##
|
|
21
|
+
## Actions
|
|
22
|
+
|
|
23
|
+
Actions are declared in `*.action.js` and discovered at build time. Each action delegates to a matching `*.task.js`.
|
|
22
24
|
|
|
23
25
|
| Action | Access |
|
|
24
26
|
|--------|--------|
|
|
25
|
-
|
|
|
26
|
-
|
|
|
27
|
-
|
|
|
28
|
-
|
|
|
27
|
+
| `@ossy/booking/actions/get-services` | public |
|
|
28
|
+
| `@ossy/booking/actions/create-service` | workspace |
|
|
29
|
+
| `@ossy/booking/actions/update-service` | workspace |
|
|
30
|
+
| `@ossy/booking/actions/delete-service` | workspace |
|
|
31
|
+
| `@ossy/booking/actions/create` | public |
|
|
32
|
+
| `@ossy/booking/actions/list` | workspace |
|
|
33
|
+
| `@ossy/booking/actions/confirm` | workspace |
|
|
34
|
+
| `@ossy/booking/actions/decline` | workspace |
|
|
35
|
+
| `@ossy/booking/actions/cancel` | authenticated |
|
|
36
|
+
| `@ossy/booking/actions/get-availability` | workspace |
|
|
37
|
+
| `@ossy/booking/actions/save-availability` | workspace |
|
|
38
|
+
| `@ossy/booking/actions/get-available-slots` | public |
|
|
39
|
+
|
|
40
|
+
## Schemas
|
|
41
|
+
|
|
42
|
+
Resource schemas live in `src/*.schema.js` and are auto-discovered by `@ossy/app build`. Canonical ids are exported from `schema-ids.js` as `BookingSchema`:
|
|
43
|
+
|
|
44
|
+
| Schema | Id | File |
|
|
45
|
+
|--------|----|------|
|
|
46
|
+
| Service | `@ossy/booking/schema/service` | `service.schema.js` |
|
|
47
|
+
| Booking | `@ossy/booking/schema/booking` | `booking.schema.js` |
|
|
48
|
+
| Availability | `@ossy/booking/schema/availability` | `availability.schema.js` |
|
|
49
|
+
| Public booking form | `@ossy/booking/schema/public-booking-form` | `public-booking-form.schema.js` |
|
|
29
50
|
|
|
30
|
-
|
|
51
|
+
Documents are stored under canonical locations. Import paths from `@ossy/booking` (`services`, `bookings`, `availability`).
|
|
31
52
|
|
|
32
|
-
|
|
33
|
-
- `@ossy/booking/booking` — an appointment (`/@ossy/booking/bookings/`)
|
|
34
|
-
- `@ossy/booking/availability` — weekly availability config (`/@ossy/booking/availability/`)
|
|
53
|
+
Tasks write resources via `commitResource` / `mutateResource` from `@ossy/resources/server`, using `schemaId` on resource events (ADR 0008).
|
|
35
54
|
|
|
36
|
-
|
|
55
|
+
## Projections
|
|
37
56
|
|
|
38
|
-
|
|
57
|
+
| Projection | Id | Purpose |
|
|
58
|
+
|------------|----|---------|
|
|
59
|
+
| `BookingListProjection` | `@ossy/booking/data/booking-list` | Workspace-scoped booking list; `@ossy/booking/actions/list` reads this first and falls back to a resource scan |
|
|
60
|
+
|
|
61
|
+
## View slots
|
|
39
62
|
|
|
40
63
|
| Component | Slot key |
|
|
41
64
|
|-----------|----------|
|
|
42
|
-
| `service-detail.component.jsx` |
|
|
43
|
-
| `service-card-slot.component.jsx` |
|
|
44
|
-
| `booking-card.component.jsx` |
|
|
65
|
+
| `service-detail.component.jsx` | `@ossy/booking/view/service` |
|
|
66
|
+
| `service-card-slot.component.jsx` | `@ossy/booking/view/service/card` |
|
|
67
|
+
| `booking-card.component.jsx` | `@ossy/booking/view/booking/card` |
|
|
45
68
|
|
|
46
69
|
See [SPEC.md](./SPEC.md) for the full product specification.
|
|
70
|
+
|
|
71
|
+
## Preview
|
|
72
|
+
|
|
73
|
+
Booking pages and flows are exercised in **`@ossy/app-test`** on the production build path. Enable the package in `devEntitlements`, then open Booking from the sidebar or `/packages/booking`. See [`packages/app-test/README.md`](../app-test/README.md).
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/booking",
|
|
3
|
-
"description": "Booking feature package — services, availability, and appointment management for Ossy
|
|
4
|
-
"version": "
|
|
3
|
+
"description": "Booking feature package — services, availability, and appointment management for Ossy providers",
|
|
4
|
+
"version": "3.0.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"module": "./src/index.js",
|
|
@@ -14,13 +14,16 @@
|
|
|
14
14
|
"src": "./src"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@ossy/email": "^
|
|
18
|
-
"@ossy/event-store": "^
|
|
19
|
-
"@ossy/observability": "^
|
|
20
|
-
"@ossy/platform": "^
|
|
21
|
-
"@ossy/resources": "^
|
|
17
|
+
"@ossy/email": "^3.0.1",
|
|
18
|
+
"@ossy/event-store": "^3.0.1",
|
|
19
|
+
"@ossy/observability": "^3.0.1",
|
|
20
|
+
"@ossy/platform": "^3.0.1",
|
|
21
|
+
"@ossy/resources": "^3.0.1",
|
|
22
|
+
"@ossy/users": "^3.0.1",
|
|
23
|
+
"@ossy/workspaces": "^3.0.1"
|
|
22
24
|
},
|
|
23
25
|
"peerDependencies": {
|
|
26
|
+
"@ossy/app": ">=1.0.0",
|
|
24
27
|
"@ossy/design-system": ">=1.0.0",
|
|
25
28
|
"@ossy/router-react": ">=1.0.0",
|
|
26
29
|
"@ossy/sdk-react": ">=1.0.0",
|
|
@@ -34,5 +37,5 @@
|
|
|
34
37
|
"/src",
|
|
35
38
|
"README.md"
|
|
36
39
|
],
|
|
37
|
-
"gitHead": "
|
|
40
|
+
"gitHead": "4a70ec216448680d2fddc57b2a8a0077489720ae"
|
|
38
41
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React, { useState, useMemo } from 'react'
|
|
2
2
|
import { View, Text, Button, Input, useLocale } from '@ossy/design-system'
|
|
3
3
|
|
|
4
|
-
const DEFAULT_WINDOW = { enabled: false, startTime: '09:00', endTime: '17:00'
|
|
4
|
+
const DEFAULT_WINDOW = { enabled: false, startTime: '09:00', endTime: '17:00' }
|
|
5
5
|
|
|
6
6
|
const buildInitialState = (availability = []) => {
|
|
7
7
|
const state = Array.from({ length: 7 }, () => ({ ...DEFAULT_WINDOW }))
|
|
@@ -11,7 +11,6 @@ const buildInitialState = (availability = []) => {
|
|
|
11
11
|
enabled: true,
|
|
12
12
|
startTime: window.startTime ?? '09:00',
|
|
13
13
|
endTime: window.endTime ?? '17:00',
|
|
14
|
-
bufferMinutes: window.bufferMinutes ?? 0,
|
|
15
14
|
}
|
|
16
15
|
}
|
|
17
16
|
}
|
|
@@ -39,11 +38,10 @@ export const AvailabilityEditor = ({ availability = [], onSave }) => {
|
|
|
39
38
|
const windows = days
|
|
40
39
|
.map((d, idx) => ({ dayOfWeek: idx, ...d }))
|
|
41
40
|
.filter((d) => d.enabled)
|
|
42
|
-
.map(({ dayOfWeek, startTime, endTime
|
|
41
|
+
.map(({ dayOfWeek, startTime, endTime }) => ({
|
|
43
42
|
dayOfWeek,
|
|
44
43
|
startTime,
|
|
45
44
|
endTime,
|
|
46
|
-
bufferMinutes: Number(bufferMinutes),
|
|
47
45
|
}))
|
|
48
46
|
|
|
49
47
|
onSave?.(windows)
|
|
@@ -87,15 +85,6 @@ export const AvailabilityEditor = ({ availability = [], onSave }) => {
|
|
|
87
85
|
onChange={(v) => update(idx, { endTime: v })}
|
|
88
86
|
size="s"
|
|
89
87
|
/>
|
|
90
|
-
<Text color="secondary" size="s">{t('booking.availabilityEditor.buffer')}</Text>
|
|
91
|
-
<Input
|
|
92
|
-
type="number"
|
|
93
|
-
value={String(days[idx].bufferMinutes)}
|
|
94
|
-
onChange={(v) => update(idx, { bufferMinutes: v })}
|
|
95
|
-
size="s"
|
|
96
|
-
style={{ width: '4rem' }}
|
|
97
|
-
/>
|
|
98
|
-
<Text color="secondary" size="s">{t('booking.availabilityEditor.min')}</Text>
|
|
99
88
|
</View>
|
|
100
89
|
) : (
|
|
101
90
|
<Text color="secondary" size="s">{t('booking.availabilityEditor.unavailable')}</Text>
|
package/src/BookingList.jsx
CHANGED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { useRouter } from '@ossy/router-react'
|
|
3
|
+
import { Text, View, Button, Page, useLocale } from '@ossy/design-system'
|
|
4
|
+
import { metadata as OpenAvailabilitySetup } from './open-availability-setup.action.js'
|
|
5
|
+
import { metadata as OpenServices } from './open-services.action.js'
|
|
6
|
+
import { metadata as OpenBookings } from './open-bookings.action.js'
|
|
7
|
+
|
|
8
|
+
export default function BookingProductHome () {
|
|
9
|
+
const { t } = useLocale()
|
|
10
|
+
const router = useRouter()
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<Page maxWidth="xl" gap="l" data-booking-status="on">
|
|
14
|
+
<View gap="m">
|
|
15
|
+
<Text as="h1" variant="heading-primary" text="booking.product.title" />
|
|
16
|
+
<Text style={{ maxWidth: '600px' }} text="booking.product.description" />
|
|
17
|
+
</View>
|
|
18
|
+
|
|
19
|
+
<View gap="m" layout="row-wrap">
|
|
20
|
+
<Button
|
|
21
|
+
{...OpenAvailabilitySetup}
|
|
22
|
+
variant="cta"
|
|
23
|
+
href={router.getHref({ id: 'booking/availability-setup' })}
|
|
24
|
+
/>
|
|
25
|
+
<Button
|
|
26
|
+
{...OpenServices}
|
|
27
|
+
variant="primary"
|
|
28
|
+
href={router.getHref({ id: 'booking/services' })}
|
|
29
|
+
/>
|
|
30
|
+
<Button
|
|
31
|
+
{...OpenBookings}
|
|
32
|
+
variant="secondary"
|
|
33
|
+
href={router.getHref({ id: 'booking/bookings' })}
|
|
34
|
+
/>
|
|
35
|
+
</View>
|
|
36
|
+
</Page>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import React, { useState, useCallback } from 'react'
|
|
2
|
+
import { GetWorkspace, EnableService } from '@ossy/workspaces'
|
|
3
|
+
import { useSdk, cacheKey } from '@ossy/sdk-react'
|
|
4
|
+
import { Text, View, Button, Page, useLocale } from '@ossy/design-system'
|
|
5
|
+
import SalesSection from './SalesSection.jsx'
|
|
6
|
+
import { useBookingHomeContent } from './booking-home-content.js'
|
|
7
|
+
|
|
8
|
+
export default function BookingSalesPage ({ isAuthenticated }) {
|
|
9
|
+
const { t } = useLocale()
|
|
10
|
+
const { invoke, invalidate } = useSdk()
|
|
11
|
+
const [error, setError] = useState(null)
|
|
12
|
+
const { cover: baseCover, features } = useBookingHomeContent()
|
|
13
|
+
|
|
14
|
+
const handleEnable = useCallback(() => {
|
|
15
|
+
setError(null)
|
|
16
|
+
invoke(EnableService, { service: '@ossy/booking' })
|
|
17
|
+
.then(() => invalidate(cacheKey(GetWorkspace)))
|
|
18
|
+
.catch(() => setError(t('booking.sales.enableError')))
|
|
19
|
+
}, [invoke, invalidate, t])
|
|
20
|
+
|
|
21
|
+
const enableAction = {
|
|
22
|
+
...EnableService,
|
|
23
|
+
'data-service': '@ossy/booking',
|
|
24
|
+
variant: 'cta',
|
|
25
|
+
label: 'booking.sales.enable',
|
|
26
|
+
onClick: handleEnable,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const cover = isAuthenticated
|
|
30
|
+
? {
|
|
31
|
+
...baseCover,
|
|
32
|
+
actions: [
|
|
33
|
+
enableAction,
|
|
34
|
+
baseCover.actions[1],
|
|
35
|
+
],
|
|
36
|
+
}
|
|
37
|
+
: baseCover
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<View data-booking-status="off">
|
|
41
|
+
<SalesSection cover={cover} features={features} />
|
|
42
|
+
|
|
43
|
+
{isAuthenticated && (
|
|
44
|
+
<Page maxWidth="xl" gap="l">
|
|
45
|
+
<View gap="m" inset="l">
|
|
46
|
+
<Text variant="heading-secondary" as="h2" text="booking.sales.enable" />
|
|
47
|
+
<Text style={{ maxWidth: '600px' }} text="booking.sales.enableDescription" />
|
|
48
|
+
<Button {...enableAction} variant="cta" />
|
|
49
|
+
{error && <Text variant="small">{error}</Text>}
|
|
50
|
+
</View>
|
|
51
|
+
</Page>
|
|
52
|
+
)}
|
|
53
|
+
</View>
|
|
54
|
+
)
|
|
55
|
+
}
|
package/src/Definition.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export const Definition = {
|
|
2
2
|
id: 'booking',
|
|
3
3
|
title: 'Booking',
|
|
4
|
-
description: 'Services, availability windows, and client appointment management for
|
|
4
|
+
description: 'Services, availability windows, and client appointment management for service providers.',
|
|
5
5
|
icon: 'calendar',
|
|
6
|
-
|
|
6
|
+
status: ['beta'],
|
|
7
7
|
}
|
package/src/HeroCover.jsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
|
-
import {
|
|
2
|
+
import { Button, View, PageSection, Text } from '@ossy/design-system'
|
|
3
3
|
|
|
4
4
|
const Cover = ({
|
|
5
5
|
title,
|
|
@@ -19,13 +19,13 @@ const Cover = ({
|
|
|
19
19
|
}}
|
|
20
20
|
>
|
|
21
21
|
<View gap="m" alignItems="center">
|
|
22
|
-
<
|
|
22
|
+
<Text as="h1" variant="display" style={{ maxWidth: titleMaxWidth, textWrap: 'balance' }}>
|
|
23
23
|
{title}
|
|
24
|
-
</
|
|
24
|
+
</Text>
|
|
25
25
|
|
|
26
|
-
<
|
|
26
|
+
<Text as="h2" variant="heading-tertiary" style={{ maxWidth: '800px' }}>
|
|
27
27
|
{text}
|
|
28
|
-
</
|
|
28
|
+
</Text>
|
|
29
29
|
</View>
|
|
30
30
|
|
|
31
31
|
<View gap="m" layout="row" justifyContent="center" style={{ flexWrap: 'wrap' }}>
|
package/src/SalesSection.jsx
CHANGED
|
@@ -1,13 +1,5 @@
|
|
|
1
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'
|
|
2
|
+
import { Text, View, Icon, PageSection, MarkdownViewer, useLocale } from '@ossy/design-system'
|
|
11
3
|
import { HeroCover } from './HeroCover.jsx'
|
|
12
4
|
|
|
13
5
|
export default ({
|
|
@@ -22,9 +14,9 @@ export default ({
|
|
|
22
14
|
<HeroCover {...cover} />
|
|
23
15
|
|
|
24
16
|
<View gap="m" inset="s">
|
|
25
|
-
<
|
|
17
|
+
<Text variant="heading-secondary" as="h2">
|
|
26
18
|
{t('booking.sales.overview')}
|
|
27
|
-
</
|
|
19
|
+
</Text>
|
|
28
20
|
|
|
29
21
|
<View gap="m" layout="row-wrap">
|
|
30
22
|
{features.map(x => (
|
|
@@ -50,9 +42,9 @@ export default ({
|
|
|
50
42
|
</View>
|
|
51
43
|
|
|
52
44
|
<View gap="m" inset="s">
|
|
53
|
-
<
|
|
45
|
+
<Text variant="heading-secondary" as="h2">
|
|
54
46
|
{t('booking.sales.features')}
|
|
55
|
-
</
|
|
47
|
+
</Text>
|
|
56
48
|
|
|
57
49
|
{features.map(x => (
|
|
58
50
|
<View key={x.title} roundness="m" gap="m" inset="l" style={{ border: '1px solid var(--separator)' }}>
|
|
@@ -60,9 +52,9 @@ export default ({
|
|
|
60
52
|
<View justifyContent="center" alignItems="center">
|
|
61
53
|
<Icon name={x.icon} size="m" />
|
|
62
54
|
</View>
|
|
63
|
-
<
|
|
55
|
+
<Text variant="heading-tertiary" as="h3">
|
|
64
56
|
{x.title}
|
|
65
|
-
</
|
|
57
|
+
</Text>
|
|
66
58
|
</View>
|
|
67
59
|
|
|
68
60
|
<Text>{x.text}</Text>
|
package/src/ServiceCard.jsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
|
-
import { View,
|
|
2
|
+
import { View, Text, Badge } from '@ossy/design-system'
|
|
3
3
|
|
|
4
4
|
const formatPrice = (cents, currency) => {
|
|
5
5
|
if (cents === 0) return 'Free'
|
|
@@ -25,7 +25,7 @@ export const ServiceCard = ({ name, duration, price, currency = 'SEK', descripti
|
|
|
25
25
|
>
|
|
26
26
|
<View gap="xs" style={{ flex: 1 }}>
|
|
27
27
|
<View layout="row" gap="s" alignItems="center">
|
|
28
|
-
<
|
|
28
|
+
<Text variant="heading-secondary" as="h2">{name}</Text>
|
|
29
29
|
{!active && <Badge label="Inactive" variant="neutral" size="s" />}
|
|
30
30
|
</View>
|
|
31
31
|
{description && <Text size="s" color="secondary">{description}</Text>}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { metadata as ToggleAvailabilityDay0 } from './toggle-availability-day-0.action.js'
|
|
2
|
+
import { metadata as ToggleAvailabilityDay1 } from './toggle-availability-day-1.action.js'
|
|
3
|
+
import { metadata as ToggleAvailabilityDay2 } from './toggle-availability-day-2.action.js'
|
|
4
|
+
import { metadata as ToggleAvailabilityDay3 } from './toggle-availability-day-3.action.js'
|
|
5
|
+
import { metadata as ToggleAvailabilityDay4 } from './toggle-availability-day-4.action.js'
|
|
6
|
+
import { metadata as ToggleAvailabilityDay5 } from './toggle-availability-day-5.action.js'
|
|
7
|
+
import { metadata as ToggleAvailabilityDay6 } from './toggle-availability-day-6.action.js'
|
|
8
|
+
import { metadata as SelectDuration30 } from './select-duration-30.action.js'
|
|
9
|
+
import { metadata as SelectDuration60 } from './select-duration-60.action.js'
|
|
10
|
+
import { metadata as SelectDuration90 } from './select-duration-90.action.js'
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
ToggleAvailabilityDay0,
|
|
14
|
+
ToggleAvailabilityDay1,
|
|
15
|
+
ToggleAvailabilityDay2,
|
|
16
|
+
ToggleAvailabilityDay3,
|
|
17
|
+
ToggleAvailabilityDay4,
|
|
18
|
+
ToggleAvailabilityDay5,
|
|
19
|
+
ToggleAvailabilityDay6,
|
|
20
|
+
SelectDuration30,
|
|
21
|
+
SelectDuration60,
|
|
22
|
+
SelectDuration90,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const TOGGLE_AVAILABILITY_DAY = {
|
|
26
|
+
0: ToggleAvailabilityDay0,
|
|
27
|
+
1: ToggleAvailabilityDay1,
|
|
28
|
+
2: ToggleAvailabilityDay2,
|
|
29
|
+
3: ToggleAvailabilityDay3,
|
|
30
|
+
4: ToggleAvailabilityDay4,
|
|
31
|
+
5: ToggleAvailabilityDay5,
|
|
32
|
+
6: ToggleAvailabilityDay6,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const SELECT_DURATION = {
|
|
36
|
+
30: SelectDuration30,
|
|
37
|
+
60: SelectDuration60,
|
|
38
|
+
90: SelectDuration90,
|
|
39
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { toMs } from './time.js'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Returns the local date string "YYYY-MM-DD" for a UTC date in the given timezone.
|
|
3
5
|
*/
|
|
@@ -42,7 +44,7 @@ function addDays(date, days) {
|
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
/**
|
|
45
|
-
* Computes available booking slots for a
|
|
47
|
+
* Computes available booking slots for a provider.
|
|
46
48
|
*
|
|
47
49
|
* @param {object} params
|
|
48
50
|
* @param {object} params.availability
|
|
@@ -51,11 +53,11 @@ function addDays(date, days) {
|
|
|
51
53
|
* - bufferMinutes: Buffer added after each confirmed booking (default 0)
|
|
52
54
|
* - blackoutDates: Array of "YYYY-MM-DD" strings to exclude
|
|
53
55
|
* - timezone: IANA timezone string (e.g. "Europe/Stockholm")
|
|
54
|
-
* @param {Array} params.bookings Existing bookings [{ startAt, endAt, status }]
|
|
55
|
-
* @param {Date|string} params.fromDate Start of range (inclusive)
|
|
56
|
-
* @param {Date|string} params.toDate End of range (inclusive)
|
|
56
|
+
* @param {Array} params.bookings Existing bookings [{ startAt, endAt, status }] — ms or legacy ISO
|
|
57
|
+
* @param {Date|string|number} params.fromDate Start of range (inclusive)
|
|
58
|
+
* @param {Date|string|number} params.toDate End of range (inclusive)
|
|
57
59
|
* @param {number} params.duration Slot duration in minutes
|
|
58
|
-
* @returns {Array<{ startAt:
|
|
60
|
+
* @returns {Array<{ startAt: number, endAt: number, duration: number }>}
|
|
59
61
|
*/
|
|
60
62
|
export function getAvailableSlots({ availability, bookings = [], fromDate, toDate, duration }) {
|
|
61
63
|
const {
|
|
@@ -65,7 +67,7 @@ export function getAvailableSlots({ availability, bookings = [], fromDate, toDat
|
|
|
65
67
|
timezone = 'UTC',
|
|
66
68
|
} = availability
|
|
67
69
|
|
|
68
|
-
const now =
|
|
70
|
+
const now = Date.now()
|
|
69
71
|
const from = new Date(fromDate)
|
|
70
72
|
const to = new Date(toDate)
|
|
71
73
|
|
|
@@ -91,20 +93,22 @@ export function getAvailableSlots({ availability, bookings = [], fromDate, toDat
|
|
|
91
93
|
let slotStart = new Date(windowStart)
|
|
92
94
|
|
|
93
95
|
while (slotStart.getTime() + slotMs <= windowEnd.getTime()) {
|
|
94
|
-
const
|
|
96
|
+
const slotEndMs = slotStart.getTime() + slotMs
|
|
95
97
|
|
|
96
|
-
if (
|
|
98
|
+
if (slotEndMs > now) {
|
|
97
99
|
const hasOverlap = bookings.some(booking => {
|
|
98
100
|
if (booking.status === 'cancelled') return false
|
|
99
|
-
const bStart =
|
|
100
|
-
const bEnd =
|
|
101
|
-
|
|
101
|
+
const bStart = toMs(booking.startAt)
|
|
102
|
+
const bEnd = toMs(booking.endAt)
|
|
103
|
+
if (bStart == null || bEnd == null) return false
|
|
104
|
+
const bufferedEnd = bEnd + bufferMinutes * 60 * 1000
|
|
105
|
+
return slotStart.getTime() < bufferedEnd && slotEndMs > bStart
|
|
102
106
|
})
|
|
103
107
|
|
|
104
108
|
if (!hasOverlap) {
|
|
105
109
|
slots.push({
|
|
106
|
-
startAt: slotStart.
|
|
107
|
-
endAt:
|
|
110
|
+
startAt: slotStart.getTime(),
|
|
111
|
+
endAt: slotEndMs,
|
|
108
112
|
duration,
|
|
109
113
|
})
|
|
110
114
|
}
|
|
@@ -114,5 +118,5 @@ export function getAvailableSlots({ availability, bookings = [], fromDate, toDat
|
|
|
114
118
|
}
|
|
115
119
|
}
|
|
116
120
|
|
|
117
|
-
return slots.sort((a, b) =>
|
|
121
|
+
return slots.sort((a, b) => a.startAt - b.startAt)
|
|
118
122
|
}
|
|
@@ -1,17 +1,17 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, jest } from '@jest/globals'
|
|
1
2
|
import { getAvailableSlots } from './availability-engine.js'
|
|
2
3
|
|
|
3
4
|
// ---------------------------------------------------------------------------
|
|
4
5
|
// Helpers
|
|
5
6
|
// ---------------------------------------------------------------------------
|
|
6
7
|
|
|
7
|
-
/** Build
|
|
8
|
+
/** Build Unix ms for a given date at HH:MM in Europe/Stockholm. */
|
|
8
9
|
function sthlm(dateStr, timeStr) {
|
|
9
|
-
// Use the same localToUTC logic duplicated here to keep tests self-contained
|
|
10
10
|
const naive = new Date(`${dateStr}T${timeStr}:00Z`)
|
|
11
11
|
const utcDate = new Date(naive.toLocaleString('en-US', { timeZone: 'UTC' }))
|
|
12
12
|
const tzDate = new Date(naive.toLocaleString('en-US', { timeZone: 'Europe/Stockholm' }))
|
|
13
13
|
const offsetMs = utcDate - tzDate
|
|
14
|
-
return new Date(naive.getTime() + offsetMs).
|
|
14
|
+
return new Date(naive.getTime() + offsetMs).getTime()
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
const TZ = 'Europe/Stockholm'
|
|
@@ -34,6 +34,14 @@ const BASE_AVAILABILITY = {
|
|
|
34
34
|
// ---------------------------------------------------------------------------
|
|
35
35
|
|
|
36
36
|
describe('getAvailableSlots', () => {
|
|
37
|
+
beforeAll(() => {
|
|
38
|
+
jest.useFakeTimers()
|
|
39
|
+
jest.setSystemTime(new Date('2024-06-01T12:00:00Z'))
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
afterAll(() => {
|
|
43
|
+
jest.useRealTimers()
|
|
44
|
+
})
|
|
37
45
|
|
|
38
46
|
describe('basic slot generation', () => {
|
|
39
47
|
it('returns slots at correct interval within the window', () => {
|
|
@@ -130,7 +138,7 @@ describe('getAvailableSlots', () => {
|
|
|
130
138
|
toDate: new Date(`${MON2}T23:59:59Z`),
|
|
131
139
|
duration: 60,
|
|
132
140
|
})
|
|
133
|
-
expect(slots.every(s =>
|
|
141
|
+
expect(slots.every(s => s.startAt !== sthlm(MON, '09:00'))).toBe(true)
|
|
134
142
|
expect(slots.length).toBeGreaterThan(0)
|
|
135
143
|
})
|
|
136
144
|
})
|
|
@@ -160,6 +168,30 @@ describe('getAvailableSlots', () => {
|
|
|
160
168
|
expect(slots[0].startAt).toBe(sthlm(MON, '10:00'))
|
|
161
169
|
})
|
|
162
170
|
|
|
171
|
+
it('accepts legacy ISO booking times', () => {
|
|
172
|
+
const from = new Date(`${MON}T00:00:00Z`)
|
|
173
|
+
const to = new Date(`${MON}T23:59:59Z`)
|
|
174
|
+
|
|
175
|
+
const bookings = [
|
|
176
|
+
{
|
|
177
|
+
startAt: new Date(sthlm(MON, '09:00')).toISOString(),
|
|
178
|
+
endAt: new Date(sthlm(MON, '10:00')).toISOString(),
|
|
179
|
+
status: 'confirmed',
|
|
180
|
+
},
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
const slots = getAvailableSlots({
|
|
184
|
+
availability: BASE_AVAILABILITY,
|
|
185
|
+
bookings,
|
|
186
|
+
fromDate: from,
|
|
187
|
+
toDate: to,
|
|
188
|
+
duration: 60,
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
expect(slots).toHaveLength(2)
|
|
192
|
+
expect(slots[0].startAt).toBe(sthlm(MON, '10:00'))
|
|
193
|
+
})
|
|
194
|
+
|
|
163
195
|
it('does NOT remove a slot blocked by a cancelled booking', () => {
|
|
164
196
|
const from = new Date(`${MON}T00:00:00Z`)
|
|
165
197
|
const to = new Date(`${MON}T23:59:59Z`)
|