@ossy/booking 1.15.7 → 1.16.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.
Files changed (46) hide show
  1. package/README.md +26 -19
  2. package/package.json +7 -7
  3. package/src/AvailabilityEditor.jsx +22 -15
  4. package/src/Definition.js +1 -23
  5. package/src/SalesSection.jsx +9 -4
  6. package/src/availability-setup.page.jsx +94 -126
  7. package/src/booking-card.component.jsx +30 -33
  8. package/src/booking-detail.page.jsx +65 -204
  9. package/src/booking-resources.js +10 -0
  10. package/src/bookings.page.jsx +54 -56
  11. package/src/cancel-booking.action.js +1 -61
  12. package/src/cancel-booking.task.js +60 -0
  13. package/src/confirm-booking.action.js +1 -82
  14. package/src/confirm-booking.task.js +81 -0
  15. package/src/create-booking.action.js +1 -168
  16. package/src/create-booking.task.js +170 -0
  17. package/src/create-service.action.js +1 -0
  18. package/src/create-service.task.js +46 -0
  19. package/src/decline-booking.action.js +1 -68
  20. package/src/decline-booking.task.js +67 -0
  21. package/src/delete-service.action.js +1 -0
  22. package/src/delete-service.task.js +41 -0
  23. package/src/en.translations.json +246 -0
  24. package/src/get-availability.action.js +1 -52
  25. package/src/get-availability.task.js +51 -0
  26. package/src/get-available-slots.action.js +1 -89
  27. package/src/get-available-slots.task.js +99 -0
  28. package/src/get-services.action.js +1 -0
  29. package/src/get-services.task.js +37 -0
  30. package/src/home.page.jsx +48 -5
  31. package/src/index.js +20 -1
  32. package/src/invoke-error-message.js +10 -0
  33. package/src/list-bookings.action.js +1 -36
  34. package/src/list-bookings.task.js +35 -0
  35. package/src/locations.js +14 -0
  36. package/src/public-booking.page.jsx +200 -245
  37. package/src/save-availability.action.js +1 -73
  38. package/src/save-availability.task.js +74 -0
  39. package/src/send-booking-reminder.task.js +2 -2
  40. package/src/services.page.jsx +167 -46
  41. package/src/sv.translations.json +246 -0
  42. package/src/update-service.action.js +1 -0
  43. package/src/update-service.task.js +52 -0
  44. package/src/BookingForm.jsx +0 -82
  45. package/src/availability.page.jsx +0 -41
  46. package/src/booking-page.page.jsx +0 -42
@@ -0,0 +1,74 @@
1
+ import { nanoid } from 'nanoid'
2
+ import { Aggregate } from '@ossy/event-store'
3
+ import { Resource, ResourcesEvents } from '@ossy/resources/server'
4
+ import { getBookingResources } from './booking-resources.js'
5
+ import { availability } from './locations.js'
6
+
7
+ export const metadata = { id: 'booking/save-availability' }
8
+
9
+ export async function run({ payload, req, log }) {
10
+ const workspaceId = req?.workspaceId
11
+
12
+ if (!workspaceId) {
13
+ throw Object.assign(new Error('workspaceId is required'), { status: 400 })
14
+ }
15
+
16
+ const { weeklyWindows, sessionDurations, bufferMinutes, timezone } = payload ?? {}
17
+
18
+ log?.info(`[booking/save-availability] Saving availability for workspace ${workspaceId}`)
19
+
20
+ const content = {
21
+ weeklyWindows: weeklyWindows ?? [],
22
+ sessionDurations: Array.isArray(sessionDurations) && sessionDurations.length
23
+ ? sessionDurations
24
+ : [60],
25
+ bufferMinutes: bufferMinutes ?? 0,
26
+ timezone: timezone ?? 'Europe/Stockholm',
27
+ }
28
+
29
+ // Check if a config resource already exists for this workspace
30
+ const existing = await getBookingResources({
31
+ type: '@ossy/booking/availability',
32
+ belongsTo: workspaceId,
33
+ })
34
+
35
+ // Identify the single config resource (new format has weeklyWindows array in content)
36
+ const configResource = existing.find(r => Array.isArray(r.content?.weeklyWindows))
37
+
38
+ if (configResource) {
39
+ log?.info(`[booking/save-availability] Updating existing resource ${configResource.id}`)
40
+
41
+ await Aggregate.Of(Resource, configResource.id)
42
+ .then(
43
+ Aggregate.Add(
44
+ ResourcesEvents.ContentUpdated({
45
+ createdBy: req?.userId ?? 'system',
46
+ content,
47
+ }),
48
+ ),
49
+ )
50
+ .then(Aggregate.Save())
51
+
52
+ return { id: configResource.id, content }
53
+ }
54
+
55
+ // No config resource found — create a new one
56
+ const resourceId = nanoid()
57
+ log?.info(`[booking/save-availability] Creating new resource ${resourceId}`)
58
+
59
+ const event = ResourcesEvents.Created({
60
+ aggregateId: resourceId,
61
+ type: '@ossy/booking/availability',
62
+ createdBy: req?.userId ?? 'system',
63
+ belongsTo: workspaceId,
64
+ location: availability,
65
+ name: `availability-config.json`,
66
+ content,
67
+ })
68
+
69
+ await Aggregate.Of(Resource, event).then(Aggregate.View())
70
+
71
+ log?.info(`[booking/save-availability] Resource ${resourceId} created`)
72
+
73
+ return { id: resourceId, content }
74
+ }
@@ -1,4 +1,4 @@
1
- import { ResourcesQueries } from '@ossy/resources'
1
+ import { getBookingResources } from './booking-resources.js'
2
2
  import BookingReminderEmail from './booking-reminder.email.jsx'
