@ossy/booking 3.9.0 → 3.11.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.
Files changed (35) hide show
  1. package/package.json +8 -8
  2. package/src/BookingSalesPage.jsx +34 -33
  3. package/src/ServiceCard.jsx +6 -2
  4. package/src/booking-cancellation.email.jsx +4 -0
  5. package/src/booking-card.component.jsx +1 -1
  6. package/src/booking-confirmation.email.jsx +4 -0
  7. package/src/booking-reminder.email.jsx +4 -0
  8. package/src/booking-request.email.jsx +4 -0
  9. package/src/bookings.page.jsx +8 -4
  10. package/src/cancel-booking.flow.js +30 -0
  11. package/src/cancel-booking.task.js +2 -0
  12. package/src/client-booking.flow.js +89 -0
  13. package/src/confirm-booking-inbox.flow.js +42 -0
  14. package/src/confirm-booking.flow.js +32 -0
  15. package/src/confirm-booking.task.js +4 -1
  16. package/src/create-booking.task.js +2 -1
  17. package/src/decline-booking-inbox.flow.js +43 -0
  18. package/src/decline-booking.flow.js +32 -0
  19. package/src/decline-booking.task.js +2 -0
  20. package/src/delete-service.flow.js +40 -0
  21. package/src/disable-booking.flow.js +58 -0
  22. package/src/en.translations.json +6 -0
  23. package/src/index.js +2 -0
  24. package/src/open-delete-service.action.js +7 -0
  25. package/src/open-edit-service.action.js +7 -0
  26. package/src/provider-availability-weekend.flow.js +34 -0
  27. package/src/provider-catalog.flow.js +35 -0
  28. package/src/provider-onboard.flow.js +3 -3
  29. package/src/public-booking.page.jsx +6 -2
  30. package/src/service-card-slot.component.jsx +6 -2
  31. package/src/service-detail.component.jsx +6 -2
  32. package/src/service.schema.js +2 -2
  33. package/src/services.page.jsx +66 -10
  34. package/src/sv.translations.json +6 -0
  35. package/src/update-service.flow.js +42 -0
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 providers",
4
- "version": "3.9.0",
4
+ "version": "3.11.0",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "module": "./src/index.js",
@@ -15,13 +15,13 @@
15
15
  },
16
16
  "dependencies": {
17
17
  "@ossy/config": "^3.0.9",
18
- "@ossy/email": "^3.0.9",
19
- "@ossy/event-store": "^3.8.0",
18
+ "@ossy/email": "^3.11.0",
19
+ "@ossy/event-store": "^3.11.0",
20
20
  "@ossy/observability": "^3.0.9",
21
- "@ossy/platform": "^3.9.0",
22
- "@ossy/resources": "^3.9.0",
23
- "@ossy/users": "^3.8.0",
24
- "@ossy/workspaces": "^3.9.0"
21
+ "@ossy/platform": "^3.11.0",
22
+ "@ossy/resources": "^3.11.0",
23
+ "@ossy/users": "^3.11.0",
24
+ "@ossy/workspaces": "^3.11.0"
25
25
  },
26
26
  "peerDependencies": {
27
27
  "@ossy/app": ">=1.0.0",
@@ -38,5 +38,5 @@
38
38
  "/src",
39
39
  "README.md"
40
40
  ],
41
- "gitHead": "f404be69becb27a1fd853a6ff1903554e76e7d17"
41
+ "gitHead": "53ff8456920e09151c9d88df4a6c3ee958d811eb"
42
42
  }
@@ -1,54 +1,55 @@
1
- import React, { useState, useCallback } from 'react'
1
+ import React, { useState, useCallback, useMemo } from 'react'
2
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'
3
+ import { useSdk } from '@ossy/sdk-react'
4
+ import { Text, View, useLocale } from '@ossy/design-system'
5
5
  import SalesSection from './SalesSection.jsx'
6
6
  import { useBookingHomeContent } from './booking-home-content.js'
7
7
 
8
8
  export default function BookingSalesPage ({ isAuthenticated }) {
9
9
  const { t } = useLocale()
10
- const { invoke, invalidate } = useSdk()
10
+ const { invoke, read } = useSdk()
11
+ // Shared GetWorkspace cache with useShellWorkspace — refetch (not bare invalidate)
12
+ // awaits the post-enable read so home can switch sales → product reliably.
13
+ const { refetch: refetchWorkspace } = read(GetWorkspace, undefined, {
14
+ enabled: !!isAuthenticated,
15
+ })
11
16
  const [error, setError] = useState(null)
12
17
  const { cover: baseCover, features } = useBookingHomeContent()
13
18
 
14
19
  const handleEnable = useCallback(() => {
15
20
  setError(null)
16
21
  invoke(EnableService, { service: '@ossy/booking' })
17
- .then(() => invalidate(cacheKey(GetWorkspace)))
22
+ .then(() => refetchWorkspace())
18
23
  .catch(() => setError(t('booking.sales.enableError')))
19
- }, [invoke, invalidate, t])
24
+ }, [invoke, refetchWorkspace, t])
20
25
 
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
- // Keep the secondary example link only. The enable CTA lives in the
33
- // section below — putting it in the hero left a duplicate that Playwright
34
- // clicked first while the following Page intercepted pointer events.
35
- actions: baseCover.actions.slice(1),
36
- }
37
- : baseCover
26
+ const cover = useMemo(() => {
27
+ if (!isAuthenticated) return baseCover
28
+ return {
29
+ ...baseCover,
30
+ // Hero CTA (not a sibling below the height:100% sales scroller) so pointer
31
+ // events reach the React onClick — required for Enable booking e2e.
32
+ actions: [
33
+ {
34
+ ...EnableService,
35
+ 'data-service': '@ossy/booking',
36
+ variant: 'cta',
37
+ // HeroCover passes label as Button children (already translated).
38
+ label: t('booking.sales.enable'),
39
+ onClick: handleEnable,
40
+ },
41
+ { label: t('booking.home.cover.ctaSecondary'), variant: 'secondary', href: '/boka/demo' },
42
+ ],
43
+ }
44
+ }, [baseCover, handleEnable, isAuthenticated, t])
38
45
 
