@ossy/booking 1.13.1 → 1.13.2
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/package.json +9 -2
- package/src/availability-engine.js +118 -0
- package/src/availability-engine.spec.js +291 -0
- package/src/availability.resource.js +6 -0
- package/src/booking-cancellation.email.jsx +47 -0
- package/src/booking-confirmation.email.jsx +107 -0
- package/src/booking-reminder.email.jsx +69 -0
- package/src/booking-request.email.jsx +96 -0
- package/src/cancel-booking.action.js +61 -0
- package/src/confirm-booking.action.js +82 -0
- package/src/create-booking.action.js +156 -0
- package/src/decline-booking.action.js +68 -0
- package/src/get-available-slots.action.js +73 -0
- package/src/index.js +2 -0
- package/src/public-booking.page.jsx +739 -0
- package/src/send-booking-reminder.task.js +76 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/booking",
|
|
3
3
|
"description": "Booking feature package — services, availability, and appointment management for Ossy consultants",
|
|
4
|
-
"version": "1.13.
|
|
4
|
+
"version": "1.13.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"module": "./src/index.js",
|
|
@@ -13,6 +13,13 @@
|
|
|
13
13
|
"ossy": {
|
|
14
14
|
"src": "./src"
|
|
15
15
|
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@ossy/email": "^1.3.2",
|
|
18
|
+
"@ossy/event-store": "^1.5.2",
|
|
19
|
+
"@ossy/observability": "^1.5.2",
|
|
20
|
+
"@ossy/platform": "^1.36.2",
|
|
21
|
+
"@ossy/resources": "^1.9.2"
|
|
22
|
+
},
|
|
16
23
|
"peerDependencies": {
|
|
17
24
|
"@ossy/design-system": ">=1.0.0",
|
|
18
25
|
"@ossy/router-react": ">=1.0.0",
|
|
@@ -27,5 +34,5 @@
|
|
|
27
34
|
"/src",
|
|
28
35
|
"README.md"
|
|
29
36
|
],
|
|
30
|
-
"gitHead": "
|
|
37
|
+
"gitHead": "723d9d35aba3cd43700ecd6e72a7e7de96acff65"
|
|
31
38
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns the local date string "YYYY-MM-DD" for a UTC date in the given timezone.
|
|
3
|
+
*/
|
|
4
|
+
function toLocalDateString(utcDate, timezone) {
|
|
5
|
+
return new Intl.DateTimeFormat('sv-SE', {
|
|
6
|
+
timeZone: timezone,
|
|
7
|
+
year: 'numeric',
|
|
8
|
+
month: '2-digit',
|
|
9
|
+
day: '2-digit',
|
|
10
|
+
}).format(utcDate)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Returns the day of week (0=Sun…6=Sat) for a UTC date in the given timezone.
|
|
15
|
+
*/
|
|
16
|
+
function getLocalDayOfWeek(utcDate, timezone) {
|
|
17
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
18
|
+
timeZone: timezone,
|
|
19
|
+
weekday: 'short',
|
|
20
|
+
}).formatToParts(utcDate)
|
|
21
|
+
const weekday = parts.find(p => p.type === 'weekday')?.value
|
|
22
|
+
return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(weekday)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Converts a local date string ("YYYY-MM-DD") and time string ("HH:MM")
|
|
27
|
+
* to a UTC Date, respecting the given timezone.
|
|
28
|
+
*/
|
|
29
|
+
function localToUTC(dateStr, timeStr, timezone) {
|
|
30
|
+
const naive = new Date(`${dateStr}T${timeStr}:00Z`)
|
|
31
|
+
const utcDate = new Date(naive.toLocaleString('en-US', { timeZone: 'UTC' }))
|
|
32
|
+
const tzDate = new Date(naive.toLocaleString('en-US', { timeZone: timezone }))
|
|
33
|
+
const offsetMs = utcDate - tzDate
|
|
34
|
+
return new Date(naive.getTime() + offsetMs)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Returns a new Date advanced by `days` calendar days (in UTC).
|
|
39
|
+
*/
|
|
40
|
+
function addDays(date, days) {
|
|
41
|
+
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Computes available booking slots for a consultant.
|
|
46
|
+
*
|
|
47
|
+
* @param {object} params
|
|
48
|
+
* @param {object} params.availability
|
|
49
|
+
* - weeklyWindows: Array of { dayOfWeek, startTime, endTime }
|
|
50
|
+
* - sessionDurations: Array of durations in minutes (first one used as default)
|
|
51
|
+
* - bufferMinutes: Buffer added after each confirmed booking (default 0)
|
|
52
|
+
* - blackoutDates: Array of "YYYY-MM-DD" strings to exclude
|
|
53
|
+
* - timezone: IANA timezone string (e.g. "Europe/Stockholm")
|
|
54
|
+
* @param {Array} params.bookings Existing bookings [{ startAt, endAt, status }]
|
|
55
|
+
* @param {Date|string} params.fromDate Start of range (inclusive)
|
|
56
|
+
* @param {Date|string} params.toDate End of range (inclusive)
|
|
57
|
+
* @param {number} params.duration Slot duration in minutes
|
|
58
|
+
* @returns {Array<{ startAt: string, endAt: string, duration: number }>}
|
|
59
|
+
*/
|
|
60
|
+
export function getAvailableSlots({ availability, bookings = [], fromDate, toDate, duration }) {
|
|
61
|
+
const {
|
|
62
|
+
weeklyWindows = [],
|
|
63
|
+
bufferMinutes = 0,
|
|
64
|
+
blackoutDates = [],
|
|
65
|
+
timezone = 'UTC',
|
|
66
|
+
} = availability
|
|
67
|
+
|
|
68
|
+
const now = new Date()
|
|
69
|
+
const from = new Date(fromDate)
|
|
70
|
+
const to = new Date(toDate)
|
|
71
|
+
|
|
72
|
+
// Snap "from" to start of that UTC day so we don't miss the first day's slots
|
|
73
|
+
const rangeStart = new Date(Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), from.getUTCDate()))
|
|
74
|
+
const rangeEnd = new Date(Date.UTC(to.getUTCFullYear(), to.getUTCMonth(), to.getUTCDate(), 23, 59, 59))
|
|
75
|
+
|
|
76
|
+
const slots = []
|
|
77
|
+
|
|
78
|
+
for (let d = new Date(rangeStart); d <= rangeEnd; d = addDays(d, 1)) {
|
|
79
|
+
const localDateStr = toLocalDateString(d, timezone)
|
|
80
|
+
|
|
81
|
+
if (blackoutDates.includes(localDateStr)) continue
|
|
82
|
+
|
|
83
|
+
const dayOfWeek = getLocalDayOfWeek(d, timezone)
|
|
84
|
+
const window = weeklyWindows.find(w => w.dayOfWeek === dayOfWeek)
|
|
85
|
+
if (!window) continue
|
|
86
|
+
|
|
87
|
+
const windowStart = localToUTC(localDateStr, window.startTime, timezone)
|
|
88
|
+
const windowEnd = localToUTC(localDateStr, window.endTime, timezone)
|
|
89
|
+
const slotMs = duration * 60 * 1000
|
|
90
|
+
|
|
91
|
+
let slotStart = new Date(windowStart)
|
|
92
|
+
|
|
93
|
+
while (slotStart.getTime() + slotMs <= windowEnd.getTime()) {
|
|
94
|
+
const slotEnd = new Date(slotStart.getTime() + slotMs)
|
|
95
|
+
|
|
96
|
+
if (slotEnd > now) {
|
|
97
|
+
const hasOverlap = bookings.some(booking => {
|
|
98
|
+
if (booking.status === 'cancelled') return false
|
|
99
|
+
const bStart = new Date(booking.startAt)
|
|
100
|
+
const bEnd = new Date(new Date(booking.endAt).getTime() + bufferMinutes * 60 * 1000)
|
|
101
|
+
return slotStart < bEnd && slotEnd > bStart
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
if (!hasOverlap) {
|
|
105
|
+
slots.push({
|
|
106
|
+
startAt: slotStart.toISOString(),
|
|
107
|
+
endAt: slotEnd.toISOString(),
|
|
108
|
+
duration,
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
slotStart = new Date(slotStart.getTime() + slotMs)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return slots.sort((a, b) => (a.startAt > b.startAt ? 1 : -1))
|
|
118
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { getAvailableSlots } from './availability-engine.js'
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Helpers
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
/** Build a UTC ISO string for a given date at HH:MM in Europe/Stockholm. */
|
|
8
|
+
function sthlm(dateStr, timeStr) {
|
|
9
|
+
// Use the same localToUTC logic duplicated here to keep tests self-contained
|
|
10
|
+
const naive = new Date(`${dateStr}T${timeStr}:00Z`)
|
|
11
|
+
const utcDate = new Date(naive.toLocaleString('en-US', { timeZone: 'UTC' }))
|
|
12
|
+
const tzDate = new Date(naive.toLocaleString('en-US', { timeZone: 'Europe/Stockholm' }))
|
|
13
|
+
const offsetMs = utcDate - tzDate
|
|
14
|
+
return new Date(naive.getTime() + offsetMs).toISOString()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const TZ = 'Europe/Stockholm'
|
|
18
|
+
|
|
19
|
+
// A Monday in winter time (UTC+1) — 2025-01-13
|
|
20
|
+
const MON = '2025-01-13'
|
|
21
|
+
// dayOfWeek for Monday = 1
|
|
22
|
+
|
|
23
|
+
const BASE_AVAILABILITY = {
|
|
24
|
+
weeklyWindows: [
|
|
25
|
+
{ dayOfWeek: 1, startTime: '09:00', endTime: '12:00' }, // Mon 09-12
|
|
26
|
+
],
|
|
27
|
+
bufferMinutes: 0,
|
|
28
|
+
blackoutDates: [],
|
|
29
|
+
timezone: TZ,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Tests
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
describe('getAvailableSlots', () => {
|
|
37
|
+
|
|
38
|
+
describe('basic slot generation', () => {
|
|
39
|
+
it('returns slots at correct interval within the window', () => {
|
|
40
|
+
const from = new Date(`${MON}T00:00:00Z`)
|
|
41
|
+
const to = new Date(`${MON}T23:59:59Z`)
|
|
42
|
+
|
|
43
|
+
const slots = getAvailableSlots({
|
|
44
|
+
availability: BASE_AVAILABILITY,
|
|
45
|
+
bookings: [],
|
|
46
|
+
fromDate: from,
|
|
47
|
+
toDate: to,
|
|
48
|
+
duration: 60,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
expect(slots).toHaveLength(3) // 09-10, 10-11, 11-12
|
|
52
|
+
expect(slots[0].startAt).toBe(sthlm(MON, '09:00'))
|
|
53
|
+
expect(slots[0].endAt).toBe(sthlm(MON, '10:00'))
|
|
54
|
+
expect(slots[0].duration).toBe(60)
|
|
55
|
+
expect(slots[1].startAt).toBe(sthlm(MON, '10:00'))
|
|
56
|
+
expect(slots[2].startAt).toBe(sthlm(MON, '11:00'))
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('respects a 30-minute session duration', () => {
|
|
60
|
+
const from = new Date(`${MON}T00:00:00Z`)
|
|
61
|
+
const to = new Date(`${MON}T23:59:59Z`)
|
|
62
|
+
|
|
63
|
+
const slots = getAvailableSlots({
|
|
64
|
+
availability: BASE_AVAILABILITY,
|
|
65
|
+
bookings: [],
|
|
66
|
+
fromDate: from,
|
|
67
|
+
toDate: to,
|
|
68
|
+
duration: 30,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
expect(slots).toHaveLength(6) // 09:00, 09:30, 10:00, 10:30, 11:00, 11:30
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('returns no slots for days not in weeklyWindows', () => {
|
|
75
|
+
const TUE = '2025-01-14'
|
|
76
|
+
const slots = getAvailableSlots({
|
|
77
|
+
availability: BASE_AVAILABILITY,
|
|
78
|
+
bookings: [],
|
|
79
|
+
fromDate: new Date(`${TUE}T00:00:00Z`),
|
|
80
|
+
toDate: new Date(`${TUE}T23:59:59Z`),
|
|
81
|
+
duration: 60,
|
|
82
|
+
})
|
|
83
|
+
expect(slots).toHaveLength(0)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('returns slots sorted ascending', () => {
|
|
87
|
+
const MON2 = '2025-01-20'
|
|
88
|
+
const availability = {
|
|
89
|
+
weeklyWindows: [
|
|
90
|
+
{ dayOfWeek: 1, startTime: '09:00', endTime: '12:00' }, // Mon
|
|
91
|
+
{ dayOfWeek: 1, startTime: '09:00', endTime: '12:00' }, // same day (dupe window, ok)
|
|
92
|
+
],
|
|
93
|
+
bufferMinutes: 0,
|
|
94
|
+
blackoutDates: [],
|
|
95
|
+
timezone: TZ,
|
|
96
|
+
}
|
|
97
|
+
const slots = getAvailableSlots({
|
|
98
|
+
availability: { ...BASE_AVAILABILITY, weeklyWindows: availability.weeklyWindows },
|
|
99
|
+
bookings: [],
|
|
100
|
+
fromDate: new Date(`${MON}T00:00:00Z`),
|
|
101
|
+
toDate: new Date(`${MON2}T23:59:59Z`),
|
|
102
|
+
duration: 60,
|
|
103
|
+
})
|
|
104
|
+
for (let i = 1; i < slots.length; i++) {
|
|
105
|
+
expect(slots[i].startAt >= slots[i - 1].startAt).toBe(true)
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe('blackout dates', () => {
|
|
111
|
+
it('excludes the blackout date', () => {
|
|
112
|
+
const availability = { ...BASE_AVAILABILITY, blackoutDates: [MON] }
|
|
113
|
+
const slots = getAvailableSlots({
|
|
114
|
+
availability,
|
|
115
|
+
bookings: [],
|
|
116
|
+
fromDate: new Date(`${MON}T00:00:00Z`),
|
|
117
|
+
toDate: new Date(`${MON}T23:59:59Z`),
|
|
118
|
+
duration: 60,
|
|
119
|
+
})
|
|
120
|
+
expect(slots).toHaveLength(0)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('still returns slots for non-blackout days in the range', () => {
|
|
124
|
+
const MON2 = '2025-01-20'
|
|
125
|
+
const availability = { ...BASE_AVAILABILITY, blackoutDates: [MON] }
|
|
126
|
+
const slots = getAvailableSlots({
|
|
127
|
+
availability,
|
|
128
|
+
bookings: [],
|
|
129
|
+
fromDate: new Date(`${MON}T00:00:00Z`),
|
|
130
|
+
toDate: new Date(`${MON2}T23:59:59Z`),
|
|
131
|
+
duration: 60,
|
|
132
|
+
})
|
|
133
|
+
expect(slots.every(s => !s.startAt.startsWith(MON))).toBe(true)
|
|
134
|
+
expect(slots.length).toBeGreaterThan(0)
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe('booking overlap removal', () => {
|
|
139
|
+
it('removes a slot that is fully occupied by a confirmed booking', () => {
|
|
140
|
+
const from = new Date(`${MON}T00:00:00Z`)
|
|
141
|
+
const to = new Date(`${MON}T23:59:59Z`)
|
|
142
|
+
|
|
143
|
+
const bookings = [
|
|
144
|
+
{
|
|
145
|
+
startAt: sthlm(MON, '09:00'),
|
|
146
|
+
endAt: sthlm(MON, '10:00'),
|
|
147
|
+
status: 'confirmed',
|
|
148
|
+
},
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
const slots = getAvailableSlots({
|
|
152
|
+
availability: BASE_AVAILABILITY,
|
|
153
|
+
bookings,
|
|
154
|
+
fromDate: from,
|
|
155
|
+
toDate: to,
|
|
156
|
+
duration: 60,
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
expect(slots).toHaveLength(2) // 10-11, 11-12 remain
|
|
160
|
+
expect(slots[0].startAt).toBe(sthlm(MON, '10:00'))
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('does NOT remove a slot blocked by a cancelled booking', () => {
|
|
164
|
+
const from = new Date(`${MON}T00:00:00Z`)
|
|
165
|
+
const to = new Date(`${MON}T23:59:59Z`)
|
|
166
|
+
|
|
167
|
+
const bookings = [
|
|
168
|
+
{
|
|
169
|
+
startAt: sthlm(MON, '09:00'),
|
|
170
|
+
endAt: sthlm(MON, '10:00'),
|
|
171
|
+
status: 'cancelled',
|
|
172
|
+
},
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
const slots = getAvailableSlots({
|
|
176
|
+
availability: BASE_AVAILABILITY,
|
|
177
|
+
bookings,
|
|
178
|
+
fromDate: from,
|
|
179
|
+
toDate: to,
|
|
180
|
+
duration: 60,
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
expect(slots).toHaveLength(3)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('removes slot that partially overlaps a confirmed booking', () => {
|
|
187
|
+
const from = new Date(`${MON}T00:00:00Z`)
|
|
188
|
+
const to = new Date(`${MON}T23:59:59Z`)
|
|
189
|
+
|
|
190
|
+
// Booking starts at 09:30, so the 09:00-10:00 slot overlaps it
|
|
191
|
+
const bookings = [
|
|
192
|
+
{
|
|
193
|
+
startAt: sthlm(MON, '09:30'),
|
|
194
|
+
endAt: sthlm(MON, '10:30'),
|
|
195
|
+
status: 'confirmed',
|
|
196
|
+
},
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
const slots = getAvailableSlots({
|
|
200
|
+
availability: BASE_AVAILABILITY,
|
|
201
|
+
bookings,
|
|
202
|
+
fromDate: from,
|
|
203
|
+
toDate: to,
|
|
204
|
+
duration: 60,
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
// 09:00-10:00 overlaps booking 09:30-10:30
|
|
208
|
+
// 10:00-11:00 overlaps booking 09:30-10:30
|
|
209
|
+
// 11:00-12:00 is free
|
|
210
|
+
expect(slots).toHaveLength(1)
|
|
211
|
+
expect(slots[0].startAt).toBe(sthlm(MON, '11:00'))
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
describe('buffer minutes', () => {
|
|
216
|
+
it('blocks slots within the buffer period after a booking', () => {
|
|
217
|
+
const from = new Date(`${MON}T00:00:00Z`)
|
|
218
|
+
const to = new Date(`${MON}T23:59:59Z`)
|
|
219
|
+
|
|
220
|
+
const bookings = [
|
|
221
|
+
{
|
|
222
|
+
startAt: sthlm(MON, '09:00'),
|
|
223
|
+
endAt: sthlm(MON, '10:00'),
|
|
224
|
+
status: 'confirmed',
|
|
225
|
+
},
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
// 30-minute buffer means 10:00-10:30 is blocked, so 10:00-11:00 slot is taken
|
|
229
|
+
const slots = getAvailableSlots({
|
|
230
|
+
availability: { ...BASE_AVAILABILITY, bufferMinutes: 30 },
|
|
231
|
+
bookings,
|
|
232
|
+
fromDate: from,
|
|
233
|
+
toDate: to,
|
|
234
|
+
duration: 60,
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
expect(slots).toHaveLength(1)
|
|
238
|
+
expect(slots[0].startAt).toBe(sthlm(MON, '11:00'))
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
describe('past slot removal', () => {
|
|
243
|
+
it('does not return slots that have already ended', () => {
|
|
244
|
+
// Use a date far in the past
|
|
245
|
+
const PAST = '2020-01-06' // a Monday
|
|
246
|
+
const availability = {
|
|
247
|
+
...BASE_AVAILABILITY,
|
|
248
|
+
weeklyWindows: [{ dayOfWeek: 1, startTime: '09:00', endTime: '17:00' }],
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const slots = getAvailableSlots({
|
|
252
|
+
availability,
|
|
253
|
+
bookings: [],
|
|
254
|
+
fromDate: new Date(`${PAST}T00:00:00Z`),
|
|
255
|
+
toDate: new Date(`${PAST}T23:59:59Z`),
|
|
256
|
+
duration: 60,
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
expect(slots).toHaveLength(0)
|
|
260
|
+
})
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
describe('empty availability', () => {
|
|
264
|
+
it('returns empty array when no weekly windows defined', () => {
|
|
265
|
+
const slots = getAvailableSlots({
|
|
266
|
+
availability: { weeklyWindows: [], bufferMinutes: 0, blackoutDates: [], timezone: TZ },
|
|
267
|
+
bookings: [],
|
|
268
|
+
fromDate: new Date(`${MON}T00:00:00Z`),
|
|
269
|
+
toDate: new Date(`${MON}T23:59:59Z`),
|
|
270
|
+
duration: 60,
|
|
271
|
+
})
|
|
272
|
+
expect(slots).toHaveLength(0)
|
|
273
|
+
})
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
describe('multi-day range', () => {
|
|
277
|
+
it('returns slots across multiple matching days', () => {
|
|
278
|
+
const MON2 = '2025-01-20'
|
|
279
|
+
const slots = getAvailableSlots({
|
|
280
|
+
availability: BASE_AVAILABILITY,
|
|
281
|
+
bookings: [],
|
|
282
|
+
fromDate: new Date(`${MON}T00:00:00Z`),
|
|
283
|
+
toDate: new Date(`${MON2}T23:59:59Z`),
|
|
284
|
+
duration: 60,
|
|
285
|
+
})
|
|
286
|
+
// Two Mondays × 3 slots each = 6
|
|
287
|
+
expect(slots).toHaveLength(6)
|
|
288
|
+
})
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { EmailLayout, EmailText } from '@ossy/email'
|
|
3
|
+
|
|
4
|
+
export const id = 'booking/cancellation'
|
|
5
|
+
export const subject = 'Din bokning har avbokats'
|
|
6
|
+
|
|
7
|
+
const SWEDISH_MONTHS = [
|
|
8
|
+
'januari', 'februari', 'mars', 'april', 'maj', 'juni',
|
|
9
|
+
'juli', 'augusti', 'september', 'oktober', 'november', 'december',
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
const SWEDISH_WEEKDAYS = [
|
|
13
|
+
'söndag', 'måndag', 'tisdag', 'onsdag', 'torsdag', 'fredag', 'lördag',
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
function formatSwedishDateTime(isoString) {
|
|
17
|
+
const d = new Date(isoString)
|
|
18
|
+
const weekday = SWEDISH_WEEKDAYS[d.getDay()]
|
|
19
|
+
const day = d.getDate()
|
|
20
|
+
const month = SWEDISH_MONTHS[d.getMonth()]
|
|
21
|
+
const year = d.getFullYear()
|
|
22
|
+
const hours = String(d.getHours()).padStart(2, '0')
|
|
23
|
+
const minutes = String(d.getMinutes()).padStart(2, '0')
|
|
24
|
+
return `${weekday} ${day} ${month} ${year} kl. ${hours}:${minutes}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default function BookingCancellationEmail({ clientName, startAt, duration }) {
|
|
28
|
+
const formattedDate = formatSwedishDateTime(startAt)
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<EmailLayout>
|
|
32
|
+
<h1 style={{ color: '#111111', fontSize: 24, fontWeight: 700, margin: '0 0 24px' }}>
|
|
33
|
+
Din bokning har avbokats
|
|
34
|
+
</h1>
|
|
35
|
+
|
|
36
|
+
<EmailText>Hej {clientName},</EmailText>
|
|
37
|
+
|
|
38
|
+
<EmailText>
|
|
39
|
+
Din bokning den <strong>{formattedDate}</strong> ({duration} minuter) har avbokats.
|
|
40
|
+
</EmailText>
|
|
41
|
+
|
|
42
|
+
<EmailText>
|
|
43
|
+
Kontakta oss om du vill boka en ny tid.
|
|
44
|
+
</EmailText>
|
|
45
|
+
</EmailLayout>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { EmailLayout, EmailText, EmailButton } from '@ossy/email'
|
|
3
|
+
|
|
4
|
+
export const id = 'booking/confirmation'
|
|
5
|
+
export const subject = 'Bokningsbekräftelse'
|
|
6
|
+
|
|
7
|
+
const SWEDISH_MONTHS = [
|
|
8
|
+
'januari', 'februari', 'mars', 'april', 'maj', 'juni',
|
|
9
|
+
'juli', 'augusti', 'september', 'oktober', 'november', 'december',
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
const SWEDISH_WEEKDAYS = [
|
|
13
|
+
'söndag', 'måndag', 'tisdag', 'onsdag', 'torsdag', 'fredag', 'lördag',
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
function formatSwedishDateTime(isoString) {
|
|
17
|
+
const d = new Date(isoString)
|
|
18
|
+
const weekday = SWEDISH_WEEKDAYS[d.getDay()]
|
|
19
|
+
const day = d.getDate()
|
|
20
|
+
const month = SWEDISH_MONTHS[d.getMonth()]
|
|
21
|
+
const year = d.getFullYear()
|
|
22
|
+
const hours = String(d.getHours()).padStart(2, '0')
|
|
23
|
+
const minutes = String(d.getMinutes()).padStart(2, '0')
|
|
24
|
+
return `${weekday} ${day} ${month} ${year} kl. ${hours}:${minutes}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function buildIcsUrl(startAt, duration, title, baseUrl) {
|
|
28
|
+
const start = new Date(startAt)
|
|
29
|
+
const end = new Date(start.getTime() + duration * 60 * 1000)
|
|
30
|
+
|
|
31
|
+
const fmt = (d) =>
|
|
32
|
+
d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')
|
|
33
|
+
|
|
34
|
+
const ics = [
|
|
35
|
+
'BEGIN:VCALENDAR',
|
|
36
|
+
'VERSION:2.0',
|
|
37
|
+
'BEGIN:VEVENT',
|
|
38
|
+
`DTSTART:${fmt(start)}`,
|
|
39
|
+
`DTEND:${fmt(end)}`,
|
|
40
|
+
`SUMMARY:${title}`,
|
|
41
|
+
'END:VEVENT',
|
|
42
|
+
'END:VCALENDAR',
|
|
43
|
+
].join('\n')
|
|
44
|
+
|
|
45
|
+
return `data:text/calendar;charset=utf-8,${encodeURIComponent(ics)}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export default function BookingConfirmationEmail({
|
|
49
|
+
clientName,
|
|
50
|
+
consultantName,
|
|
51
|
+
startAt,
|
|
52
|
+
duration,
|
|
53
|
+
baseUrl = '',
|
|
54
|
+
}) {
|
|
55
|
+
const formattedDate = formatSwedishDateTime(startAt)
|
|
56
|
+
const icsUrl = buildIcsUrl(startAt, duration, `Möte med ${consultantName}`, baseUrl)
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<EmailLayout>
|
|
60
|
+
<h1 style={{ color: '#111111', fontSize: 24, fontWeight: 700, margin: '0 0 24px' }}>
|
|
61
|
+
Din bokning är bekräftad
|
|
62
|
+
</h1>
|
|
63
|
+
|
|
64
|
+
<EmailText>
|
|
65
|
+
Hej {clientName},
|
|
66
|
+
</EmailText>
|
|
67
|
+
|
|
68
|
+
<EmailText>
|
|
69
|
+
Din bokning hos <strong>{consultantName}</strong> är nu bekräftad.
|
|
70
|
+
</EmailText>
|
|
71
|
+
|
|
72
|
+
<table cellPadding={0} cellSpacing={0} style={{ marginBottom: 32, width: '100%' }}>
|
|
73
|
+
<tbody>
|
|
74
|
+
<tr>
|
|
75
|
+
<td style={{ paddingBottom: 12 }}>
|
|
76
|
+
<strong style={{ color: '#111111', fontSize: 14 }}>Datum & tid</strong>
|
|
77
|
+
</td>
|
|
78
|
+
<td style={{ paddingBottom: 12, color: '#444444', fontSize: 14 }}>
|
|
79
|
+
{formattedDate}
|
|
80
|
+
</td>
|
|
81
|
+
</tr>
|
|
82
|
+
<tr>
|
|
83
|
+
<td style={{ paddingBottom: 12 }}>
|
|
84
|
+
<strong style={{ color: '#111111', fontSize: 14 }}>Längd</strong>
|
|
85
|
+
</td>
|
|
86
|
+
<td style={{ paddingBottom: 12, color: '#444444', fontSize: 14 }}>
|
|
87
|
+
{duration} minuter
|
|
88
|
+
</td>
|
|
89
|
+
</tr>
|
|
90
|
+
</tbody>
|
|
91
|
+
</table>
|
|
92
|
+
|
|
93
|
+
<EmailText>
|
|
94
|
+
<a
|
|
95
|
+
href={icsUrl}
|
|
96
|
+
style={{ color: '#111111', textDecoration: 'underline', fontSize: 14 }}
|
|
97
|
+
>
|
|
98
|
+
Lägg till i kalender (.ics)
|
|
99
|
+
</a>
|
|
100
|
+
</EmailText>
|
|
101
|
+
|
|
102
|
+
<EmailText style={{ color: '#888888', fontSize: 13 }}>
|
|
103
|
+
Om du behöver ändra eller avboka din tid, kontakta oss i god tid.
|
|
104
|
+
</EmailText>
|
|
105
|
+
</EmailLayout>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { EmailLayout, EmailText } from '@ossy/email'
|
|
3
|
+
|
|
4
|
+
export const id = 'booking/reminder'
|
|
5
|
+
export const subject = 'Påminnelse: din bokning imorgon'
|
|
6
|
+
|
|
7
|
+
const SWEDISH_MONTHS = [
|
|
8
|
+
'januari', 'februari', 'mars', 'april', 'maj', 'juni',
|
|
9
|
+
'juli', 'augusti', 'september', 'oktober', 'november', 'december',
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
const SWEDISH_WEEKDAYS = [
|
|
13
|
+
'söndag', 'måndag', 'tisdag', 'onsdag', 'torsdag', 'fredag', 'lördag',
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
function formatSwedishDateTime(isoString) {
|
|
17
|
+
const d = new Date(isoString)
|
|
18
|
+
const weekday = SWEDISH_WEEKDAYS[d.getDay()]
|
|
19
|
+
const day = d.getDate()
|
|
20
|
+
const month = SWEDISH_MONTHS[d.getMonth()]
|
|
21
|
+
const year = d.getFullYear()
|
|
22
|
+
const hours = String(d.getHours()).padStart(2, '0')
|
|
23
|
+
const minutes = String(d.getMinutes()).padStart(2, '0')
|
|
24
|
+
return `${weekday} ${day} ${month} ${year} kl. ${hours}:${minutes}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default function BookingReminderEmail({ clientName, consultantName, startAt, duration }) {
|
|
28
|
+
const formattedDate = formatSwedishDateTime(startAt)
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<EmailLayout>
|
|
32
|
+
<h1 style={{ color: '#111111', fontSize: 24, fontWeight: 700, margin: '0 0 24px' }}>
|
|
33
|
+
Påminnelse om din bokning
|
|
34
|
+
</h1>
|
|
35
|
+
|
|
36
|
+
<EmailText>Hej {clientName},</EmailText>
|
|
37
|
+
|
|
38
|
+
<EmailText>
|
|
39
|
+
Det här är en påminnelse om att du har en bokning hos{' '}
|
|
40
|
+
<strong>{consultantName ?? 'din konsult'}</strong> imorgon.
|
|
41
|
+
</EmailText>
|
|
42
|
+
|
|
43
|
+
<table cellPadding={0} cellSpacing={0} style={{ marginBottom: 32 }}>
|
|
44
|
+
<tbody>
|
|
45
|
+
<tr>
|
|
46
|
+
<td style={{ paddingRight: 24, paddingBottom: 12 }}>
|
|
47
|
+
<strong style={{ color: '#111111', fontSize: 14 }}>Datum & tid</strong>
|
|
48
|
+
</td>
|
|
49
|
+
<td style={{ paddingBottom: 12, color: '#444444', fontSize: 14 }}>
|
|
50
|
+
{formattedDate}
|
|
51
|
+
</td>
|
|
52
|
+
</tr>
|
|
53
|
+
<tr>
|
|
54
|
+
<td style={{ paddingRight: 24 }}>
|
|
55
|
+
<strong style={{ color: '#111111', fontSize: 14 }}>Längd</strong>
|
|
56
|
+
</td>
|
|
57
|
+
<td style={{ color: '#444444', fontSize: 14 }}>
|
|
58
|
+
{duration} minuter
|
|
59
|
+
</td>
|
|
60
|
+
</tr>
|
|
61
|
+
</tbody>
|
|
62
|
+
</table>
|
|
63
|
+
|
|
64
|
+
<EmailText style={{ color: '#888888', fontSize: 13 }}>
|
|
65
|
+
Om du behöver avboka, kontakta oss så snart som möjligt.
|
|
66
|
+
</EmailText>
|
|
67
|
+
</EmailLayout>
|
|
68
|
+
)
|
|
69
|
+
}
|