3
3
 
4
4
  export const metadata = {
@@ -13,7 +13,7 @@ export async function run({ sdk, log, integrations }) {
13
13
 
14
14
  log?.info(`[booking/send-reminder] Checking bookings between ${windowStart} and ${windowEnd}`)
15
15
 
16
- const allBookings = await ResourcesQueries.GetResources({
16
+ const allBookings = await getBookingResources({
17
17
  type: '@ossy/booking/booking',
18
18
  })
19
19
 
@@ -1,8 +1,13 @@
1
- import React from 'react'
2
- import { View, Title, Text, Button, Tags } from '@ossy/design-system'
1
+ import React, { useState, useEffect, useCallback } from 'react'
2
+ import { View, Title, Text, Button, Input, Textarea, Alert, useLocale } from '@ossy/design-system'
3
+ import { useSdk } from '@ossy/sdk-react'
3
4
  import { Definition } from './Definition.js'
4
- import { moduleStatusTags } from './moduleStatus.js'
5
5
  import { ServiceCard } from './ServiceCard.jsx'
6
+ import { metadata as CreateService } from './create-service.action.js'
7
+ import { metadata as UpdateService } from './update-service.action.js'
8
+ import { metadata as DeleteService } from './delete-service.action.js'
9
+ import { metadata as GetServices } from './get-services.action.js'
10
+ import { invokeErrorMessage } from './invoke-error-message.js'
6
11
 
7
12
  export const metadata = {
8
13
  id: 'booking/services',
@@ -12,53 +17,169 @@ export const metadata = {
12
17
  },
13
18
  }
14
19
 
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
- )}
20
+ const EMPTY_FORM = {
21
+ name: '',
22
+ duration: 60,
23
+ price: 0,
24
+ currency: 'SEK',
25
+ description: '',
26
+ active: true,
27
+ }
28
+
29
+ export default function ServicesPage () {
30
+ const { t } = useLocale()
31
+ const sdk = useSdk()
32
+ const [services, setServices] = useState([])
33
+ const [loading, setLoading] = useState(true)
34
+ const [error, setError] = useState(null)
35
+ const [formOpen, setFormOpen] = useState(false)
36
+ const [editingId, setEditingId] = useState(null)
37
+ const [form, setForm] = useState(EMPTY_FORM)
38
+ const [saving, setSaving] = useState(false)
39
+
40
+ const loadServices = useCallback(async () => {
41
+ setLoading(true)
42
+ setError(null)
43
+ try {
44
+ const data = await sdk.invoke(GetServices, { includeInactive: true })
45
+ setServices(Array.isArray(data) ? data : [])
46
+ } catch (err) {
47
+ setError(err.message)
48
+ } finally {
49
+ setLoading(false)
50
+ }
51
+ }, [sdk, t])
52
+
53
+ useEffect(() => {
54
+ loadServices()
55
+ }, [loadServices])
56
+
57
+ const openCreate = () => {
58
+ setEditingId(null)
59
+ setForm(EMPTY_FORM)
60
+ setFormOpen(true)
61
+ }
62
+
63
+ const openEdit = (service) => {
64
+ setEditingId(service.id)
65
+ setForm({
66
+ name: service.name ?? '',
67
+ duration: service.duration ?? 60,
68
+ price: service.price ?? 0,
69
+ currency: service.currency ?? 'SEK',
70
+ description: service.description ?? '',
71
+ active: service.active !== false,
72
+ })
73
+ setFormOpen(true)
74
+ }
75
+
76
+ const handleSave = async () => {
77
+ if (!form.name.trim()) return
78
+ setSaving(true)
79
+ setError(null)
80
+ try {
81
+ const bookingAction = editingId ? UpdateService : CreateService
82
+ const body = editingId ? { serviceId: editingId, ...form } : form
83
+ await sdk.invoke(bookingAction, body)
84
+ setFormOpen(false)
85
+ await loadServices()
86
+ } catch (err) {
87
+ setError(await invokeErrorMessage(err, t('booking.services.errorSave')))
88
+ } finally {
89
+ setSaving(false)
90
+ }
91
+ }
92
+
93
+ const handleDelete = async (serviceId) => {
94
+ if (!window.confirm(t('booking.services.deleteConfirm'))) return
95
+ setError(null)
96
+ try {
97
+ await sdk.invoke(DeleteService, { serviceId })
98
+ await loadServices()
99
+ } catch (err) {
100
+ setError(await invokeErrorMessage(err, t('booking.services.errorDelete')))
101
+ }
102
+ }
103
+
104
+ return (
105
+ <View
106
+ gap="m"
107
+ surface="primary"
108
+ style={{ padding: 'var(--space-m) var(--space-l)', height: '100%', overflowY: 'auto' }}
109
+ >
110
+ <View inset="s" gap="s">
111
+ <View layout="row" justifyContent="space-between" alignItems="center" style={{ flexShrink: 0, flexWrap: 'wrap', gap: 'var(--space-s)' }}>
112
+ <Title>{Definition.title} — {t('booking.services.titleSuffix')}</Title>
113
+ <Button variant="primary" size="s" onClick={openCreate}>{t('booking.services.addService')}</Button>
28
114
  </View>
29
- {/* TODO: wire to booking.create-service action */}
30
- <Button variant="primary" size="s">Add service</Button>
115
+ <Text style={{ maxWidth: '480px' }}>{t('booking.services.description')}</Text>
31
116
  </View>
32
117
 
33
- <Text style={{ maxWidth: '400px' }}>
34
- Define what you offer — name, duration, and price. Clients will see these when booking.
35
- </Text>
36
- </View>
118
+ {error && <Alert variant="danger">{error}</Alert>}
37
119
 
38
- <View gap="s" inset="s">
39
- <Title variant="secondary">Your services</Title>
120
+ {formOpen && (
121
+ <View gap="m" surface="secondary" roundness="m" inset="m">
122
+ <Title variant="tertiary">{editingId ? t('booking.services.editService') : t('booking.services.newService')}</Title>
123
+ <View gap="xs">
124
+ <Text weight="medium">{t('booking.services.field.name')}</Text>
125
+ <Input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} />
126
+ </View>
127
+ <View gap="xs">
128
+ <Text weight="medium">{t('booking.services.field.duration')}</Text>
129
+ <Input type="number" value={String(form.duration)} onChange={e => setForm(f => ({ ...f, duration: Number(e.target.value) || 60 }))} />
130
+ </View>
131
+ <View gap="xs">
132
+ <Text weight="medium">{t('booking.services.field.price')}</Text>
133
+ <Input type="number" value={String(form.price)} onChange={e => setForm(f => ({ ...f, price: Number(e.target.value) || 0 }))} />
134
+ </View>
135
+ <View gap="xs">
136
+ <Text weight="medium">{t('booking.services.field.currency')}</Text>
137
+ <Input value={form.currency} onChange={e => setForm(f => ({ ...f, currency: e.target.value }))} />
138
+ </View>
139
+ <View gap="xs">
140
+ <Text weight="medium">{t('booking.services.field.description')}</Text>
141
+ <Textarea value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} rows={3} />
142
+ </View>
143
+ <label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
144
+ <input type="checkbox" checked={form.active} onChange={e => setForm(f => ({ ...f, active: e.target.checked }))} />
145
+ <Text>{t('booking.services.field.active')}</Text>
146
+ </label>
147
+ <View layout="row" gap="s">
148
+ <Button data-action={editingId ? UpdateService.id : CreateService.id} variant="primary" disabled={saving} onClick={handleSave}>
149
+ {saving ? t('booking.services.saving') : t('booking.services.save')}
150
+ </Button>
151
+ <Button variant="neutral" onClick={() => setFormOpen(false)}>{t('booking.services.cancel')}</Button>
152
+ </View>
153
+ </View>
154
+ )}
40
155
 
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
- />
156
+ <View gap="s" inset="s">
157
+ <Title variant="secondary">{t('booking.services.yourServices')}</Title>
158
+ {loading ? (
159
+ <Text color="secondary">{t('booking.services.loading')}</Text>
160
+ ) : services.length === 0 ? (
161
+ <Text color="secondary">{t('booking.services.empty')}</Text>
162
+ ) : (
163
+ <View gap="s">
164
+ {services.map(service => (
165
+ <View key={service.id} gap="xs">
166
+ <ServiceCard
167
+ name={service.name}
168
+ duration={service.duration}
169
+ price={service.price}
170
+ currency={service.currency}
171
+ description={service.description}
172
+ active={service.active !== false}
173
+ />
174
+ <View layout="row" gap="s">
175
+ <Button variant="link" size="s" onClick={() => openEdit(service)}>{t('booking.services.edit')}</Button>
176
+ <Button data-action={DeleteService.id} variant="link" size="s" onClick={() => handleDelete(service.id)}>{t('booking.services.delete')}</Button>
177
+ </View>
178
+ </View>
179
+ ))}
180
+ </View>
181
+ )}
59
182
  </View>
60
183
  </View>
61
- </View>
62
- )
63
-
64
- export default ServicesPage
184
+ )
185
+ }
@@ -0,0 +1,246 @@
1
+ {
2
+ "booking/home.documentTitle": "Bokningskalender – Ossy",
3
+ "booking.home.cover.title": "Låt kunder boka tid direkt",
4
+ "booking.home.cover.text": "Dela en länk. Kunder väljer tid. Du fokuserar på jobbet.",
5
+ "booking.home.cover.ctaPrimary": "Kom igång gratis",
6
+ "booking.home.cover.ctaSecondary": "Se ett exempel",
7
+ "booking.home.features.page.title": "Din bokningssida",
8
+ "booking.home.features.page.text": "En personlig sida på ossy.se/boka/ditt-namn. Dela länken med kunder – det är allt som krävs.",
9
+ "booking.home.features.confirmations.title": "Automatiska bekräftelser",
10
+ "booking.home.features.confirmations.text": "Kunden får en bekräftelse med kalenderinbjudan direkt. Inga manuella steg från din sida.",
11
+ "booking.home.features.availability.title": "Hantera din tid",
12
+ "booking.home.features.availability.text": "Ange när du är tillgänglig. Vi visar bara lediga tider – aldrig dubbelbokade.",
13
+ "booking.home.features.email.title": "E-postpingpong",
14
+ "booking.home.features.email.text": "Slipp fram och tillbaka i flera dagar bara för att hitta ett gemensamt tidslucka.",
15
+ "booking.home.features.doubleBook.title": "Inga dubbelbokningar",
16
+ "booking.home.features.doubleBook.text": "En kalender här, en länk där – vi håller koll så samma tid inte bokas två gånger.",
17
+ "booking.sales.overview": "Översikt",
18
+ "booking.sales.features": "Funktioner",
19
+ "booking/bookings.documentTitle": "Bokningar",
20
+ "booking.bookings.titleSuffix": "Bokningar",
21
+ "booking.bookings.description": "Hantera bokningsförfrågningar och bekräftade möten.",
22
+ "booking.bookings.tab.pending": "Förfrågningar",
23
+ "booking.bookings.tab.confirmed": "Bekräftade",
24
+ "booking.bookings.tab.all": "Alla",
25
+ "booking.bookings.empty.pending.heading": "Inga förfrågningar",
26
+ "booking.bookings.empty.pending.body": "Du har inga väntande bokningsförfrågningar just nu.",
27
+ "booking.bookings.empty.confirmed.heading": "Inga bekräftade bokningar",
28
+ "booking.bookings.empty.confirmed.body": "Bekräftade bokningar visas här.",
29
+ "booking.bookings.empty.all.heading": "Inga bokningar",
30
+ "booking.bookings.empty.all.body": "Bokningar från dina klienter visas här.",
31
+ "booking.bookings.loading": "Laddar…",
32
+ "booking.bookings.errorStatus": "Fel {status}",
33
+ "booking.bookings.errorConfirm": "Bekräftning misslyckades",
34
+ "booking.bookings.errorDecline": "Avböjning misslyckades",
35
+ "booking.bookings.errorCancel": "Avbokning misslyckades",
36
+ "booking/booking-detail.documentTitle": "Bokningsdetaljer",
37
+ "booking.bookingDetail.back": "← Alla bokningar",
38
+ "booking.bookingDetail.loading": "Laddar…",
39
+ "booking.bookingDetail.notFound": "Bokningen hittades inte",
40
+ "booking.bookingDetail.errorStatus": "Fel {status}",
41
+ "booking.bookingDetail.status.pending": "Väntar på bekräftelse",
42
+ "booking.bookingDetail.status.confirmed": "Bekräftad",
43
+ "booking.bookingDetail.status.cancelled": "Avbokad",
44
+ "booking.bookingDetail.label.dateTime": "Datum och tid",
45
+ "booking.bookingDetail.label.duration": "Längd",
46
+ "booking.bookingDetail.label.message": "Meddelande",
47
+ "booking.bookingDetail.durationMinutes": "{minutes} minuter",
48
+ "booking.bookingDetail.durationHours": "{hours} timme",
49
+ "booking.bookingDetail.durationHoursMinutes": "{hours} timme {minutes} min",
50
+ "booking.bookingDetail.manageRequest": "Hantera förfrågan",
51
+ "booking.bookingDetail.manageBooking": "Hantera bokning",
52
+ "booking.bookingDetail.confirm": "Bekräfta bokning",
53
+ "booking.bookingDetail.confirming": "Bekräftar…",
54
+ "booking.bookingDetail.errorConfirm": "Bekräftning misslyckades",
55
+ "booking.bookingDetail.decline": "Avböj förfrågan",
56
+ "booking.bookingDetail.declineTitle": "Avböj bokningsförfrågan",
57
+ "booking.bookingDetail.declinePlaceholder": "T.ex. Tyvärr är den begärda tiden inte längre tillgänglig…",
58
+ "booking.bookingDetail.declineReasonHint": "Anledning (valfritt — skickas till klienten)",
59
+ "booking.bookingDetail.declineConfirm": "Bekräfta avböjning",
60
+ "booking.bookingDetail.declining": "Avböjer…",
61
+ "booking.bookingDetail.errorDecline": "Avböjning misslyckades",
62
+ "booking.bookingDetail.cancelMeeting": "Avboka möte",
63
+ "booking.bookingDetail.cancelTitle": "Avboka bekräftad bokning",
64
+ "booking.bookingDetail.cancelBody": "Klienten meddelas via e-post. Åtgärden kan inte ångras.",
65
+ "booking.bookingDetail.cancelConfirm": "Bekräfta avbokning",
66
+ "booking.bookingDetail.cancelling": "Avbokar…",
67
+ "booking.bookingDetail.errorCancel": "Avbokning misslyckades",
68
+ "booking.bookingDetail.cancelButton": "Avbryt",
69
+ "booking/availability.documentTitle": "Tillgänglighet",
70
+ "booking.availability.titleSuffix": "Tillgänglighet",
71
+ "booking.availability.description": "Ange din veckotillgänglighet. Kunder kan bara boka tider inom dessa fönster.",
72
+ "booking/availability-setup.documentTitle": "Tillgänglighet",
73
+ "booking.availabilitySetup.title": "Tillgänglighet",
74
+ "booking.availabilitySetup.description": "Ange när du är tillgänglig för bokningar. Klienter kan bara boka tider inom dessa fönster.",
75
+ "booking.availabilitySetup.loading": "Laddar…",
76
+ "booking.availabilitySetup.weeklySchedule": "Veckoschema",
77
+ "booking.availabilitySetup.day.monday": "Måndag",
78
+ "booking.availabilitySetup.day.tuesday": "Tisdag",
79
+ "booking.availabilitySetup.day.wednesday": "Onsdag",
80
+ "booking.availabilitySetup.day.thursday": "Torsdag",
81
+ "booking.availabilitySetup.day.friday": "Fredag",
82
+ "booking.availabilitySetup.day.saturday": "Lördag",
83
+ "booking.availabilitySetup.day.sunday": "Söndag",
84
+ "booking.availabilitySetup.unavailable": "Inte tillgänglig",
85
+ "booking.availabilitySetup.sessionDuration": "Sessionslängd",
86
+ "booking.availabilitySetup.sessionDurationHint": "Välj de sessionslängder klienter kan välja bland.",
87
+ "booking.availabilitySetup.durationMin": "{minutes} min",
88
+ "booking.availabilitySetup.bufferLabel": "Bufferttid mellan bokningar",
89
+ "booking.availabilitySetup.bufferNone": "Ingen bufferttid",
90
+ "booking.availabilitySetup.bufferMinutes": "{minutes} minuter",
91
+ "booking.availabilitySetup.bufferHint": "Automatisk paustid som läggs till efter varje bokning.",
92
+ "booking.availabilitySetup.timezone": "Tidszon",
93
+ "booking.availabilitySetup.save": "Spara tillgänglighet",
94
+ "booking.availabilitySetup.saving": "Sparar…",
95
+ "booking.availabilitySetup.errorSave": "Kunde inte spara ({status})",
96
+ "booking.availabilitySetup.savedTitle": "Tillgänglighet sparad",
97
+ "booking.availabilitySetup.savedBody": "Din bokningslänk är redo att delas med klienter.",
98
+ "booking.availabilitySetup.copyLink": "Kopiera länk",
99
+ "booking.availabilitySetup.copied": "Kopierad!",
100
+ "booking/services.documentTitle": "Tjänster",
101
+ "booking.services.titleSuffix": "Tjänster",
102
+ "booking.services.description": "Definiera vad du erbjuder — namn, längd och pris. Kunder ser detta vid bokning.",
103
+ "booking.services.addService": "Lägg till tjänst",
104
+ "booking.services.yourServices": "Dina tjänster",
105
+ "booking.services.example.introCall.name": "Introduktionssamtal",
106
+ "booking.services.example.introCall.description": "Ett gratis 30-minuters intro för att se om vi passar ihop.",
107
+ "booking.services.example.strategy.name": "Strategimöte",
108
+ "booking.services.example.strategy.description": "Fördjupat strategimöte.",
109
+ "booking/book.documentTitle": "Boka ett möte",
110
+ "booking.book.title": "Boka ett möte",
111
+ "booking.book.description": "Välj en tid som passar dig och bekräfta din bokning.",
112
+ "booking.book.form.chooseService": "Välj en tjänst",
113
+ "booking.book.form.loadingServices": "Laddar tjänster för workspace {workspaceId}…",
114
+ "booking.book.form.continuePlaceholder": "Fortsätt (platshållare)",
115
+ "booking.book.form.pickTime": "Välj en tid",
116
+ "booking.book.form.slotsPlaceholder": "Tillgängliga tider visas här.",
117
+ "booking.book.form.back": "Tillbaka",
118
+ "booking.book.form.yourDetails": "Dina uppgifter",
119
+ "booking.book.form.nameLabel": "Namn",
120
+ "booking.book.form.namePlaceholder": "Anna Svensson",
121
+ "booking.book.form.emailLabel": "E-post",
122
+ "booking.book.form.emailPlaceholder": "anna@exempel.se",
123
+ "booking.book.form.confirmPay": "Bekräfta och betala",
124
+ "booking.book.form.confirmed": "Bokning bekräftad!",
125
+ "booking.book.form.checkEmail": "Kolla din e-post för en bekräftelse.",
126
+ "booking.availabilityEditor.save": "Spara tillgänglighet",
127
+ "booking.availabilityEditor.unavailable": "Inte tillgänglig",
128
+ "booking.availabilityEditor.to": "till",
129
+ "booking.availabilityEditor.buffer": "Buffert",
130
+ "booking.availabilityEditor.min": "min",
131
+ "booking.availabilityEditor.day.sun": "Sön",
132
+ "booking.availabilityEditor.day.mon": "Mån",
133
+ "booking.availabilityEditor.day.tue": "Tis",
134
+ "booking.availabilityEditor.day.wed": "Ons",
135
+ "booking.availabilityEditor.day.thu": "Tor",
136
+ "booking.availabilityEditor.day.fri": "Fre",
137
+ "booking.availabilityEditor.day.sat": "Lör",
138
+ "public-booking.documentTitle": "Boka en tid",
139
+ "publicBooking.title": "Boka en tid",
140
+ "publicBooking.description": "Välj ett datum och en tid som passar dig.",
141
+ "publicBooking.loadingSlots": "Laddar tider…",
142
+ "publicBooking.slotsError": "Kunde inte ladda tillgängliga tider.",
143
+ "publicBooking.noSlots": "Inga tillgängliga tider för valt datum.",
144
+ "publicBooking.back": "← Tillbaka",
145
+ "publicBooking.selectedTime": "Vald tid: {datetime}",
146
+ "publicBooking.durationMin": "({minutes} min)",
147
+ "publicBooking.continue": "Fortsätt →",
148
+ "publicBooking.pickDate": "Välj ett markerat datum i kalendern",
149
+ "publicBooking.form.name": "Ditt namn",
150
+ "publicBooking.form.namePlaceholder": "Anna Svensson",
151
+ "publicBooking.form.email": "E-postadress",
152
+ "publicBooking.form.emailPlaceholder": "anna@exempel.se",
153
+ "publicBooking.form.message": "Meddelande (valfritt)",
154
+ "publicBooking.form.messagePlaceholder": "Berätta gärna vad du vill prata om...",
155
+ "publicBooking.form.submit": "Skicka förfrågan",
156
+ "publicBooking.form.submitting": "Skickar…",
157
+ "publicBooking.form.error": "Bokning misslyckades ({status})",
158
+ "publicBooking.pending.title": "Din förfrågan är skickad!",
159
+ "publicBooking.pending.body": "Konsulten bekräftar inom kort. Du får ett mejl med kalenderinbjudan när bokningen är bekräftad.",
160
+ "publicBooking.pending.requestedTime": "Önskad tid",
161
+ "publicBooking.pending.duration": "{minutes} minuter",
162
+ "publicBooking.month.january": "Januari",
163
+ "publicBooking.month.february": "Februari",
164
+ "publicBooking.month.march": "Mars",
165
+ "publicBooking.month.april": "April",
166
+ "publicBooking.month.may": "Maj",
167
+ "publicBooking.month.june": "Juni",
168
+ "publicBooking.month.july": "Juli",
169
+ "publicBooking.month.august": "Augusti",
170
+ "publicBooking.month.september": "September",
171
+ "publicBooking.month.october": "Oktober",
172
+ "publicBooking.month.november": "November",
173
+ "publicBooking.month.december": "December",
174
+ "publicBooking.weekday.sun": "Sön",
175
+ "publicBooking.weekday.mon": "Mån",
176
+ "publicBooking.weekday.tue": "Tis",
177
+ "publicBooking.weekday.wed": "Ons",
178
+ "publicBooking.weekday.thu": "Tor",
179
+ "publicBooking.weekday.fri": "Fre",
180
+ "publicBooking.weekday.sat": "Lör",
181
+ "publicBooking.weekdayLong.sun": "Söndag",
182
+ "publicBooking.weekdayLong.mon": "Måndag",
183
+ "publicBooking.weekdayLong.tue": "Tisdag",
184
+ "publicBooking.weekdayLong.wed": "Onsdag",
185
+ "publicBooking.weekdayLong.thu": "Torsdag",
186
+ "publicBooking.weekdayLong.fri": "Fredag",
187
+ "publicBooking.weekdayLong.sat": "Lördag",
188
+ "booking.card.status.pending": "Väntar",
189
+ "booking.card.status.confirmed": "Bekräftad",
190
+ "booking.card.status.cancelled": "Avbokad",
191
+ "booking.card.view": "Visa →",
192
+ "booking.card.confirm": "Bekräfta",
193
+ "booking.card.decline": "Avböj",
194
+ "booking.card.cancel": "Avboka",
195
+ "booking.card.waiting": "Väntar…",
196
+ "booking.card.errorGeneric": "Något gick fel",
197
+ "booking/create.label": "Skapa bokning",
198
+ "booking/create.description": "Skapa en ny bokningsförfrågan från en klient",
199
+ "booking/get-available-slots.label": "Hämta tillgängliga tider",
200
+ "booking/get-available-slots.description": "Lista bokningsbara tider för en konsult",
201
+ "booking/get-availability.label": "Hämta tillgänglighet",
202
+ "booking/get-availability.description": "Ladda veckotillgänglighet för workspace",
203
+ "booking/save-availability.label": "Spara tillgänglighet",
204
+ "booking/save-availability.description": "Spara veckotillgänglighet och bokningsinställningar",
205
+ "booking/list.label": "Lista bokningar",
206
+ "booking/list.description": "Lista alla bokningar för aktuellt workspace",
207
+ "booking/decline.label": "Avböj bokning",
208
+ "booking/decline.description": "Avböj en väntande bokningsförfrågan",
209
+ "booking/confirm.label": "Bekräfta bokning",
210
+ "booking/confirm.description": "Bekräfta en väntande bokningsförfrågan",
211
+ "booking/cancel.label": "Avboka bokning",
212
+ "booking/cancel.description": "Avboka en bekräftad bokning",
213
+ "booking/get-services.label": "Hämta tjänster",
214
+ "booking/get-services.description": "Lista bokningsbara tjänster för konsult eller workspace",
215
+ "booking/create-service.label": "Skapa tjänst",
216
+ "booking/create-service.description": "Skapa en ny bokningsbar tjänst",
217
+ "booking/update-service.label": "Uppdatera tjänst",
218
+ "booking/update-service.description": "Uppdatera en befintlig bokningsbar tjänst",
219
+ "booking/delete-service.label": "Ta bort tjänst",
220
+ "booking/delete-service.description": "Ta bort en bokningsbar tjänst",
221
+ "booking.services.loading": "Laddar…",
222
+ "booking.services.empty": "Inga tjänster ännu. Lägg till din första tjänst.",
223
+ "booking.services.errorLoad": "Kunde inte ladda tjänster ({status})",
224
+ "booking.services.errorSave": "Kunde inte spara tjänst",
225
+ "booking.services.errorDelete": "Kunde inte ta bort tjänst",
226
+ "booking.services.deleteConfirm": "Ta bort denna tjänst?",
227
+ "booking.services.editService": "Redigera tjänst",
228
+ "booking.services.newService": "Ny tjänst",
229
+ "booking.services.save": "Spara",
230
+ "booking.services.saving": "Sparar…",
231
+ "booking.services.cancel": "Avbryt",
232
+ "booking.services.edit": "Redigera",
233
+ "booking.services.delete": "Ta bort",
234
+ "booking.services.field.name": "Namn",
235
+ "booking.services.field.duration": "Längd (minuter)",
236
+ "booking.services.field.price": "Pris (öre)",
237
+ "booking.services.field.currency": "Valuta",
238
+ "booking.services.field.description": "Beskrivning",
239
+ "booking.services.field.active": "Aktiv (synlig för klienter)",
240
+ "publicBooking.chooseService": "Välj tjänst",
241
+ "publicBooking.loadingServices": "Laddar tjänster…",
242
+ "publicBooking.servicesError": "Kunde inte ladda tjänster.",
243
+ "publicBooking.noServices": "Inga bokningsbara tjänster finns just nu.",
244
+ "publicBooking.serviceFree": "Gratis",
245
+ "publicBooking.serviceMeta": "{duration} min · {price}"
246
+ }
@@ -0,0 +1 @@
1
+ export const metadata = { id: 'booking/update-service', access: 'workspace' }
@@ -0,0 +1,52 @@
1
+ import { Aggregate } from '@ossy/event-store'
2
+ import { Resource, ResourcesEvents } from '@ossy/resources/server'
3
+ import { getBookingResources } from './booking-resources.js'
4
+
5
+ export const metadata = { id: 'booking/update-service' }
6
+
7
+ export async function run ({ payload, req, log }) {
8
+ const workspaceId = req?.workspaceId
9
+ const { serviceId, name, duration, price, currency, description, active } = payload ?? {}
10
+
11
+ if (!workspaceId) {
12
+ throw Object.assign(new Error('workspaceId is required'), { status: 400 })
13
+ }
14
+ if (!serviceId) {
15
+ throw Object.assign(new Error('serviceId is required'), { status: 400 })
16
+ }
17
+
18
+ const existing = await getBookingResources({
19
+ type: '@ossy/booking/service',
20
+ belongsTo: workspaceId,
21
+ })
22
+
23
+ const resource = existing.find(r => r.id === serviceId)
24
+ if (!resource) {
25
+ throw Object.assign(new Error('Service not found'), { status: 404 })
26
+ }
27
+
28
+ const content = {
29
+ ...resource.content,
30
+ ...(name != null ? { name: String(name).trim() } : {}),
31
+ ...(duration != null ? { duration: Number(duration) || 60 } : {}),
32
+ ...(price != null ? { price: Number(price) || 0 } : {}),
33
+ ...(currency != null ? { currency } : {}),
34
+ ...(description != null ? { description: String(description).trim() } : {}),
35
+ ...(active != null ? { active: active !== false } : {}),
36
+ }
37
+
38
+ log?.info(`[booking/update-service] Updating service ${serviceId}`)
39
+
40
+ await Aggregate.Of(Resource, serviceId)
41
+ .then(
42
+ Aggregate.Add(
43
+ ResourcesEvents.ContentUpdated({
44
+ createdBy: req?.userId ?? 'system',
45
+ content,
46
+ }),
47
+ ),
48
+ )
49
+ .then(Aggregate.Save())
50
+
51
+ return { id: serviceId, ...content }
52
+ }