39
46
  return (
40
47
  <View data-booking-status="off">
41
48
  <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>
49
+ {error && (
50
+ <View inset="l">
51
+ <Text variant="small">{error}</Text>
52
+ </View>
52
53
  )}
53
54
  </View>
54
55
  )
@@ -1,9 +1,13 @@
1
1
  import React from 'react'
2
2
  import { View, Text, Badge } from '@ossy/design-system'
3
3
 
4
- const formatPrice = (cents, currency) => {
4
+ const formatPrice = (cents, currency = 'SEK') => {
5
5
  if (cents === 0) return 'Free'
6
- return new Intl.NumberFormat('sv-SE', { style: 'currency', currency }).format(cents / 100)
6
+ try {
7
+ return new Intl.NumberFormat('sv-SE', { style: 'currency', currency }).format(cents / 100)
8
+ } catch {
9
+ return `${(cents / 100).toFixed(2)} ${currency}`
10
+ }
7
11
  }
8
12
 
9
13
  const formatDuration = (minutes) => {
@@ -45,3 +45,7 @@ export default function BookingCancellationEmail({ clientName, startAt, duration
45
45
  </EmailLayout>
46
46
  )
47
47
  }
48
+
49
+ BookingCancellationEmail.id = id
50
+ BookingCancellationEmail.subject = subject
51
+
@@ -69,7 +69,7 @@ export function BookingCard ({ booking, resource, onConfirm, onDecline, onCancel
69
69
  const isConfirmed = data.status === 'confirmed'
70
70
 
71
71
  return (
72
- <View surface="secondary" roundness="m" inset="m" gap="s">
72
+ <View surface="secondary" roundness="m" inset="m" gap="s" data-booking-status={data.status}>
73
73
  <View layout="row" justifyContent="space-between" alignItems="flex-start" gap="m">
74
74
  <View gap="xs" style={{ flex: 1, minWidth: 0 }}>
75
75
  <View layout="row" gap="s" alignItems="center" style={{ flexWrap: 'wrap' }}>
@@ -107,3 +107,7 @@ export default function BookingConfirmationEmail({
107
107
  </EmailLayout>
108
108
  )
109
109
  }
110
+
111
+ BookingConfirmationEmail.id = id
112
+ BookingConfirmationEmail.subject = subject
113
+
@@ -68,3 +68,7 @@ export default function BookingReminderEmail({ clientName, providerName, consult
68
68
  </EmailLayout>
69
69
  )
70
70
  }
71
+
72
+ BookingReminderEmail.id = id
73
+ BookingReminderEmail.subject = subject
74
+
@@ -97,3 +97,7 @@ export default function BookingRequestEmail({
97
97
  </EmailLayout>
98
98
  )
99
99
  }
100
+
101
+ BookingRequestEmail.id = id
102
+ BookingRequestEmail.subject = subject
103
+
@@ -22,17 +22,21 @@ export default function BookingsPage () {
22
22
  const { t } = useLocale()
23
23
  const sdk = useSdk()
24
24
  const router = useRouter()
25
- const [activeTab, setActiveTab] = useState('pending')
25
+ const initialTab = ['pending', 'confirmed', 'all'].includes(router.searchParams?.tab)
26
+ ? router.searchParams.tab
27
+ : 'pending'
28
+ const [activeTab, setActiveTab] = useState(initialTab)
26
29
  const [bookings, setBookings] = useState([])
27
30
  const [loading, setLoading] = useState(true)
28
31
  const [error, setError] = useState(null)
29
32
 
30
33
  const TABS = useMemo(() => [
31
- { id: 'pending', label: t('booking.bookings.tab.pending') },
32
- { id: 'confirmed', label: t('booking.bookings.tab.confirmed') },
33
- { id: 'all', label: t('booking.bookings.tab.all') },
34
+ { id: 'pending', label: t('booking.bookings.tab.pending'), 'data-bookings-tab': 'pending' },
35
+ { id: 'confirmed', label: t('booking.bookings.tab.confirmed'), 'data-bookings-tab': 'confirmed' },
36
+ { id: 'all', label: t('booking.bookings.tab.all'), 'data-bookings-tab': 'all' },
34
37
  ], [t])
35
38
 
39
+
36
40
  const EMPTY_MESSAGES = useMemo(() => ({
37
41
  pending: {
38
42
  heading: t('booking.bookings.empty.pending.heading'),
@@ -0,0 +1,30 @@
1
+ import confirmBooking from './confirm-booking.flow.js'
2
+ import { metadata as CancelBooking } from './cancel-booking.action.js'
3
+
4
+ export const metadata = {
5
+ id: '@ossy/booking/flows/cancel-booking',
6
+ feature: 'booking',
7
+ requires: ['server', 'database'],
8
+ timeout: 150_000,
9
+ }
10
+
11
+ /**
12
+ * Provider cancels a confirmed booking from the bookings list.
13
+ *
14
+ * Composes after confirm-booking. Opens the confirmed tab via search param,
15
+ * cancels, then asserts cancelled status on the all tab.
16
+ */
17
+ export default {
18
+ title: 'Cancel booking',
19
+ description:
20
+ 'Client books; provider confirms via email; provider cancels from the confirmed bookings list',
21
+ steps: [
22
+ ...confirmBooking.steps,
23
+ { page: '@booking/bookings', search: { tab: 'confirmed' } },
24
+ { result: { action: CancelBooking, timeout: 20000 } },
25
+ { action: CancelBooking },
26
+ { result: { action: CancelBooking, hidden: true, timeout: 15000 } },
27
+ { page: '@booking/bookings', search: { tab: 'all' } },
28
+ { result: { selector: '[data-booking-status="cancelled"]', timeout: 15000 } },
29
+ ],
30
+ }
@@ -26,6 +26,8 @@ export async function run ({ payload, req, log, integrations }) {
26
26
  bookingId,
27
27
  ResourcesEvents.Patched({
28
28
  createdBy: req?.userId ?? 'system',
29
+ // Required for BookingListProjection.scopeFromEvent (live projection updates).
30
+ belongsTo: booking.belongsTo,
29
31
  content: { ...booking.content, status: 'cancelled' },
30
32
  }),
31
33
  )
@@ -0,0 +1,89 @@
1
+ import providerOnboard from './provider-onboard.flow.js'
2
+ import { metadata as OpenServices } from './open-services.action.js'
3
+ import { metadata as OpenCreateService } from './open-create-service.action.js'
4
+ import { metadata as CreateService } from './create-service.action.js'
5
+ import { metadata as OpenDeleteService } from './open-delete-service.action.js'
6
+ import { metadata as ServiceForm } from './service.form.js'
7
+ import { metadata as SaveAvailability } from './save-availability.action.js'
8
+ import { metadata as OpenAvailabilitySetup } from './open-availability-setup.action.js'
9
+ import { metadata as CreateBooking } from './create-booking.action.js'
10
+ import { metadata as PublicBookingForm } from './public-booking-form.form.js'
11
+ import {
12
+ ToggleAvailabilityDay1,
13
+ ToggleAvailabilityDay2,
14
+ ToggleAvailabilityDay3,
15
+ ToggleAvailabilityDay4,
16
+ ToggleAvailabilityDay5,
17
+ SelectDuration60,
18
+ } from './availability-action-maps.js'
19
+
20
+ export const metadata = {
21
+ id: '@ossy/booking/flows/client-booking',
22
+ feature: 'booking',
23
+ requires: ['server', 'database'],
24
+ timeout: 90_000,
25
+ }
26
+
27
+ /**
28
+ * Client books via the public page after the provider has a catalog service
29
+ * and weekly availability. Composes provider onboard + catalog + availability.
30
+ *
31
+ * Next: confirm-booking (provider confirms via request email).
32
+ */
33
+ export default {
34
+ title: 'Book as client',
35
+ description:
36
+ 'Provider enables booking, adds a service and availability; a client picks a slot and submits a booking request',
37
+ steps: [
38
+ ...providerOnboard.steps,
39
+
40
+ // Catalog service
41
+ { action: OpenServices },
42
+ { result: { selector: '[data-services-status]', timeout: 15000 } },
43
+ { result: { action: OpenCreateService, timeout: 10000 } },
44
+ { action: OpenCreateService },
45
+ { result: { action: CreateService, timeout: 15000 } },
46
+ { form: ServiceForm },
47
+ { action: CreateService },
48
+ { result: { action: OpenDeleteService, timeout: 15000 } },
49
+
50
+ // Availability + capture public booking slug
51
+ { page: '@booking/home' },
52
+ { result: { action: OpenAvailabilitySetup, timeout: 10000 } },
53
+ { action: OpenAvailabilitySetup },
54
+ { result: { action: SaveAvailability, timeout: 10000 } },
55
+ { action: ToggleAvailabilityDay1 },
56
+ { action: ToggleAvailabilityDay2 },
57
+ { action: ToggleAvailabilityDay3 },
58
+ { action: ToggleAvailabilityDay4 },
59
+ { action: ToggleAvailabilityDay5 },
60
+ { action: SelectDuration60 },
61
+ { action: SaveAvailability },
62
+ { result: { selector: '[data-availability-status="saved"]', timeout: 10000 } },
63
+ {
64
+ capture: {
65
+ providerSlug: { selector: '[data-provider-slug]', attr: 'data-provider-slug' },
66
+ providerEmail: '$email',
67
+ },
68
+ },
69
+
70
+ // Public client booking journey
71
+ { page: '@public-booking', params: { providerSlug: '$providerSlug' } },
72
+ { result: { action: 'public-booking/continue', timeout: 15000 } },
73
+ { pickFirst: 'service' },
74
+ { action: 'public-booking/continue' },
75
+ {
76
+ result: {
77
+ selector: '[data-action="public-booking/pick-date"]:not([disabled])',
78
+ timeout: 20000,
79
+ },
80
+ },
81
+ { pickFirst: 'date' },
82
+ { pickFirst: 'slot' },
83
+ { action: 'public-booking/continue' },
84
+ { result: { action: CreateBooking, timeout: 10000 } },
85
+ { form: PublicBookingForm },
86
+ { action: CreateBooking },
87
+ { result: { selector: '[data-flow-stage="booking-pending"]', timeout: 20000 } },
88
+ ],
89
+ }
@@ -0,0 +1,42 @@
1
+ import clientBooking from './client-booking.flow.js'
2
+ import { metadata as ConfirmBooking } from './confirm-booking.action.js'
3
+ import { metadata as OpenBookings } from './open-bookings.action.js'
4
+
5
+ export const metadata = {
6
+ id: '@ossy/booking/flows/confirm-booking-inbox',
7
+ feature: 'booking',
8
+ requires: ['server', 'database'],
9
+ timeout: 150_000,
10
+ }
11
+
12
+ /**
13
+ * Provider confirms a pending booking from the in-app bookings inbox
14
+ * (not the request email link).
15
+ *
16
+ * Composes after client-booking; session remains the provider.
17
+ */
18
+ export default {
19
+ title: 'Confirm booking from inbox',
20
+ description:
21
+ 'Client submits a booking request; provider opens bookings inbox and confirms the pending card',
22
+ steps: [
23
+ ...clientBooking.steps,
24
+ { page: '@booking/home' },
25
+ { result: { selector: '[data-booking-status="on"]', timeout: 15000 } },
26
+ { result: { action: OpenBookings, timeout: 10000 } },
27
+ { action: OpenBookings },
28
+ { result: { page: '@booking/bookings', timeout: 15000 } },
29
+ { result: { selector: '[data-booking-status="pending"]', timeout: 20000 } },
30
+ { result: { action: ConfirmBooking, timeout: 10000 } },
31
+ { action: ConfirmBooking },
32
+ {
33
+ result: {
34
+ selector: '[data-booking-status="pending"]',
35
+ hidden: true,
36
+ timeout: 15000,
37
+ },
38
+ },
39
+ { page: '@booking/bookings', search: { tab: 'confirmed' } },
40
+ { result: { selector: '[data-booking-status="confirmed"]', timeout: 15000 } },
41
+ ],
42
+ }
@@ -0,0 +1,32 @@
1
+ import clientBooking from './client-booking.flow.js'
2
+
3
+ export const metadata = {
4
+ id: '@ossy/booking/flows/confirm-booking',
5
+ feature: 'booking',
6
+ requires: ['server', 'database'],
7
+ timeout: 120_000,
8
+ }
9
+
10
+ /**
11
+ * Provider confirms a pending booking via the request email link.
12
+ *
13
+ * Composes after client-booking. Captures `providerEmail` before the client
14
+ * form overwrites `$email` with the client address.
15
+ */
16
+ export default {
17
+ title: 'Confirm booking',
18
+ description:
19
+ 'Client submits a booking request; provider confirms via the booking-request email link',
20
+ steps: [
21
+ ...clientBooking.steps,
22
+ {
23
+ email: {
24
+ to: '$providerEmail',
25
+ id: '@ossy/booking/emails/request',
26
+ click: 'Bekräfta bokning',
27
+ timeout: 20000,
28
+ },
29
+ },
30
+ { result: { url: /\/api\/v0\/booking\/confirm/, timeout: 15000 } },
31
+ ],
32
+ }
@@ -1,5 +1,5 @@
1
1
  import { ResourcesEvents, mutateResource, viewResource } from '@ossy/resources/server'
2
- import BookingConfirmationEmail from './booking-confirmation.email.jsx'
2
+ import BookingConfirmationEmail, { id as bookingConfirmationEmailId } from './booking-confirmation.email.jsx'
3
3
  import { resolveProviderName, workspaceIdFromContent } from './provider-fields.js'
4
4
  import { resolveWorkspaceContact } from './resolve-workspace-contact.js'
5
5
 
@@ -33,6 +33,8 @@ export async function run ({ payload, req, log, integrations }) {
33
33
  bookingId,
34
34
  ResourcesEvents.Patched({
35
35
  createdBy: req?.userId ?? 'system',
36
+ // Required for BookingListProjection.scopeFromEvent (live projection updates).
37
+ belongsTo: booking.belongsTo,
36
38
  content: { ...booking.content, status: 'confirmed' },
37
39
  }),
38
40
  )
@@ -55,6 +57,7 @@ export async function run ({ payload, req, log, integrations }) {
55
57
  to: booking.content.clientEmail,
56
58
  from: 'noreply@ossy.se',
57
59
  subject: 'Bokningsbekräftelse',
60
+ templateId: bookingConfirmationEmailId,
58
61
  },
59
62
  )
60
63
  log?.info(`[booking/tasks/confirm] Confirmation email sent to ${booking.content.clientEmail}`)
@@ -7,7 +7,7 @@ import { toMs } from './time.js'
7
7
  import { getBookingResources } from './booking-resources.js'
8
8
  import { createBookingEmailToken } from './booking-email-token.js'
9
9
  import { bookings } from './locations.js'
10
- import BookingRequestEmail from './booking-request.email.jsx'
10
+ import BookingRequestEmail, { id as bookingRequestEmailId } from './booking-request.email.jsx'
11
11
  import {
12
12
  resolveProviderEmail,
13
13
  resolveProviderName,
@@ -178,6 +178,7 @@ export async function run({ payload, req, log, integrations }) {
178
178
  to: providerEmail,
179
179
  from: 'noreply@ossy.se',
180
180
  subject: 'Ny bokningsförfrågan',
181
+ templateId: bookingRequestEmailId,
181
182
  },
182
183
  )
183
184
  log?.info(`[booking/tasks/create] Booking request email sent to provider ${providerEmail}`)
@@ -0,0 +1,43 @@
1
+ import clientBooking from './client-booking.flow.js'
2
+ import { metadata as DeclineBooking } from './decline-booking.action.js'
3
+ import { metadata as OpenBookings } from './open-bookings.action.js'
4
+
5
+ export const metadata = {
6
+ id: '@ossy/booking/flows/decline-booking-inbox',
7
+ feature: 'booking',
8
+ requires: ['server', 'database'],
9
+ timeout: 150_000,
10
+ }
11
+
12
+ /**
13
+ * Provider declines a pending booking from the in-app bookings inbox
14
+ * (not the request email link).
15
+ *
16
+ * Composes after client-booking; session remains the provider.
17
+ * Decline sets status to cancelled (same as cancel-booking task).
18
+ */
19
+ export default {
20
+ title: 'Decline booking from inbox',
21
+ description:
22
+ 'Client submits a booking request; provider opens bookings inbox and declines the pending card',
23
+ steps: [
24
+ ...clientBooking.steps,
25
+ { page: '@booking/home' },
26
+ { result: { selector: '[data-booking-status="on"]', timeout: 15000 } },
27
+ { result: { action: OpenBookings, timeout: 10000 } },
28
+ { action: OpenBookings },
29
+ { result: { page: '@booking/bookings', timeout: 15000 } },
30
+ { result: { selector: '[data-booking-status="pending"]', timeout: 20000 } },
31
+ { result: { action: DeclineBooking, timeout: 10000 } },
32
+ { action: DeclineBooking },
33
+ {
34
+ result: {
35
+ selector: '[data-booking-status="pending"]',
36
+ hidden: true,
37
+ timeout: 15000,
38
+ },
39
+ },
40
+ { page: '@booking/bookings', search: { tab: 'all' } },
41
+ { result: { selector: '[data-booking-status="cancelled"]', timeout: 15000 } },
42
+ ],
43
+ }
@@ -0,0 +1,32 @@
1
+ import clientBooking from './client-booking.flow.js'
2
+
3
+ export const metadata = {
4
+ id: '@ossy/booking/flows/decline-booking',
5
+ feature: 'booking',
6
+ requires: ['server', 'database'],
7
+ timeout: 120_000,
8
+ }
9
+
10
+ /**
11
+ * Provider declines a pending booking via the request email link.
12
+ *
13
+ * Composes after client-booking. Captures `providerEmail` before the client
14
+ * form overwrites `$email` with the client address.
15
+ */
16
+ export default {
17
+ title: 'Decline booking',
18
+ description:
19
+ 'Client submits a booking request; provider declines via the booking-request email link',
20
+ steps: [
21
+ ...clientBooking.steps,
22
+ {
23
+ email: {
24
+ to: '$providerEmail',
25
+ id: '@ossy/booking/emails/request',
26
+ click: 'Avböj förfrågan',
27
+ timeout: 20000,
28
+ },
29
+ },
30
+ { result: { url: /\/api\/v0\/booking\/decline/, timeout: 15000 } },
31
+ ],
32
+ }
@@ -31,6 +31,8 @@ export async function run ({ payload, req, log, integrations }) {
31
31
  bookingId,
32
32
  ResourcesEvents.Patched({
33
33
  createdBy: req?.userId ?? 'system',
34
+ // Required for BookingListProjection.scopeFromEvent (live projection updates).
35
+ belongsTo: booking.belongsTo,
34
36
  content: { ...booking.content, status: 'cancelled', declineReason: reason ?? '' },
35
37
  }),
36
38
  )
@@ -0,0 +1,40 @@
1
+ import providerCatalogFlow from './provider-catalog.flow.js'
2
+ import { metadata as OpenDeleteService } from './open-delete-service.action.js'
3
+ import { metadata as DeleteService } from './delete-service.action.js'
4
+
5
+ export const metadata = {
6
+ id: '@ossy/booking/flows/delete-service',
7
+ feature: 'booking',
8
+ requires: ['server', 'database'],
9
+ timeout: 120_000,
10
+ }
11
+
12
+ /**
13
+ * After creating a catalog service, open delete from the services list, confirm
14
+ * via Guide, and assert the catalog is empty again.
15
+ */
16
+ export default {
17
+ title: 'Delete booking service',
18
+ description:
19
+ 'Signed-in provider creates a catalog service, deletes it from the confirm dialog, and sees an empty services list',
20
+ steps: [
21
+ ...providerCatalogFlow.steps,
22
+ { result: { action: OpenDeleteService, timeout: 10000 } },
23
+ { action: OpenDeleteService },
24
+ { result: { action: DeleteService, timeout: 10000 } },
25
+ { action: DeleteService },
26
+ {
27
+ result: {
28
+ selector: '[data-services-status="empty"]',
29
+ timeout: 20000,
30
+ },
31
+ },
32
+ {
33
+ result: {
34
+ selector: '[data-service-id]',
35
+ hidden: true,
36
+ timeout: 10000,
37
+ },
38
+ },
39
+ ],
40
+ }
@@ -0,0 +1,58 @@
1
+ import providerOnboardFlow from './provider-onboard.flow.js'
2
+ import { DisableService, EnableService } from '@ossy/workspaces'
3
+
4
+ const BOOKING = '@ossy/booking'
5
+
6
+ export const metadata = {
7
+ id: '@ossy/booking/flows/disable-booking',
8
+ feature: 'booking',
9
+ requires: ['server', 'database'],
10
+ timeout: 120_000,
11
+ }
12
+
13
+ /**
14
+ * After enabling booking, disable the package from the catalog detail
15
+ * toggle and confirm the booking home returns to the sales (off) state.
16
+ */
17
+ export default {
18
+ title: 'Disable booking',
19
+ description:
20
+ 'Signed-in user enables booking, disables it from the package catalog, and sees the sales home again',
21
+ steps: [
22
+ ...providerOnboardFlow.steps,
23
+ { page: '@packages/detail', params: { packageSlug: 'booking' } },
24
+ {
25
+ result: {
26
+ action: { ...DisableService, service: BOOKING },
27
+ timeout: 15000,
28
+ },
29
+ },
30
+ {
31
+ result: {
32
+ selector: `[data-package-service="${BOOKING}"][data-service-enabled="true"]`,
33
+ timeout: 10000,
34
+ },
35
+ },
36
+ { action: { ...DisableService, service: BOOKING } },
37
+ {
38
+ result: {
39
+ action: { ...EnableService, service: BOOKING },
40
+ timeout: 20000,
41
+ },
42
+ },
43
+ {
44
+ result: {
45
+ selector: `[data-package-service="${BOOKING}"][data-service-enabled="false"]`,
46
+ timeout: 10000,
47
+ },
48
+ },
49
+ { page: '@booking/home' },
50
+ { result: { selector: '[data-booking-status="off"]', timeout: 20000 } },
51
+ {
52
+ result: {
53
+ action: { ...EnableService, service: BOOKING },
54
+ timeout: 10000,
55
+ },
56
+ },
57
+ ],
58
+ }
@@ -242,6 +242,10 @@
242
242
  "@ossy/booking/actions/update-service.description": "Update an existing bookable service",
243
243
  "@ossy/booking/actions/delete-service.label": "Delete service",
244
244
  "@ossy/booking/actions/delete-service.description": "Remove a bookable service",
245
+ "@ossy/booking/actions/open-delete-service.label": "Delete service",
246
+ "@ossy/booking/actions/open-delete-service.description": "Open delete confirmation for a bookable service",
247
+ "@ossy/booking/actions/open-edit-service.label": "Edit service",
248
+ "@ossy/booking/actions/open-edit-service.description": "Open the form to edit an existing bookable service",
245
249
  "@ossy/booking/actions/open-availability-setup.label": "Set up availability",
246
250
  "@ossy/booking/actions/open-availability-setup.description": "Open availability settings",
247
251
  "@ossy/booking/actions/open-services.label": "Manage services",
@@ -276,6 +280,8 @@
276
280
  "booking.services.errorSave": "Could not save service",
277
281
  "booking.services.errorDelete": "Could not delete service",
278
282
  "booking.services.deleteConfirm": "Delete this service?",
283
+ "booking.services.deleteTitle": "Delete this service?",
284
+ "booking.services.deleteText": "This removes the service from your catalog. Existing bookings are not changed.",
279
285
  "booking.services.editService": "Edit service",
280
286
  "booking.services.newService": "New service",
281
287
  "booking.services.save": "Save",
package/src/index.js CHANGED
@@ -12,6 +12,8 @@ export { metadata as ServiceForm } from './service.form.js'
12
12
  export { metadata as PublicBookingForm } from './public-booking-form.form.js'
13
13
  export { metadata as UpdateService } from './update-service.action.js'
14
14
  export { metadata as DeleteService } from './delete-service.action.js'
15
+ export { metadata as OpenDeleteService } from './open-delete-service.action.js'
16
+ export { metadata as OpenEditService } from './open-edit-service.action.js'
15
17
  export { metadata as SaveAvailability } from './save-availability.action.js'
16
18
  export { metadata as ConfirmBooking } from './confirm-booking.action.js'
17
19
  export { metadata as DeclineBooking } from './decline-booking.action.js'
@@ -0,0 +1,7 @@
1
+ /** Client-only — opens the service delete confirmation dialog. */
2
+ export const metadata = {
3
+ id: '@ossy/booking/actions/open-delete-service',
4
+ access: 'workspace',
5
+ label: 'booking.services.delete',
6
+ icon: 'trash-empty',
7
+ }
@@ -0,0 +1,7 @@
1
+ /** Client-only — opens the service edit form for an existing catalog service. */
2
+ export const metadata = {
3
+ id: '@ossy/booking/actions/open-edit-service',
4
+ access: 'workspace',
5
+ label: 'booking.services.edit',
6
+ icon: 'pen',
7
+ }
@@ -0,0 +1,34 @@
1
+ import providerOnboard from './provider-onboard.flow.js'
2
+ import { metadata as SaveAvailability } from './save-availability.action.js'
3
+ import { metadata as OpenAvailabilitySetup } from './open-availability-setup.action.js'
4
+ import {
5
+ ToggleAvailabilityDay0,
6
+ ToggleAvailabilityDay6,
7
+ SelectDuration90,
8
+ } from './availability-action-maps.js'
9
+
10
+ export const metadata = {
11
+ id: '@ossy/booking/flows/provider-availability-weekend',
12
+ feature: 'booking',
13
+ requires: ['server', 'database'],
14
+ }
15
+
16
+ /**
17
+ * Issue #5 — cover weekend day toggles and 90-minute duration (sibling of the
18
+ * Mon–Fri + 60m provider-availability flow).
19
+ */
20
+ export default {
21
+ title: 'Set up weekend availability',
22
+ description:
23
+ 'New service provider enables booking and saves Saturday/Sunday availability with 90-minute slots',
24
+ steps: [
25
+ ...providerOnboard.steps,
26
+ { action: OpenAvailabilitySetup },
27
+ { result: { action: SaveAvailability, timeout: 10000 } },
28
+ { action: ToggleAvailabilityDay0 },
29
+ { action: ToggleAvailabilityDay6 },
30
+ { action: SelectDuration90 },
31
+ { action: SaveAvailability },
32
+ { result: { selector: '[data-availability-status="saved"]', timeout: 10000 } },
33
+ ],
34
+ }
@@ -0,0 +1,35 @@
1
+ import providerOnboard from './provider-onboard.flow.js'
2
+ import { metadata as OpenServices } from './open-services.action.js'
3
+ import { metadata as OpenCreateService } from './open-create-service.action.js'
4
+ import { metadata as CreateService } from './create-service.action.js'
5
+ import { metadata as OpenDeleteService } from './open-delete-service.action.js'
6
+ import { metadata as ServiceForm } from './service.form.js'
7
+
8
+ export const metadata = {
9
+ id: '@ossy/booking/flows/provider-catalog',
10
+ feature: 'booking',
11
+ requires: ['server', 'database'],
12
+ }
13
+
14
+ /**
15
+ * Step 2 — provider enables booking and adds a catalog service.
16
+ *
17
+ * Composes after provider-onboard. client-booking / confirm-booking compose
18
+ * catalog + availability after onboard for the public booking journey.
19
+ */
20
+ export default {
21
+ title: 'Create booking service',
22
+ description:
23
+ 'New service provider registers, verifies email, enables booking, and creates a catalog service',
24
+ steps: [
25
+ ...providerOnboard.steps,
26
+ { action: OpenServices },
27
+ { result: { selector: '[data-services-status]', timeout: 15000 } },
28
+ { result: { action: OpenCreateService, timeout: 10000 } },
29
+ { action: OpenCreateService },
30
+ { result: { action: CreateService, timeout: 15000 } },
31
+ { form: ServiceForm },
32
+ { action: CreateService },
33
+ { result: { action: OpenDeleteService, timeout: 15000 } },
34
+ ],
35
+ }
@@ -10,7 +10,7 @@ export const metadata = {
10
10
  /**
11
11
  * Step 1 — provider signs up and enables the booking capability for their workspace.
12
12
  *
13
- * Next flows (catalog setup, client booking, confirm) will compose after this.
13
+ * Next: provider-catalog (service setup), then client booking / confirm.
14
14
  */
15
15
  export default {
16
16
  title: 'Enable booking',
@@ -18,8 +18,8 @@ export default {
18
18
  steps: [
19
19
  ...signUpFlow.steps,
20
20
  { page: '@booking/home' },
21
- { result: { action: { ...EnableService, service: '@ossy/booking' }, timeout: 10000 } },
21
+ { result: { action: { ...EnableService, service: '@ossy/booking' }, timeout: 15000 } },
22
22
  { action: { ...EnableService, service: '@ossy/booking' } },
23
- { result: { selector: '[data-booking-status="on"]', timeout: 10000 } },
23
+ { result: { selector: '[data-booking-status="on"]', timeout: 20000 } },
24
24
  ],
25
25
  }
@@ -151,7 +151,7 @@ function BookingForm ({ slot, service, providerSlug, providerContact, onSuccess,
151
151
 
152
152
  function PendingScreen ({ slot, t, language }) {
153
153
  return (
154
- <View gap="m" alignItems="center" style={{ textAlign: 'center' }}>
154
+ <View gap="m" alignItems="center" style={{ textAlign: 'center' }} data-flow-stage="booking-pending">
155
155
  <Text variant="heading-secondary" as="h2" text="publicBooking.pending.title" />
156
156
  <Text color="secondary" text="publicBooking.pending.body" />
157
157
  <Alert variant="warning" title={t('publicBooking.pending.requestedTime')}>
@@ -175,7 +175,11 @@ function ServicePicker ({ services, selectedService, onSelect, t, language }) {
175
175
  }
176
176
  const formatPrice = (cents, currency) => {
177
177
  if (!cents) return t('publicBooking.serviceFree')
178
- return new Intl.NumberFormat(language === 'sv' ? 'sv-SE' : 'en-GB', { style: 'currency', currency: currency || 'SEK' }).format(cents / 100)
178
+ try {
179
+ return new Intl.NumberFormat(language === 'sv' ? 'sv-SE' : 'en-GB', { style: 'currency', currency: currency || 'SEK' }).format(cents / 100)
180
+ } catch {
181
+ return `${(cents / 100).toFixed(2)} ${currency || 'SEK'}`
182
+ }
179
183
  }
180
184
  return (
181
185
  <View gap="s">
@@ -3,9 +3,13 @@ import { View, Text, Badge } from '@ossy/design-system'
3
3
 
4
4
  export const metadata = { id: '@ossy/booking/view/service/card' }
5
5
 
6
- const formatPrice = (cents, currency) => {
6
+ const formatPrice = (cents, currency = 'SEK') => {
7
7
  if (cents === 0) return 'Free'
8
- return new Intl.NumberFormat('sv-SE', { style: 'currency', currency }).format(cents / 100)
8
+ try {
9
+ return new Intl.NumberFormat('sv-SE', { style: 'currency', currency }).format(cents / 100)
10
+ } catch {
11
+ return `${(cents / 100).toFixed(2)} ${currency}`
12
+ }
9
13
  }
10
14
 
11
15
  const formatDuration = (minutes) => {
@@ -3,9 +3,13 @@ import { View, Text, Badge } from '@ossy/design-system'
3
3
 
4
4
  export const metadata = { id: '@ossy/booking/view/service' }
5
5
 
6
- const formatPrice = (cents, currency) => {
6
+ const formatPrice = (cents, currency = 'SEK') => {
7
7
  if (cents === 0) return 'Free'
8
- return new Intl.NumberFormat('sv-SE', { style: 'currency', currency }).format(cents / 100)
8
+ try {
9
+ return new Intl.NumberFormat('sv-SE', { style: 'currency', currency }).format(cents / 100)
10
+ } catch {
11
+ return `${(cents / 100).toFixed(2)} ${currency}`
12
+ }
9
13
  }
10
14
 
11
15
  const formatDuration = (minutes) => {
@@ -5,9 +5,9 @@ export default {
5
5
  icon: 'calendar',
6
6
  fields: [
7
7
  { name: 'name', type: 'text', required: true },
8
- { name: 'duration', type: 'number', required: true },
8
+ { name: 'duration', type: 'select', required: true, options: ['30', '60', '90'] },
9
9
  { name: 'price', type: 'number' },
10
- { name: 'currency', type: 'text' },
10
+ { name: 'currency', type: 'select', options: ['SEK', 'EUR', 'USD', 'NOK', 'DKK'] },
11
11
  { name: 'description', type: 'textarea' },
12
12
  { name: 'active', type: 'boolean' },
13
13
  ],
@@ -1,5 +1,5 @@
1
1
  import React, { useState, useEffect, useCallback } from 'react'
2
- import { View, Text, Button, Alert, Form, FieldFactory, FormStatus, ContentHeader, Page, useLocale } from '@ossy/design-system'
2
+ import { View, Text, Button, Alert, Form, FieldFactory, FormStatus, ContentHeader, Page, Overlay, Guide, useLocale } from '@ossy/design-system'
3
3
  import { useSdk } from '@ossy/sdk-react'
4
4
  import { useDocumentValidator } from '@ossy/resources'
5
5
  import { Definition } from './Definition.js'
@@ -7,6 +7,8 @@ import { ServiceCard } from './ServiceCard.jsx'
7
7
  import { metadata as CreateService } from './create-service.action.js'
8
8
  import { metadata as UpdateService } from './update-service.action.js'
9
9
  import { metadata as DeleteService } from './delete-service.action.js'
10
+ import { metadata as OpenDeleteService } from './open-delete-service.action.js'
11
+ import { metadata as OpenEditService } from './open-edit-service.action.js'
10
12
  import { metadata as GetServices } from './get-services.action.js'
11
13
  import { metadata as OpenCreateService } from './open-create-service.action.js'
12
14
  import { metadata as ServiceForm } from './service.form.js'
@@ -23,7 +25,7 @@ export const metadata = {
23
25
 
24
26
  const EMPTY_FORM = {
25
27
  name: '',
26
- duration: 60,
28
+ duration: '60',
27
29
  price: 0,
28
30
  currency: 'SEK',
29
31
  description: '',
@@ -40,6 +42,8 @@ export default function ServicesPage () {
40
42
  const [editingId, setEditingId] = useState(null)
41
43
  const [formDefaults, setFormDefaults] = useState(EMPTY_FORM)
42
44
  const [saving, setSaving] = useState(false)
45
+ const [serviceToDelete, setServiceToDelete] = useState(null)
46
+ const [deleting, setDeleting] = useState(false)
43
47
  const validate = useDocumentValidator(serviceTemplate)
44
48
 
45
49
  const loadServices = useCallback(async () => {
@@ -69,7 +73,7 @@ export default function ServicesPage () {
69
73
  setEditingId(service.id)
70
74
  setFormDefaults({
71
75
  name: service.name ?? '',
72
- duration: service.duration ?? 60,
76
+ duration: String(service.duration ?? 60),
73
77
  price: service.price ?? 0,
74
78
  currency: service.currency ?? 'SEK',
75
79
  description: service.description ?? '',
@@ -94,14 +98,18 @@ export default function ServicesPage () {
94
98
  }
95
99
  }
96
100
 
97
- const handleDelete = async (serviceId) => {
98
- if (!window.confirm(t('booking.services.deleteConfirm'))) return
101
+ const confirmDelete = async () => {
102
+ if (!serviceToDelete?.id) return
103
+ setDeleting(true)
99
104
  setError(null)
100
105
  try {
101
- await sdk.invoke(DeleteService, { serviceId })
106
+ await sdk.invoke(DeleteService, { serviceId: serviceToDelete.id })
107
+ setServiceToDelete(null)
102
108
  await loadServices()
103
109
  } catch (err) {
104
110
  setError(await invokeErrorMessage(err, t('booking.services.errorDelete')))
111
+ } finally {
112
+ setDeleting(false)
105
113
  }
106
114
  }
107
115
 
@@ -157,7 +165,10 @@ export default function ServicesPage () {
157
165
  </Form>
158
166
  )}
159
167
 
160
- <View gap="s">
168
+ <View
169
+ gap="s"
170
+ data-services-status={loading ? 'loading' : services.length === 0 ? 'empty' : 'populated'}
171
+ >
161
172
  <Text variant="heading-secondary" as="h2" text="booking.services.yourServices" />
162
173
  {loading ? (
163
174
  <Text color="secondary" text="booking.services.loading" />
@@ -166,7 +177,12 @@ export default function ServicesPage () {
166
177
  ) : (
167
178
  <View gap="s">
168
179
  {services.map(service => (
169
- <View key={service.id} gap="xs">
180
+ <View
181
+ key={service.id}
182
+ gap="xs"
183
+ data-service-id={service.id}
184
+ data-service-name={service.name}
185
+ >
170
186
  <ServiceCard
171
187
  name={service.name}
172
188
  duration={service.duration}
@@ -176,14 +192,54 @@ export default function ServicesPage () {
176
192
  active={service.active !== false}
177
193
  />
178
194
  <View layout="row" gap="s">
179
- <Button variant="link" size="s" label="booking.services.edit" onClick={() => openEdit(service)} />
180
- <Button {...DeleteService} variant="link" size="s" label="booking.services.delete" onClick={() => handleDelete(service.id)} />
195
+ <Button
196
+ {...OpenEditService}
197
+ variant="link"
198
+ size="s"
199
+ label="booking.services.edit"
200
+ onClick={() => openEdit(service)}
201
+ />
202
+ <Button
203
+ {...OpenDeleteService}
204
+ variant="link"
205
+ size="s"
206
+ label="booking.services.delete"
207
+ onClick={() => setServiceToDelete(service)}
208
+ />
181
209
  </View>
182
210
  </View>
183
211
  ))}
184
212
  </View>
185
213
  )}
186
214
  </View>
215
+
216
+ <Overlay isVisible={Boolean(serviceToDelete)} onClose={() => !deleting && setServiceToDelete(null)}>
217
+ <View layout="off-center-s" style={{ height: '100%' }}>
218
+ <View data-region="content">
219
+ <View surface="primary" roundness="s" inset="l">
220
+ <Guide
221
+ title="booking.services.deleteTitle"
222
+ text="booking.services.deleteText"
223
+ actions={[
224
+ {
225
+ label: 'booking.services.cancel',
226
+ variant: 'command',
227
+ disabled: deleting,
228
+ onClick: () => setServiceToDelete(null),
229
+ },
230
+ {
231
+ ...DeleteService,
232
+ label: 'booking.services.delete',
233
+ variant: 'command-danger',
234
+ disabled: deleting,
235
+ onClick: confirmDelete,
236
+ },
237
+ ]}
238
+ />
239
+ </View>
240
+ </View>
241
+ </View>
242
+ </Overlay>
187
243
  </Page>
188
244
  )
189
245
  }
@@ -242,6 +242,10 @@
242
242
  "@ossy/booking/actions/update-service.description": "Uppdatera en befintlig bokningsbar tjänst",
243
243
  "@ossy/booking/actions/delete-service.label": "Ta bort tjänst",
244
244
  "@ossy/booking/actions/delete-service.description": "Ta bort en bokningsbar tjänst",
245
+ "@ossy/booking/actions/open-delete-service.label": "Ta bort tjänst",
246
+ "@ossy/booking/actions/open-delete-service.description": "Öppna bekräftelse för att ta bort en bokningsbar tjänst",
247
+ "@ossy/booking/actions/open-edit-service.label": "Redigera tjänst",
248
+ "@ossy/booking/actions/open-edit-service.description": "Öppna formuläret för att redigera en befintlig bokningsbar tjänst",
245
249
  "@ossy/booking/actions/open-availability-setup.label": "Ställ in tillgänglighet",
246
250
  "@ossy/booking/actions/open-availability-setup.description": "Öppna tillgänglighetsinställningar",
247
251
  "@ossy/booking/actions/open-services.label": "Hantera tjänster",
@@ -276,6 +280,8 @@
276
280
  "booking.services.errorSave": "Kunde inte spara tjänst",
277
281
  "booking.services.errorDelete": "Kunde inte ta bort tjänst",
278
282
  "booking.services.deleteConfirm": "Ta bort denna tjänst?",
283
+ "booking.services.deleteTitle": "Ta bort denna tjänst?",
284
+ "booking.services.deleteText": "Detta tar bort tjänsten från din katalog. Befintliga bokningar påverkas inte.",
279
285
  "booking.services.editService": "Redigera tjänst",
280
286
  "booking.services.newService": "Ny tjänst",
281
287
  "booking.services.save": "Spara",
@@ -0,0 +1,42 @@
1
+ import providerCatalogFlow from './provider-catalog.flow.js'
2
+ import { metadata as OpenEditService } from './open-edit-service.action.js'
3
+ import { metadata as UpdateService } from './update-service.action.js'
4
+ import { metadata as ServiceForm } from './service.form.js'
5
+
6
+ export const metadata = {
7
+ id: '@ossy/booking/flows/update-service',
8
+ feature: 'booking',
9
+ requires: ['server', 'database'],
10
+ timeout: 120_000,
11
+ }
12
+
13
+ /**
14
+ * After creating a catalog service, open edit, change fields, save via
15
+ * UpdateService, and assert the list shows the updated name.
16
+ */
17
+ export default {
18
+ title: 'Update booking service',
19
+ description:
20
+ 'Signed-in provider creates a catalog service, edits it from the services list, and sees the updated name',
21
+ steps: [
22
+ ...providerCatalogFlow.steps,
23
+ { result: { action: OpenEditService, timeout: 10000 } },
24
+ { action: OpenEditService },
25
+ { result: { action: UpdateService, timeout: 10000 } },
26
+ { form: ServiceForm, fields: { name: 'e2e-updated-service' } },
27
+ { action: UpdateService },
28
+ {
29
+ result: {
30
+ selector: '[data-service-name="e2e-updated-service"]',
31
+ timeout: 20000,
32
+ },
33
+ },
34
+ {
35
+ result: {
36
+ selector: '[data-action="@ossy/booking/actions/update-service"]',
37
+ hidden: true,
38
+ timeout: 10000,
39
+ },
40
+ },
41
+ ],
42
+ }