@ossy/booking 1.13.1 → 1.13.3

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 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.1",
4
+ "version": "1.13.3",
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.3",
18
+ "@ossy/event-store": "^1.5.3",
19
+ "@ossy/observability": "^1.5.3",
20
+ "@ossy/platform": "^1.36.3",
21
+ "@ossy/resources": "^1.9.3"
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": "d5b71d16ec56c819401c7a74f95d12b2347ca3c4"
37
+ "gitHead": "afdf2cc378dd2e7cbb63680f04386e4ad260ed2d"
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
+ })
@@ -23,5 +23,11 @@ export default {
23
23
  name: 'bufferMinutes',
24
24
  type: 'number',
25
25
  },
26
+ {
27
+ // IANA timezone name, e.g. 'Europe/Stockholm'. Default applied at runtime.
28
+ name: 'timezone',
29
+ type: 'text',
30
+ default: 'Europe/Stockholm',
31
+ },
26
32
  ],
27
33
  }
@@ -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 &amp; 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 &amp; 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
+ }