@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.
- package/README.md +26 -19
- package/package.json +7 -7
- package/src/AvailabilityEditor.jsx +22 -15
- package/src/Definition.js +1 -23
- package/src/SalesSection.jsx +9 -4
- package/src/availability-setup.page.jsx +94 -126
- package/src/booking-card.component.jsx +30 -33
- package/src/booking-detail.page.jsx +65 -204
- package/src/booking-resources.js +10 -0
- package/src/bookings.page.jsx +54 -56
- package/src/cancel-booking.action.js +1 -61
- package/src/cancel-booking.task.js +60 -0
- package/src/confirm-booking.action.js +1 -82
- package/src/confirm-booking.task.js +81 -0
- package/src/create-booking.action.js +1 -168
- package/src/create-booking.task.js +170 -0
- package/src/create-service.action.js +1 -0
- package/src/create-service.task.js +46 -0
- package/src/decline-booking.action.js +1 -68
- package/src/decline-booking.task.js +67 -0
- package/src/delete-service.action.js +1 -0
- package/src/delete-service.task.js +41 -0
- package/src/en.translations.json +246 -0
- package/src/get-availability.action.js +1 -52
- package/src/get-availability.task.js +51 -0
- package/src/get-available-slots.action.js +1 -89
- package/src/get-available-slots.task.js +99 -0
- package/src/get-services.action.js +1 -0
- package/src/get-services.task.js +37 -0
- package/src/home.page.jsx +48 -5
- package/src/index.js +20 -1
- package/src/invoke-error-message.js +10 -0
- package/src/list-bookings.action.js +1 -36
- package/src/list-bookings.task.js +35 -0
- package/src/locations.js +14 -0
- package/src/public-booking.page.jsx +200 -245
- package/src/save-availability.action.js +1 -73
- package/src/save-availability.task.js +74 -0
- package/src/send-booking-reminder.task.js +2 -2
- package/src/services.page.jsx +167 -46
- package/src/sv.translations.json +246 -0
- package/src/update-service.action.js +1 -0
- package/src/update-service.task.js +52 -0
- package/src/BookingForm.jsx +0 -82
- package/src/availability.page.jsx +0 -41
- 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 {
|
|
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
|
|
16
|
+
const allBookings = await getBookingResources({
|
|
17
17
|
type: '@ossy/booking/booking',
|
|
18
18
|
})
|
|
19
19
|
|
package/src/services.page.jsx
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import React from 'react'
|
|
2
|
-
import { View, Title, Text, Button,
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
{
|
|
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
|
-
<
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
+
}
|