@ossy/booking 1.13.3 → 1.14.0

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.3",
4
+ "version": "1.14.0",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "module": "./src/index.js",
@@ -14,11 +14,11 @@
14
14
  "src": "./src"
15
15
  },
16
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"
17
+ "@ossy/email": "^1.4.0",
18
+ "@ossy/event-store": "^1.6.0",
19
+ "@ossy/observability": "^1.6.0",
20
+ "@ossy/platform": "^1.37.0",
21
+ "@ossy/resources": "^1.10.0"
22
22
  },
23
23
  "peerDependencies": {
24
24
  "@ossy/design-system": ">=1.0.0",
@@ -34,5 +34,5 @@
34
34
  "/src",
35
35
  "README.md"
36
36
  ],
37
- "gitHead": "afdf2cc378dd2e7cbb63680f04386e4ad260ed2d"
37
+ "gitHead": "abc435125ddf56e01b61353d49d2a18d2ab55be8"
38
38
  }
@@ -0,0 +1,542 @@
1
+ import React, { useState, useEffect } from 'react'
2
+
3
+ export const path = { sv: '/tillganglighet', en: '/availability' }
4
+ export const id = 'booking/availability-setup'
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Constants
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const FONT = 'system-ui, -apple-system, sans-serif'
11
+
12
+ const COLOR = {
13
+ bg: '#f5f5f5',
14
+ surface: '#ffffff',
15
+ surfaceAlt: '#f9f9f9',
16
+ surfaceEnabled: '#f8f9ff',
17
+ border: '#e2e2e2',
18
+ borderEnabled: '#c7d2fe',
19
+ primary: '#111111',
20
+ primaryText: '#ffffff',
21
+ secondary: '#555555',
22
+ muted: '#999999',
23
+ success: '#16a34a',
24
+ successBg: '#f0fdf4',
25
+ successBorder: '#bbf7d0',
26
+ error: '#dc2626',
27
+ errorBg: '#fef2f2',
28
+ errorBorder: '#fecaca',
29
+ accent: '#4f46e5',
30
+ accentBg: '#eef2ff',
31
+ accentBorder: '#c7d2fe',
32
+ }
33
+
34
+ const DAYS = [
35
+ { label: 'Måndag', dayOfWeek: 1 },
36
+ { label: 'Tisdag', dayOfWeek: 2 },
37
+ { label: 'Onsdag', dayOfWeek: 3 },
38
+ { label: 'Torsdag', dayOfWeek: 4 },
39
+ { label: 'Fredag', dayOfWeek: 5 },
40
+ { label: 'Lördag', dayOfWeek: 6 },
41
+ { label: 'Söndag', dayOfWeek: 0 },
42
+ ]
43
+
44
+ const SESSION_DURATIONS = [30, 60, 90]
45
+ const BUFFER_OPTIONS = [0, 15, 30]
46
+ const TIMEZONE_OPTIONS = [
47
+ 'Europe/Stockholm',
48
+ 'Europe/London',
49
+ 'Europe/Paris',
50
+ 'Europe/Berlin',
51
+ 'America/New_York',
52
+ 'America/Los_Angeles',
53
+ 'Asia/Tokyo',
54
+ ]
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Helpers
58
+ // ---------------------------------------------------------------------------
59
+
60
+ function buildDayState(weeklyWindows = []) {
61
+ const byDay = {}
62
+ for (const w of weeklyWindows) {
63
+ byDay[w.dayOfWeek] = {
64
+ enabled: true,
65
+ startTime: w.startTime ?? '09:00',
66
+ endTime: w.endTime ?? '17:00',
67
+ }
68
+ }
69
+ return Object.fromEntries(
70
+ DAYS.map(d => [
71
+ d.dayOfWeek,
72
+ byDay[d.dayOfWeek] ?? { enabled: false, startTime: '09:00', endTime: '17:00' },
73
+ ]),
74
+ )
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Sub-components
79
+ // ---------------------------------------------------------------------------
80
+
81
+ function Toggle({ checked, onChange }) {
82
+ return (
83
+ <button
84
+ role="switch"
85
+ aria-checked={checked}
86
+ onClick={() => onChange(!checked)}
87
+ style={{
88
+ width: 36,
89
+ height: 20,
90
+ borderRadius: 10,
91
+ border: 'none',
92
+ background: checked ? COLOR.accent : '#d1d5db',
93
+ cursor: 'pointer',
94
+ position: 'relative',
95
+ flexShrink: 0,
96
+ transition: 'background 0.15s',
97
+ padding: 0,
98
+ }}
99
+ >
100
+ <span
101
+ style={{
102
+ position: 'absolute',
103
+ top: 2,
104
+ left: checked ? 18 : 2,
105
+ width: 16,
106
+ height: 16,
107
+ borderRadius: '50%',
108
+ background: '#ffffff',
109
+ transition: 'left 0.15s',
110
+ boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
111
+ }}
112
+ />
113
+ </button>
114
+ )
115
+ }
116
+
117
+ function TimeInput({ value, onChange }) {
118
+ return (
119
+ <input
120
+ type="time"
121
+ value={value}
122
+ onChange={e => onChange(e.target.value)}
123
+ style={{
124
+ padding: '5px 8px',
125
+ border: `1px solid ${COLOR.border}`,
126
+ borderRadius: 6,
127
+ fontFamily: FONT,
128
+ fontSize: 13,
129
+ color: COLOR.primary,
130
+ background: COLOR.surface,
131
+ outline: 'none',
132
+ }}
133
+ />
134
+ )
135
+ }
136
+
137
+ function DayRow({ label, dayOfWeek, day, onToggle, onTimeChange }) {
138
+ return (
139
+ <div
140
+ style={{
141
+ display: 'flex',
142
+ alignItems: 'center',
143
+ gap: 12,
144
+ padding: '10px 14px',
145
+ borderRadius: 8,
146
+ background: day.enabled ? COLOR.surfaceEnabled : COLOR.surfaceAlt,
147
+ border: `1px solid ${day.enabled ? COLOR.borderEnabled : COLOR.border}`,
148
+ transition: 'background 0.15s, border-color 0.15s',
149
+ }}
150
+ >
151
+ <Toggle checked={day.enabled} onChange={onToggle} />
152
+
153
+ <span
154
+ style={{
155
+ width: 70,
156
+ fontSize: 14,
157
+ fontWeight: 600,
158
+ color: day.enabled ? COLOR.primary : COLOR.muted,
159
+ flexShrink: 0,
160
+ userSelect: 'none',
161
+ }}
162
+ >
163
+ {label}
164
+ </span>
165
+
166
+ {day.enabled ? (
167
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1 }}>
168
+ <TimeInput value={day.startTime} onChange={v => onTimeChange('startTime', v)} />
169
+ <span style={{ fontSize: 13, color: COLOR.muted, userSelect: 'none' }}>–</span>
170
+ <TimeInput value={day.endTime} onChange={v => onTimeChange('endTime', v)} />
171
+ </div>
172
+ ) : (
173
+ <span style={{ fontSize: 13, color: COLOR.muted }}>Inte tillgänglig</span>
174
+ )}
175
+ </div>
176
+ )
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Page
181
+ // ---------------------------------------------------------------------------
182
+
183
+ export default function AvailabilitySetupPage({ workspaceId, ...props }) {
184
+ const [dayState, setDayState] = useState(() => buildDayState())
185
+ const [sessionDurations, setSessionDurations] = useState([60])
186
+ const [bufferMinutes, setBufferMinutes] = useState(0)
187
+ const [timezone, setTimezone] = useState('Europe/Stockholm')
188
+ const [loading, setLoading] = useState(true)
189
+ const [saving, setSaving] = useState(false)
190
+ const [saved, setSaved] = useState(false)
191
+ const [error, setError] = useState(null)
192
+ const [copied, setCopied] = useState(false)
193
+
194
+ const bookingUrl = `https://ossy.se/boka/${workspaceId ?? ''}`
195
+
196
+ // Load existing availability on mount
197
+ useEffect(() => {
198
+ fetch('/actions/booking/get-availability')
199
+ .then(r => (r.ok ? r.json() : null))
200
+ .then(data => {
201
+ if (data) {
202
+ setDayState(buildDayState(data.weeklyWindows ?? []))
203
+ setSessionDurations(
204
+ Array.isArray(data.sessionDurations) && data.sessionDurations.length
205
+ ? data.sessionDurations
206
+ : [60],
207
+ )
208
+ setBufferMinutes(data.bufferMinutes ?? 0)
209
+ setTimezone(data.timezone ?? 'Europe/Stockholm')
210
+ setSaved(true)
211
+ }
212
+ })
213
+ .catch(() => {})
214
+ .finally(() => setLoading(false))
215
+ }, [])
216
+
217
+ const toggleDay = (dayOfWeek) => {
218
+ setDayState(prev => ({
219
+ ...prev,
220
+ [dayOfWeek]: { ...prev[dayOfWeek], enabled: !prev[dayOfWeek].enabled },
221
+ }))
222
+ setSaved(false)
223
+ }
224
+
225
+ const updateDayTime = (dayOfWeek, field, value) => {
226
+ setDayState(prev => ({
227
+ ...prev,
228
+ [dayOfWeek]: { ...prev[dayOfWeek], [field]: value },
229
+ }))
230
+ setSaved(false)
231
+ }
232
+
233
+ const toggleDuration = (dur) => {
234
+ setSessionDurations(prev => {
235
+ if (prev.includes(dur)) {
236
+ const next = prev.filter(d => d !== dur)
237
+ return next.length ? next : [dur]
238
+ }
239
+ return [...prev, dur].sort((a, b) => a - b)
240
+ })
241
+ setSaved(false)
242
+ }
243
+
244
+ const handleSave = async () => {
245
+ setSaving(true)
246
+ setError(null)
247
+
248
+ const weeklyWindows = DAYS.filter(d => dayState[d.dayOfWeek]?.enabled).map(d => ({
249
+ dayOfWeek: d.dayOfWeek,
250
+ startTime: dayState[d.dayOfWeek].startTime,
251
+ endTime: dayState[d.dayOfWeek].endTime,
252
+ }))
253
+
254
+ try {
255
+ const res = await fetch('/actions/booking/save-availability', {
256
+ method: 'POST',
257
+ headers: { 'Content-Type': 'application/json' },
258
+ body: JSON.stringify({ weeklyWindows, sessionDurations, bufferMinutes, timezone }),
259
+ })
260
+
261
+ if (!res.ok) {
262
+ const data = await res.json().catch(() => ({}))
263
+ throw new Error(data?.message ?? `Kunde inte spara (${res.status})`)
264
+ }
265
+
266
+ setSaved(true)
267
+ } catch (err) {
268
+ setError(err.message)
269
+ } finally {
270
+ setSaving(false)
271
+ }
272
+ }
273
+
274
+ const handleCopyLink = () => {
275
+ navigator.clipboard.writeText(bookingUrl).then(() => {
276
+ setCopied(true)
277
+ setTimeout(() => setCopied(false), 2500)
278
+ })
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // Styles
283
+ // ---------------------------------------------------------------------------
284
+
285
+ const cardStyle = {
286
+ marginBottom: 16,
287
+ padding: '20px 24px',
288
+ background: COLOR.surface,
289
+ border: `1px solid ${COLOR.border}`,
290
+ borderRadius: 12,
291
+ }
292
+
293
+ const sectionHeadingStyle = {
294
+ margin: '0 0 14px',
295
+ fontSize: 12,
296
+ fontWeight: 700,
297
+ color: COLOR.muted,
298
+ textTransform: 'uppercase',
299
+ letterSpacing: '0.06em',
300
+ }
301
+
302
+ const selectStyle = {
303
+ padding: '8px 12px',
304
+ border: `1px solid ${COLOR.border}`,
305
+ borderRadius: 8,
306
+ fontFamily: FONT,
307
+ fontSize: 14,
308
+ color: COLOR.primary,
309
+ background: COLOR.surface,
310
+ cursor: 'pointer',
311
+ outline: 'none',
312
+ }
313
+
314
+ if (loading) {
315
+ return (
316
+ <div style={{ fontFamily: FONT, padding: '32px 24px', color: COLOR.secondary, fontSize: 14 }}>
317
+ Laddar…
318
+ </div>
319
+ )
320
+ }
321
+
322
+ return (
323
+ <div
324
+ style={{
325
+ fontFamily: FONT,
326
+ maxWidth: 620,
327
+ padding: '28px 24px',
328
+ boxSizing: 'border-box',
329
+ }}
330
+ >
331
+ {/* Page header */}
332
+ <div style={{ marginBottom: 24 }}>
333
+ <h1 style={{ margin: '0 0 6px', fontSize: 22, fontWeight: 700, color: COLOR.primary }}>
334
+ Tillgänglighet
335
+ </h1>
336
+ <p style={{ margin: 0, fontSize: 14, color: COLOR.secondary, maxWidth: 480 }}>
337
+ Ange när du är tillgänglig för bokningar. Klienter kan bara boka tider inom dessa
338
+ fönster.
339
+ </p>
340
+ </div>
341
+
342
+ {/* Weekly schedule */}
343
+ <div style={cardStyle}>
344
+ <p style={sectionHeadingStyle}>Veckoschema</p>
345
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
346
+ {DAYS.map(({ label, dayOfWeek }) => (
347
+ <DayRow
348
+ key={dayOfWeek}
349
+ label={label}
350
+ dayOfWeek={dayOfWeek}
351
+ day={dayState[dayOfWeek]}
352
+ onToggle={() => toggleDay(dayOfWeek)}
353
+ onTimeChange={(field, value) => updateDayTime(dayOfWeek, field, value)}
354
+ />
355
+ ))}
356
+ </div>
357
+ </div>
358
+
359
+ {/* Session durations */}
360
+ <div style={cardStyle}>
361
+ <p style={sectionHeadingStyle}>Sessionslängd</p>
362
+ <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
363
+ {SESSION_DURATIONS.map(dur => {
364
+ const active = sessionDurations.includes(dur)
365
+ return (
366
+ <button
367
+ key={dur}
368
+ onClick={() => toggleDuration(dur)}
369
+ style={{
370
+ padding: '8px 20px',
371
+ border: `2px solid ${active ? COLOR.accent : COLOR.border}`,
372
+ borderRadius: 8,
373
+ background: active ? COLOR.accentBg : COLOR.surface,
374
+ color: active ? COLOR.accent : COLOR.secondary,
375
+ fontFamily: FONT,
376
+ fontSize: 14,
377
+ fontWeight: 600,
378
+ cursor: 'pointer',
379
+ transition: 'all 0.15s',
380
+ }}
381
+ >
382
+ {dur} min
383
+ </button>
384
+ )
385
+ })}
386
+ </div>
387
+ <p style={{ margin: '10px 0 0', fontSize: 12, color: COLOR.muted }}>
388
+ Välj de sessionslängder klienter kan välja bland.
389
+ </p>
390
+ </div>
391
+
392
+ {/* Buffer time */}
393
+ <div style={cardStyle}>
394
+ <label
395
+ htmlFor="buffer-select"
396
+ style={{ display: 'block', fontWeight: 600, fontSize: 14, color: COLOR.primary, marginBottom: 8 }}
397
+ >
398
+ Bufferttid mellan bokningar
399
+ </label>
400
+ <select
401
+ id="buffer-select"
402
+ value={bufferMinutes}
403
+ onChange={e => {
404
+ setBufferMinutes(Number(e.target.value))
405
+ setSaved(false)
406
+ }}
407
+ style={{ ...selectStyle, minWidth: 190 }}
408
+ >
409
+ {BUFFER_OPTIONS.map(opt => (
410
+ <option key={opt} value={opt}>
411
+ {opt === 0 ? 'Ingen bufferttid' : `${opt} minuter`}
412
+ </option>
413
+ ))}
414
+ </select>
415
+ <p style={{ margin: '8px 0 0', fontSize: 12, color: COLOR.muted }}>
416
+ Automatisk paustid som läggs till efter varje bokning.
417
+ </p>
418
+ </div>
419
+
420
+ {/* Timezone */}
421
+ <div style={cardStyle}>
422
+ <label
423
+ htmlFor="tz-select"
424
+ style={{ display: 'block', fontWeight: 600, fontSize: 14, color: COLOR.primary, marginBottom: 8 }}
425
+ >
426
+ Tidszon
427
+ </label>
428
+ <select
429
+ id="tz-select"
430
+ value={timezone}
431
+ onChange={e => {
432
+ setTimezone(e.target.value)
433
+ setSaved(false)
434
+ }}
435
+ style={{ ...selectStyle, minWidth: 220 }}
436
+ >
437
+ {TIMEZONE_OPTIONS.map(tz => (
438
+ <option key={tz} value={tz}>
439
+ {tz}
440
+ </option>
441
+ ))}
442
+ </select>
443
+ </div>
444
+
445
+ {/* Error message */}
446
+ {error && (
447
+ <div
448
+ style={{
449
+ marginBottom: 16,
450
+ padding: '12px 16px',
451
+ background: COLOR.errorBg,
452
+ border: `1px solid ${COLOR.errorBorder}`,
453
+ borderRadius: 8,
454
+ color: COLOR.error,
455
+ fontSize: 14,
456
+ }}
457
+ >
458
+ {error}
459
+ </div>
460
+ )}
461
+
462
+ {/* Save button */}
463
+ <button
464
+ onClick={handleSave}
465
+ disabled={saving}
466
+ style={{
467
+ padding: '12px 28px',
468
+ background: saving ? COLOR.muted : COLOR.primary,
469
+ color: COLOR.primaryText,
470
+ border: 'none',
471
+ borderRadius: 8,
472
+ fontFamily: FONT,
473
+ fontSize: 15,
474
+ fontWeight: 600,
475
+ cursor: saving ? 'not-allowed' : 'pointer',
476
+ marginBottom: 20,
477
+ transition: 'background 0.15s',
478
+ }}
479
+ >
480
+ {saving ? 'Sparar…' : 'Spara tillgänglighet'}
481
+ </button>
482
+
483
+ {/* Booking URL — shown after successful save */}
484
+ {saved && workspaceId && (
485
+ <div
486
+ style={{
487
+ padding: '20px 24px',
488
+ background: COLOR.successBg,
489
+ border: `1px solid ${COLOR.successBorder}`,
490
+ borderRadius: 12,
491
+ }}
492
+ >
493
+ <p style={{ margin: '0 0 4px', fontWeight: 700, fontSize: 14, color: COLOR.success }}>
494
+ ✓ Tillgänglighet sparad
495
+ </p>
496
+ <p style={{ margin: '0 0 14px', fontSize: 13, color: COLOR.secondary }}>
497
+ Din bokningslänk är redo att delas med klienter.
498
+ </p>
499
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
500
+ <code
501
+ style={{
502
+ display: 'block',
503
+ padding: '8px 12px',
504
+ background: COLOR.surface,
505
+ border: `1px solid ${COLOR.border}`,
506
+ borderRadius: 6,
507
+ fontSize: 13,
508
+ color: COLOR.primary,
509
+ fontFamily: 'ui-monospace, monospace',
510
+ flex: 1,
511
+ overflow: 'hidden',
512
+ textOverflow: 'ellipsis',
513
+ whiteSpace: 'nowrap',
514
+ }}
515
+ >
516
+ {bookingUrl}
517
+ </code>
518
+ <button
519
+ onClick={handleCopyLink}
520
+ style={{
521
+ padding: '8px 16px',
522
+ background: copied ? COLOR.success : COLOR.primary,
523
+ color: COLOR.primaryText,
524
+ border: 'none',
525
+ borderRadius: 6,
526
+ fontFamily: FONT,
527
+ fontSize: 13,
528
+ fontWeight: 600,
529
+ cursor: 'pointer',
530
+ flexShrink: 0,
531
+ transition: 'background 0.15s',
532
+ whiteSpace: 'nowrap',
533
+ }}
534
+ >
535
+ {copied ? '✓ Kopierad!' : 'Kopiera länk'}
536
+ </button>
537
+ </div>
538
+ </div>
539
+ )}
540
+ </div>
541
+ )
542
+ }