@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,170 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid'
|
|
2
|
+
import { Aggregate } from '@ossy/event-store'
|
|
3
|
+
import { Resource, ResourcesEvents } from '@ossy/resources/server'
|
|
4
|
+
import { getAvailableSlots } from './availability-engine.js'
|
|
5
|
+
import { getBookingResources } from './booking-resources.js'
|
|
6
|
+
import { bookings } from './locations.js'
|
|
7
|
+
import BookingRequestEmail from './booking-request.email.jsx'
|
|
8
|
+
|
|
9
|
+
export const metadata = { id: 'booking/create' }
|
|
10
|
+
|
|
11
|
+
export async function run({ payload, log, integrations }) {
|
|
12
|
+
const {
|
|
13
|
+
consultantId,
|
|
14
|
+
startAt,
|
|
15
|
+
duration,
|
|
16
|
+
serviceId,
|
|
17
|
+
clientName,
|
|
18
|
+
clientEmail,
|
|
19
|
+
clientMessage,
|
|
20
|
+
consultantName,
|
|
21
|
+
consultantEmail,
|
|
22
|
+
baseUrl,
|
|
23
|
+
confirmUrl,
|
|
24
|
+
declineUrl,
|
|
25
|
+
} = payload ?? {}
|
|
26
|
+
|
|
27
|
+
if (!consultantId) throw Object.assign(new Error('consultantId is required'), { status: 400 })
|
|
28
|
+
if (!startAt) throw Object.assign(new Error('startAt is required'), { status: 400 })
|
|
29
|
+
if (!duration) throw Object.assign(new Error('duration is required'), { status: 400 })
|
|
30
|
+
if (!clientName) throw Object.assign(new Error('clientName is required'), { status: 400 })
|
|
31
|
+
if (!clientEmail) throw Object.assign(new Error('clientEmail is required'), { status: 400 })
|
|
32
|
+
|
|
33
|
+
log?.info(`[booking/create] Creating booking for ${clientEmail} with ${consultantId}`)
|
|
34
|
+
|
|
35
|
+
// Re-validate slot availability
|
|
36
|
+
const availabilityResources = await getBookingResources({
|
|
37
|
+
type: '@ossy/booking/availability',
|
|
38
|
+
belongsTo: consultantId,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// New format: single resource with weeklyWindows array in content
|
|
42
|
+
const configResource = availabilityResources.find(r => Array.isArray(r.content?.weeklyWindows))
|
|
43
|
+
let weeklyWindows, firstResource
|
|
44
|
+
|
|
45
|
+
if (configResource) {
|
|
46
|
+
weeklyWindows = configResource.content.weeklyWindows ?? []
|
|
47
|
+
firstResource = configResource
|
|
48
|
+
} else {
|
|
49
|
+
// Legacy format: one resource per day
|
|
50
|
+
firstResource = availabilityResources[0]
|
|
51
|
+
weeklyWindows = availabilityResources
|
|
52
|
+
.map(r => ({
|
|
53
|
+
dayOfWeek: r.content?.dayOfWeek,
|
|
54
|
+
startTime: r.content?.startTime ?? '09:00',
|
|
55
|
+
endTime: r.content?.endTime ?? '17:00',
|
|
56
|
+
}))
|
|
57
|
+
.filter(w => w.dayOfWeek != null)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const availability = {
|
|
61
|
+
weeklyWindows,
|
|
62
|
+
bufferMinutes: firstResource?.content?.bufferMinutes ?? 0,
|
|
63
|
+
blackoutDates: firstResource?.content?.blackoutDates ?? [],
|
|
64
|
+
timezone: firstResource?.content?.timezone ?? 'Europe/Stockholm',
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const allBookingResources = await getBookingResources({
|
|
68
|
+
type: '@ossy/booking/booking',
|
|
69
|
+
belongsTo: consultantId,
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const confirmedBookings = allBookingResources
|
|
73
|
+
.filter(b => b.content?.status === 'confirmed')
|
|
74
|
+
.map(b => ({
|
|
75
|
+
startAt: b.content.startsAt,
|
|
76
|
+
endAt: b.content.endsAt,
|
|
77
|
+
status: 'confirmed',
|
|
78
|
+
}))
|
|
79
|
+
|
|
80
|
+
const startDate = new Date(startAt)
|
|
81
|
+
const endAt = new Date(startDate.getTime() + duration * 60 * 1000).toISOString()
|
|
82
|
+
|
|
83
|
+
// Validate the requested slot is still open
|
|
84
|
+
const openSlots = getAvailableSlots({
|
|
85
|
+
availability,
|
|
86
|
+
bookings: confirmedBookings,
|
|
87
|
+
fromDate: startDate,
|
|
88
|
+
toDate: startDate,
|
|
89
|
+
duration,
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const isAvailable = openSlots.some(s => s.startAt === startDate.toISOString())
|
|
93
|
+
if (!isAvailable) {
|
|
94
|
+
throw Object.assign(new Error('The requested slot is no longer available'), { status: 409 })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Create the booking resource
|
|
98
|
+
const bookingId = nanoid()
|
|
99
|
+
const event = ResourcesEvents.Created({
|
|
100
|
+
aggregateId: bookingId,
|
|
101
|
+
type: '@ossy/booking/booking',
|
|
102
|
+
createdBy: 'public',
|
|
103
|
+
belongsTo: consultantId,
|
|
104
|
+
location: bookings,
|
|
105
|
+
name: `booking-${bookingId}.json`,
|
|
106
|
+
content: {
|
|
107
|
+
serviceId: serviceId ?? null,
|
|
108
|
+
clientName,
|
|
109
|
+
clientEmail,
|
|
110
|
+
clientMessage: clientMessage ?? '',
|
|
111
|
+
consultantId,
|
|
112
|
+
startsAt: startDate.toISOString(),
|
|
113
|
+
endsAt: endAt,
|
|
114
|
+
duration,
|
|
115
|
+
status: 'pending',
|
|
116
|
+
paymentStatus: 'unpaid',
|
|
117
|
+
},
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const booking = await Aggregate.Of(Resource, event).then(Aggregate.View())
|
|
121
|
+
|
|
122
|
+
log?.info(`[booking/create] Booking ${bookingId} created`)
|
|
123
|
+
|
|
124
|
+
const emailClient = integrations?.get?.('email')
|
|
125
|
+
if (emailClient) {
|
|
126
|
+
// Notify the consultant of the new booking request
|
|
127
|
+
if (consultantEmail) {
|
|
128
|
+
try {
|
|
129
|
+
await emailClient.sendTemplate(
|
|
130
|
+
BookingRequestEmail,
|
|
131
|
+
{
|
|
132
|
+
consultantName: consultantName ?? 'Hej',
|
|
133
|
+
clientName,
|
|
134
|
+
clientEmail,
|
|
135
|
+
startAt: startDate.toISOString(),
|
|
136
|
+
duration,
|
|
137
|
+
message: clientMessage ?? '',
|
|
138
|
+
confirmUrl: confirmUrl ?? `${baseUrl ?? ''}/actions/booking/confirm?bookingId=${bookingId}`,
|
|
139
|
+
declineUrl: declineUrl ?? `${baseUrl ?? ''}/actions/booking/decline?bookingId=${bookingId}`,
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
to: consultantEmail,
|
|
143
|
+
from: 'noreply@ossy.se',
|
|
144
|
+
subject: 'Ny bokningsförfrågan',
|
|
145
|
+
},
|
|
146
|
+
)
|
|
147
|
+
log?.info(`[booking/create] Booking request email sent to consultant ${consultantEmail}`)
|
|
148
|
+
} catch (err) {
|
|
149
|
+
log?.error('[booking/create] Failed to send booking request email to consultant', err)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Send a simple receipt to the client — no .ics yet (sent after consultant confirms)
|
|
154
|
+
try {
|
|
155
|
+
await emailClient.send(
|
|
156
|
+
{
|
|
157
|
+
to: clientEmail,
|
|
158
|
+
from: 'noreply@ossy.se',
|
|
159
|
+
subject: 'Vi har tagit emot din förfrågan',
|
|
160
|
+
text: `Hej ${clientName},\n\nVi har tagit emot din bokningsförfrågan. Konsulten bekräftar inom kort och du får ett nytt mejl med kalenderinbjudan när bokningen är bekräftad.\n\nMvh\nOssy`,
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
log?.info(`[booking/create] Receipt email sent to client ${clientEmail}`)
|
|
164
|
+
} catch (err) {
|
|
165
|
+
log?.error('[booking/create] Failed to send receipt email to client', err)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return booking
|
|
170
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const metadata = { id: 'booking/create-service', access: 'workspace' }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid'
|
|
2
|
+
import { Aggregate } from '@ossy/event-store'
|
|
3
|
+
import { Resource, ResourcesEvents } from '@ossy/resources/server'
|
|
4
|
+
import { services } from './locations.js'
|
|
5
|
+
|
|
6
|
+
export const metadata = { id: 'booking/create-service' }
|
|
7
|
+
|
|
8
|
+
export async function run ({ payload, req, log }) {
|
|
9
|
+
const workspaceId = req?.workspaceId
|
|
10
|
+
|
|
11
|
+
if (!workspaceId) {
|
|
12
|
+
throw Object.assign(new Error('workspaceId is required'), { status: 400 })
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { name, duration, price, currency, description, active } = payload ?? {}
|
|
16
|
+
|
|
17
|
+
if (!name?.trim()) {
|
|
18
|
+
throw Object.assign(new Error('name is required'), { status: 400 })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const resourceId = nanoid()
|
|
22
|
+
const content = {
|
|
23
|
+
name: name.trim(),
|
|
24
|
+
duration: Number(duration) || 60,
|
|
25
|
+
price: Number(price) || 0,
|
|
26
|
+
currency: currency ?? 'SEK',
|
|
27
|
+
description: description?.trim() ?? '',
|
|
28
|
+
active: active !== false,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
log?.info(`[booking/create-service] Creating service ${resourceId} for workspace ${workspaceId}`)
|
|
32
|
+
|
|
33
|
+
const event = ResourcesEvents.Created({
|
|
34
|
+
aggregateId: resourceId,
|
|
35
|
+
type: '@ossy/booking/service',
|
|
36
|
+
createdBy: req?.userId ?? 'system',
|
|
37
|
+
belongsTo: workspaceId,
|
|
38
|
+
location: services,
|
|
39
|
+
name: `${content.name.replace(/\s+/g, '-').toLowerCase()}.json`,
|
|
40
|
+
content,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const service = await Aggregate.Of(Resource, event).then(Aggregate.View())
|
|
44
|
+
|
|
45
|
+
return { id: resourceId, ...content, ...service }
|
|
46
|
+
}
|
|
@@ -1,68 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { Resource, ResourcesEvents } from '@ossy/resources'
|
|
3
|
-
import BookingCancellationEmail from './booking-cancellation.email.jsx'
|
|
4
|
-
|
|
5
|
-
export const id = 'booking/decline'
|
|
6
|
-
export const access = 'workspace'
|
|
7
|
-
|
|
8
|
-
export async function run({ payload, req, log, integrations }) {
|
|
9
|
-
const bookingId = payload?.bookingId ?? req?.params?.bookingId
|
|
10
|
-
const workspaceId = payload?.workspaceId ?? req?.workspaceId
|
|
11
|
-
const reason = payload?.reason ?? null
|
|
12
|
-
|
|
13
|
-
if (!bookingId) throw Object.assign(new Error('bookingId is required'), { status: 400 })
|
|
14
|
-
|
|
15
|
-
log?.info(`[booking/decline] Declining booking ${bookingId}`)
|
|
16
|
-
|
|
17
|
-
const booking = await Aggregate.Of(Resource, bookingId).then(Aggregate.View())
|
|
18
|
-
if (!booking?.id) throw Object.assign(new Error('Booking not found'), { status: 404 })
|
|
19
|
-
|
|
20
|
-
if (booking.belongsTo !== workspaceId) {
|
|
21
|
-
throw Object.assign(new Error('You do not have permission to decline this booking'), { status: 403 })
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
if (booking.content?.status === 'cancelled') {
|
|
25
|
-
throw Object.assign(new Error('Booking is already cancelled'), { status: 409 })
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
if (booking.content?.status === 'confirmed') {
|
|
29
|
-
throw Object.assign(new Error('Use cancel-booking to cancel an already confirmed booking'), { status: 409 })
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
await Aggregate.Of(Resource, bookingId)
|
|
33
|
-
.then(Aggregate.Add(
|
|
34
|
-
ResourcesEvents.ContentUpdated({
|
|
35
|
-
createdBy: req?.userId ?? 'system',
|
|
36
|
-
content: { ...booking.content, status: 'cancelled', declineReason: reason ?? '' },
|
|
37
|
-
}),
|
|
38
|
-
))
|
|
39
|
-
.then(Aggregate.Save())
|
|
40
|
-
|
|
41
|
-
log?.info(`[booking/decline] Booking ${bookingId} declined`)
|
|
42
|
-
|
|
43
|
-
// Send decline email to client
|
|
44
|
-
const emailClient = integrations?.get?.('email')
|
|
45
|
-
if (emailClient && booking.content?.clientEmail) {
|
|
46
|
-
try {
|
|
47
|
-
await emailClient.sendTemplate(
|
|
48
|
-
BookingCancellationEmail,
|
|
49
|
-
{
|
|
50
|
-
clientName: booking.content.clientName,
|
|
51
|
-
startAt: booking.content.startsAt,
|
|
52
|
-
duration: booking.content.duration,
|
|
53
|
-
reason,
|
|
54
|
-
},
|
|
55
|
-
{
|
|
56
|
-
to: booking.content.clientEmail,
|
|
57
|
-
from: 'noreply@ossy.se',
|
|
58
|
-
subject: 'Din bokningsförfrågan avböjdes',
|
|
59
|
-
},
|
|
60
|
-
)
|
|
61
|
-
log?.info(`[booking/decline] Decline email sent to ${booking.content.clientEmail}`)
|
|
62
|
-
} catch (err) {
|
|
63
|
-
log?.error('[booking/decline] Failed to send decline email to client', err)
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return { id: bookingId, status: 'cancelled' }
|
|
68
|
-
}
|
|
1
|
+
export const metadata = { id: 'booking/decline', access: 'workspace' }
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Aggregate } from '@ossy/event-store'
|
|
2
|
+
import { Resource, ResourcesEvents } from '@ossy/resources/server'
|
|
3
|
+
import BookingCancellationEmail from './booking-cancellation.email.jsx'
|
|
4
|
+
|
|
5
|
+
export const metadata = { id: 'booking/decline' }
|
|
6
|
+
|
|
7
|
+
export async function run({ payload, req, log, integrations }) {
|
|
8
|
+
const bookingId = payload?.bookingId ?? req?.params?.bookingId
|
|
9
|
+
const workspaceId = payload?.workspaceId ?? req?.workspaceId
|
|
10
|
+
const reason = payload?.reason ?? null
|
|
11
|
+
|
|
12
|
+
if (!bookingId) throw Object.assign(new Error('bookingId is required'), { status: 400 })
|
|
13
|
+
|
|
14
|
+
log?.info(`[booking/decline] Declining booking ${bookingId}`)
|
|
15
|
+
|
|
16
|
+
const booking = await Aggregate.Of(Resource, bookingId).then(Aggregate.View())
|
|
17
|
+
if (!booking?.id) throw Object.assign(new Error('Booking not found'), { status: 404 })
|
|
18
|
+
|
|
19
|
+
if (booking.belongsTo !== workspaceId) {
|
|
20
|
+
throw Object.assign(new Error('You do not have permission to decline this booking'), { status: 403 })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (booking.content?.status === 'cancelled') {
|
|
24
|
+
throw Object.assign(new Error('Booking is already cancelled'), { status: 409 })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (booking.content?.status === 'confirmed') {
|
|
28
|
+
throw Object.assign(new Error('Use cancel-booking to cancel an already confirmed booking'), { status: 409 })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
await Aggregate.Of(Resource, bookingId)
|
|
32
|
+
.then(Aggregate.Add(
|
|
33
|
+
ResourcesEvents.ContentUpdated({
|
|
34
|
+
createdBy: req?.userId ?? 'system',
|
|
35
|
+
content: { ...booking.content, status: 'cancelled', declineReason: reason ?? '' },
|
|
36
|
+
}),
|
|
37
|
+
))
|
|
38
|
+
.then(Aggregate.Save())
|
|
39
|
+
|
|
40
|
+
log?.info(`[booking/decline] Booking ${bookingId} declined`)
|
|
41
|
+
|
|
42
|
+
// Send decline email to client
|
|
43
|
+
const emailClient = integrations?.get?.('email')
|
|
44
|
+
if (emailClient && booking.content?.clientEmail) {
|
|
45
|
+
try {
|
|
46
|
+
await emailClient.sendTemplate(
|
|
47
|
+
BookingCancellationEmail,
|
|
48
|
+
{
|
|
49
|
+
clientName: booking.content.clientName,
|
|
50
|
+
startAt: booking.content.startsAt,
|
|
51
|
+
duration: booking.content.duration,
|
|
52
|
+
reason,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
to: booking.content.clientEmail,
|
|
56
|
+
from: 'noreply@ossy.se',
|
|
57
|
+
subject: 'Din bokningsförfrågan avböjdes',
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
log?.info(`[booking/decline] Decline email sent to ${booking.content.clientEmail}`)
|
|
61
|
+
} catch (err) {
|
|
62
|
+
log?.error('[booking/decline] Failed to send decline email to client', err)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { id: bookingId, status: 'cancelled' }
|
|
67
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const metadata = { id: 'booking/delete-service', access: 'workspace' }
|
|
@@ -0,0 +1,41 @@
|
|
|
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/delete-service' }
|
|
6
|
+
|
|
7
|
+
export async function run ({ payload, req, log }) {
|
|
8
|
+
const workspaceId = req?.workspaceId
|
|
9
|
+
const { serviceId } = 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
|
+
log?.info(`[booking/delete-service] Deleting service ${serviceId}`)
|
|
29
|
+
|
|
30
|
+
await Aggregate.Of(Resource, serviceId)
|
|
31
|
+
.then(
|
|
32
|
+
Aggregate.Add(
|
|
33
|
+
ResourcesEvents.Deleted({
|
|
34
|
+
createdBy: req?.userId ?? 'system',
|
|
35
|
+
}),
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
.then(Aggregate.Save())
|
|
39
|
+
|
|
40
|
+
return { id: serviceId, deleted: true }
|
|
41
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
{
|
|
2
|
+
"booking/home.documentTitle": "Booking calendar – Ossy",
|
|
3
|
+
"booking.home.cover.title": "Let clients book time directly",
|
|
4
|
+
"booking.home.cover.text": "Share a link. Clients pick a slot. You focus on the work.",
|
|
5
|
+
"booking.home.cover.ctaPrimary": "Get started free",
|
|
6
|
+
"booking.home.cover.ctaSecondary": "See an example",
|
|
7
|
+
"booking.home.features.page.title": "Your booking page",
|
|
8
|
+
"booking.home.features.page.text": "A personal page at ossy.se/booking/your-name. Share the link with clients — that's all it takes.",
|
|
9
|
+
"booking.home.features.confirmations.title": "Automatic confirmations",
|
|
10
|
+
"booking.home.features.confirmations.text": "Clients get a confirmation with a calendar invite instantly. No manual steps on your side.",
|
|
11
|
+
"booking.home.features.availability.title": "Manage your time",
|
|
12
|
+
"booking.home.features.availability.text": "Set when you're available. We only show open slots — never double-booked.",
|
|
13
|
+
"booking.home.features.email.title": "Email ping-pong",
|
|
14
|
+
"booking.home.features.email.text": "Skip days of back-and-forth just to find a shared slot.",
|
|
15
|
+
"booking.home.features.doubleBook.title": "No double bookings",
|
|
16
|
+
"booking.home.features.doubleBook.text": "One calendar here, one link there — we keep track so the same slot isn't booked twice.",
|
|
17
|
+
"booking.sales.overview": "Overview",
|
|
18
|
+
"booking.sales.features": "Features",
|
|
19
|
+
"booking/bookings.documentTitle": "Bookings",
|
|
20
|
+
"booking.bookings.titleSuffix": "Bookings",
|
|
21
|
+
"booking.bookings.description": "Manage booking requests and confirmed meetings.",
|
|
22
|
+
"booking.bookings.tab.pending": "Requests",
|
|
23
|
+
"booking.bookings.tab.confirmed": "Confirmed",
|
|
24
|
+
"booking.bookings.tab.all": "All",
|
|
25
|
+
"booking.bookings.empty.pending.heading": "No requests",
|
|
26
|
+
"booking.bookings.empty.pending.body": "You have no pending booking requests right now.",
|
|
27
|
+
"booking.bookings.empty.confirmed.heading": "No confirmed bookings",
|
|
28
|
+
"booking.bookings.empty.confirmed.body": "Confirmed bookings appear here.",
|
|
29
|
+
"booking.bookings.empty.all.heading": "No bookings",
|
|
30
|
+
"booking.bookings.empty.all.body": "Bookings from your clients appear here.",
|
|
31
|
+
"booking.bookings.loading": "Loading…",
|
|
32
|
+
"booking.bookings.errorStatus": "Error {status}",
|
|
33
|
+
"booking.bookings.errorConfirm": "Confirmation failed",
|
|
34
|
+
"booking.bookings.errorDecline": "Decline failed",
|
|
35
|
+
"booking.bookings.errorCancel": "Cancellation failed",
|
|
36
|
+
"booking/booking-detail.documentTitle": "Booking details",
|
|
37
|
+
"booking.bookingDetail.back": "← All bookings",
|
|
38
|
+
"booking.bookingDetail.loading": "Loading…",
|
|
39
|
+
"booking.bookingDetail.notFound": "Booking not found",
|
|
40
|
+
"booking.bookingDetail.errorStatus": "Error {status}",
|
|
41
|
+
"booking.bookingDetail.status.pending": "Awaiting confirmation",
|
|
42
|
+
"booking.bookingDetail.status.confirmed": "Confirmed",
|
|
43
|
+
"booking.bookingDetail.status.cancelled": "Cancelled",
|
|
44
|
+
"booking.bookingDetail.label.dateTime": "Date and time",
|
|
45
|
+
"booking.bookingDetail.label.duration": "Duration",
|
|
46
|
+
"booking.bookingDetail.label.message": "Message",
|
|
47
|
+
"booking.bookingDetail.durationMinutes": "{minutes} minutes",
|
|
48
|
+
"booking.bookingDetail.durationHours": "{hours} hour",
|
|
49
|
+
"booking.bookingDetail.durationHoursMinutes": "{hours} hour {minutes} min",
|
|
50
|
+
"booking.bookingDetail.manageRequest": "Manage request",
|
|
51
|
+
"booking.bookingDetail.manageBooking": "Manage booking",
|
|
52
|
+
"booking.bookingDetail.confirm": "Confirm booking",
|
|
53
|
+
"booking.bookingDetail.confirming": "Confirming…",
|
|
54
|
+
"booking.bookingDetail.errorConfirm": "Confirmation failed",
|
|
55
|
+
"booking.bookingDetail.decline": "Decline request",
|
|
56
|
+
"booking.bookingDetail.declineTitle": "Decline booking request",
|
|
57
|
+
"booking.bookingDetail.declinePlaceholder": "E.g. Unfortunately the requested time is no longer available…",
|
|
58
|
+
"booking.bookingDetail.declineReasonHint": "Reason (optional — sent to the client)",
|
|
59
|
+
"booking.bookingDetail.declineConfirm": "Confirm decline",
|
|
60
|
+
"booking.bookingDetail.declining": "Declining…",
|
|
61
|
+
"booking.bookingDetail.errorDecline": "Decline failed",
|
|
62
|
+
"booking.bookingDetail.cancelMeeting": "Cancel meeting",
|
|
63
|
+
"booking.bookingDetail.cancelTitle": "Cancel confirmed booking",
|
|
64
|
+
"booking.bookingDetail.cancelBody": "The client will be notified by email. This action cannot be undone.",
|
|
65
|
+
"booking.bookingDetail.cancelConfirm": "Confirm cancellation",
|
|
66
|
+
"booking.bookingDetail.cancelling": "Cancelling…",
|
|
67
|
+
"booking.bookingDetail.errorCancel": "Cancellation failed",
|
|
68
|
+
"booking.bookingDetail.cancelButton": "Cancel",
|
|
69
|
+
"booking/availability.documentTitle": "Availability",
|
|
70
|
+
"booking.availability.titleSuffix": "Availability",
|
|
71
|
+
"booking.availability.description": "Set your weekly availability. Clients can only book slots within these windows.",
|
|
72
|
+
"booking/availability-setup.documentTitle": "Availability",
|
|
73
|
+
"booking.availabilitySetup.title": "Availability",
|
|
74
|
+
"booking.availabilitySetup.description": "Set when you are available for bookings. Clients can only book times within these windows.",
|
|
75
|
+
"booking.availabilitySetup.loading": "Loading…",
|
|
76
|
+
"booking.availabilitySetup.weeklySchedule": "Weekly schedule",
|
|
77
|
+
"booking.availabilitySetup.day.monday": "Monday",
|
|
78
|
+
"booking.availabilitySetup.day.tuesday": "Tuesday",
|
|
79
|
+
"booking.availabilitySetup.day.wednesday": "Wednesday",
|
|
80
|
+
"booking.availabilitySetup.day.thursday": "Thursday",
|
|
81
|
+
"booking.availabilitySetup.day.friday": "Friday",
|
|
82
|
+
"booking.availabilitySetup.day.saturday": "Saturday",
|
|
83
|
+
"booking.availabilitySetup.day.sunday": "Sunday",
|
|
84
|
+
"booking.availabilitySetup.unavailable": "Unavailable",
|
|
85
|
+
"booking.availabilitySetup.sessionDuration": "Session length",
|
|
86
|
+
"booking.availabilitySetup.sessionDurationHint": "Choose session lengths clients can select.",
|
|
87
|
+
"booking.availabilitySetup.durationMin": "{minutes} min",
|
|
88
|
+
"booking.availabilitySetup.bufferLabel": "Buffer time between bookings",
|
|
89
|
+
"booking.availabilitySetup.bufferNone": "No buffer",
|
|
90
|
+
"booking.availabilitySetup.bufferMinutes": "{minutes} minutes",
|
|
91
|
+
"booking.availabilitySetup.bufferHint": "Automatic pause added after each booking.",
|
|
92
|
+
"booking.availabilitySetup.timezone": "Time zone",
|
|
93
|
+
"booking.availabilitySetup.save": "Save availability",
|
|
94
|
+
"booking.availabilitySetup.saving": "Saving…",
|
|
95
|
+
"booking.availabilitySetup.errorSave": "Could not save ({status})",
|
|
96
|
+
"booking.availabilitySetup.savedTitle": "Availability saved",
|
|
97
|
+
"booking.availabilitySetup.savedBody": "Your booking link is ready to share with clients.",
|
|
98
|
+
"booking.availabilitySetup.copyLink": "Copy link",
|
|
99
|
+
"booking.availabilitySetup.copied": "Copied!",
|
|
100
|
+
"booking/services.documentTitle": "Services",
|
|
101
|
+
"booking.services.titleSuffix": "Services",
|
|
102
|
+
"booking.services.description": "Define what you offer — name, duration, and price. Clients will see these when booking.",
|
|
103
|
+
"booking.services.addService": "Add service",
|
|
104
|
+
"booking.services.yourServices": "Your services",
|
|
105
|
+
"booking.services.example.introCall.name": "Intro call",
|
|
106
|
+
"booking.services.example.introCall.description": "A free 30-minute intro to see if we're a good fit.",
|
|
107
|
+
"booking.services.example.strategy.name": "Strategy session",
|
|
108
|
+
"booking.services.example.strategy.description": "Deep-dive strategy session.",
|
|
109
|
+
"booking/book.documentTitle": "Book a session",
|
|
110
|
+
"booking.book.title": "Book a session",
|
|
111
|
+
"booking.book.description": "Pick a time that works for you and confirm your appointment.",
|
|
112
|
+
"booking.book.form.chooseService": "Choose a service",
|
|
113
|
+
"booking.book.form.loadingServices": "Loading services for workspace {workspaceId}…",
|
|
114
|
+
"booking.book.form.continuePlaceholder": "Continue (placeholder)",
|
|
115
|
+
"booking.book.form.pickTime": "Pick a time",
|
|
116
|
+
"booking.book.form.slotsPlaceholder": "Available slots will appear here.",
|
|
117
|
+
"booking.book.form.back": "Back",
|
|
118
|
+
"booking.book.form.yourDetails": "Your details",
|
|
119
|
+
"booking.book.form.nameLabel": "Name",
|
|
120
|
+
"booking.book.form.namePlaceholder": "Jane Smith",
|
|
121
|
+
"booking.book.form.emailLabel": "Email",
|
|
122
|
+
"booking.book.form.emailPlaceholder": "jane@example.com",
|
|
123
|
+
"booking.book.form.confirmPay": "Confirm & Pay",
|
|
124
|
+
"booking.book.form.confirmed": "Booking confirmed!",
|
|
125
|
+
"booking.book.form.checkEmail": "Check your email for a confirmation.",
|
|
126
|
+
"booking.availabilityEditor.save": "Save availability",
|
|
127
|
+
"booking.availabilityEditor.unavailable": "Unavailable",
|
|
128
|
+
"booking.availabilityEditor.to": "to",
|
|
129
|
+
"booking.availabilityEditor.buffer": "Buffer",
|
|
130
|
+
"booking.availabilityEditor.min": "min",
|
|
131
|
+
"booking.availabilityEditor.day.sun": "Sun",
|
|
132
|
+
"booking.availabilityEditor.day.mon": "Mon",
|
|
133
|
+
"booking.availabilityEditor.day.tue": "Tue",
|
|
134
|
+
"booking.availabilityEditor.day.wed": "Wed",
|
|
135
|
+
"booking.availabilityEditor.day.thu": "Thu",
|
|
136
|
+
"booking.availabilityEditor.day.fri": "Fri",
|
|
137
|
+
"booking.availabilityEditor.day.sat": "Sat",
|
|
138
|
+
"public-booking.documentTitle": "Book an appointment",
|
|
139
|
+
"publicBooking.title": "Book an appointment",
|
|
140
|
+
"publicBooking.description": "Choose a date and time that works for you.",
|
|
141
|
+
"publicBooking.loadingSlots": "Loading times…",
|
|
142
|
+
"publicBooking.slotsError": "Could not load available times.",
|
|
143
|
+
"publicBooking.noSlots": "No available times for the selected date.",
|
|
144
|
+
"publicBooking.back": "← Back",
|
|
145
|
+
"publicBooking.selectedTime": "Selected time: {datetime}",
|
|
146
|
+
"publicBooking.durationMin": "({minutes} min)",
|
|
147
|
+
"publicBooking.continue": "Continue →",
|
|
148
|
+
"publicBooking.pickDate": "Select a highlighted date in the calendar",
|
|
149
|
+
"publicBooking.form.name": "Your name",
|
|
150
|
+
"publicBooking.form.namePlaceholder": "Anna Svensson",
|
|
151
|
+
"publicBooking.form.email": "Email address",
|
|
152
|
+
"publicBooking.form.emailPlaceholder": "anna@example.com",
|
|
153
|
+
"publicBooking.form.message": "Message (optional)",
|
|
154
|
+
"publicBooking.form.messagePlaceholder": "Tell us what you'd like to discuss…",
|
|
155
|
+
"publicBooking.form.submit": "Send request",
|
|
156
|
+
"publicBooking.form.submitting": "Sending…",
|
|
157
|
+
"publicBooking.form.error": "Booking failed ({status})",
|
|
158
|
+
"publicBooking.pending.title": "Your request has been sent!",
|
|
159
|
+
"publicBooking.pending.body": "The consultant will confirm shortly. You will receive an email with a calendar invite once the booking is confirmed.",
|
|
160
|
+
"publicBooking.pending.requestedTime": "Requested time",
|
|
161
|
+
"publicBooking.pending.duration": "{minutes} minutes",
|
|
162
|
+
"publicBooking.month.january": "January",
|
|
163
|
+
"publicBooking.month.february": "February",
|
|
164
|
+
"publicBooking.month.march": "March",
|
|
165
|
+
"publicBooking.month.april": "April",
|
|
166
|
+
"publicBooking.month.may": "May",
|
|
167
|
+
"publicBooking.month.june": "June",
|
|
168
|
+
"publicBooking.month.july": "July",
|
|
169
|
+
"publicBooking.month.august": "August",
|
|
170
|
+
"publicBooking.month.september": "September",
|
|
171
|
+
"publicBooking.month.october": "October",
|
|
172
|
+
"publicBooking.month.november": "November",
|
|
173
|
+
"publicBooking.month.december": "December",
|
|
174
|
+
"publicBooking.weekday.sun": "Sun",
|
|
175
|
+
"publicBooking.weekday.mon": "Mon",
|
|
176
|
+
"publicBooking.weekday.tue": "Tue",
|
|
177
|
+
"publicBooking.weekday.wed": "Wed",
|
|
178
|
+
"publicBooking.weekday.thu": "Thu",
|
|
179
|
+
"publicBooking.weekday.fri": "Fri",
|
|
180
|
+
"publicBooking.weekday.sat": "Sat",
|
|
181
|
+
"publicBooking.weekdayLong.sun": "Sunday",
|
|
182
|
+
"publicBooking.weekdayLong.mon": "Monday",
|
|
183
|
+
"publicBooking.weekdayLong.tue": "Tuesday",
|
|
184
|
+
"publicBooking.weekdayLong.wed": "Wednesday",
|
|
185
|
+
"publicBooking.weekdayLong.thu": "Thursday",
|
|
186
|
+
"publicBooking.weekdayLong.fri": "Friday",
|
|
187
|
+
"publicBooking.weekdayLong.sat": "Saturday",
|
|
188
|
+
"booking.card.status.pending": "Pending",
|
|
189
|
+
"booking.card.status.confirmed": "Confirmed",
|
|
190
|
+
"booking.card.status.cancelled": "Cancelled",
|
|
191
|
+
"booking.card.view": "View →",
|
|
192
|
+
"booking.card.confirm": "Confirm",
|
|
193
|
+
"booking.card.decline": "Decline",
|
|
194
|
+
"booking.card.cancel": "Cancel",
|
|
195
|
+
"booking.card.waiting": "Waiting…",
|
|
196
|
+
"booking.card.errorGeneric": "Something went wrong",
|
|
197
|
+
"booking/create.label": "Create booking",
|
|
198
|
+
"booking/create.description": "Create a new booking request from a client",
|
|
199
|
+
"booking/get-available-slots.label": "Get available slots",
|
|
200
|
+
"booking/get-available-slots.description": "List bookable time slots for a consultant",
|
|
201
|
+
"booking/get-availability.label": "Get availability",
|
|
202
|
+
"booking/get-availability.description": "Load weekly availability settings for the workspace",
|
|
203
|
+
"booking/save-availability.label": "Save availability",
|
|
204
|
+
"booking/save-availability.description": "Save weekly availability and booking preferences",
|
|
205
|
+
"booking/list.label": "List bookings",
|
|
206
|
+
"booking/list.description": "List all bookings for the current workspace",
|
|
207
|
+
"booking/decline.label": "Decline booking",
|
|
208
|
+
"booking/decline.description": "Decline a pending booking request",
|
|
209
|
+
"booking/confirm.label": "Confirm booking",
|
|
210
|
+
"booking/confirm.description": "Confirm a pending booking request",
|
|
211
|
+
"booking/cancel.label": "Cancel booking",
|
|
212
|
+
"booking/cancel.description": "Cancel a confirmed booking",
|
|
213
|
+
"booking/get-services.label": "Get services",
|
|
214
|
+
"booking/get-services.description": "List bookable services for a consultant or workspace",
|
|
215
|
+
"booking/create-service.label": "Create service",
|
|
216
|
+
"booking/create-service.description": "Create a new bookable service",
|
|
217
|
+
"booking/update-service.label": "Update service",
|
|
218
|
+
"booking/update-service.description": "Update an existing bookable service",
|
|
219
|
+
"booking/delete-service.label": "Delete service",
|
|
220
|
+
"booking/delete-service.description": "Remove a bookable service",
|
|
221
|
+
"booking.services.loading": "Loading…",
|
|
222
|
+
"booking.services.empty": "No services yet. Add your first service to get started.",
|
|
223
|
+
"booking.services.errorLoad": "Could not load services ({status})",
|
|
224
|
+
"booking.services.errorSave": "Could not save service",
|
|
225
|
+
"booking.services.errorDelete": "Could not delete service",
|
|
226
|
+
"booking.services.deleteConfirm": "Delete this service?",
|
|
227
|
+
"booking.services.editService": "Edit service",
|
|
228
|
+
"booking.services.newService": "New service",
|
|
229
|
+
"booking.services.save": "Save",
|
|
230
|
+
"booking.services.saving": "Saving…",
|
|
231
|
+
"booking.services.cancel": "Cancel",
|
|
232
|
+
"booking.services.edit": "Edit",
|
|
233
|
+
"booking.services.delete": "Delete",
|
|
234
|
+
"booking.services.field.name": "Name",
|
|
235
|
+
"booking.services.field.duration": "Duration (minutes)",
|
|
236
|
+
"booking.services.field.price": "Price (öre/cents)",
|
|
237
|
+
"booking.services.field.currency": "Currency",
|
|
238
|
+
"booking.services.field.description": "Description",
|
|
239
|
+
"booking.services.field.active": "Active (visible to clients)",
|
|
240
|
+
"publicBooking.chooseService": "Choose a service",
|
|
241
|
+
"publicBooking.loadingServices": "Loading services…",
|
|
242
|
+
"publicBooking.servicesError": "Could not load services.",
|
|
243
|
+
"publicBooking.noServices": "No bookable services are available right now.",
|
|
244
|
+
"publicBooking.serviceFree": "Free",
|
|
245
|
+
"publicBooking.serviceMeta": "{duration} min · {price}"
|
|
246
|
+
}
|