@ossy/booking 1.13.2 → 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.
@@ -0,0 +1,659 @@
1
+ import React, { useState, useEffect, useCallback } from 'react'
2
+ import { useRouter } from '@ossy/router-react'
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Tokens
6
+ // ---------------------------------------------------------------------------
7
+ const C = {
8
+ bg: '#fafafa',
9
+ surface: '#ffffff',
10
+ border: '#e4e4e7',
11
+ primary: '#111111',
12
+ secondary: '#52525b',
13
+ muted: '#a1a1aa',
14
+ pendingBg: '#fffbeb',
15
+ pendingText: '#92400e',
16
+ pendingBorder: '#fde68a',
17
+ confirmedBg: '#f0fdf4',
18
+ confirmedText: '#166534',
19
+ confirmedBorder: '#bbf7d0',
20
+ cancelledBg: '#f4f4f5',
21
+ cancelledText: '#52525b',
22
+ cancelledBorder: '#e4e4e7',
23
+ errorText: '#dc2626',
24
+ dangerBorder: '#fca5a5',
25
+ inputBorder: '#d4d4d8',
26
+ }
27
+ const FONT = 'system-ui, -apple-system, sans-serif'
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Page metadata
31
+ // ---------------------------------------------------------------------------
32
+ export const metadata = {
33
+ id: 'booking/booking-detail',
34
+ path: {
35
+ sv: '/bokningar/:bookingId',
36
+ en: '/bookings/:bookingId',
37
+ },
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Helpers
42
+ // ---------------------------------------------------------------------------
43
+ const SV_MONTHS = [
44
+ 'januari', 'februari', 'mars', 'april', 'maj', 'juni',
45
+ 'juli', 'augusti', 'september', 'oktober', 'november', 'december',
46
+ ]
47
+ const SV_WEEKDAYS = ['Söndag', 'Måndag', 'Tisdag', 'Onsdag', 'Torsdag', 'Fredag', 'Lördag']
48
+
49
+ function formatDateTime(iso) {
50
+ if (!iso) return '—'
51
+ const d = new Date(iso)
52
+ const weekday = SV_WEEKDAYS[d.getDay()]
53
+ const day = d.getDate()
54
+ const month = SV_MONTHS[d.getMonth()]
55
+ const year = d.getFullYear()
56
+ const hh = String(d.getHours()).padStart(2, '0')
57
+ const mm = String(d.getMinutes()).padStart(2, '0')
58
+ try {
59
+ const tz = new Intl.DateTimeFormat('sv-SE', { timeZoneName: 'short', hour: 'numeric' })
60
+ .formatToParts(d).find(p => p.type === 'timeZoneName')?.value ?? ''
61
+ return `${weekday} ${day} ${month} ${year} kl. ${hh}:${mm}${tz ? ` (${tz})` : ''}`
62
+ } catch {
63
+ return `${weekday} ${day} ${month} ${year} kl. ${hh}:${mm}`
64
+ }
65
+ }
66
+
67
+ function formatDuration(minutes) {
68
+ if (!minutes) return '—'
69
+ if (minutes < 60) return `${minutes} minuter`
70
+ const h = Math.floor(minutes / 60)
71
+ const m = minutes % 60
72
+ return m ? `${h} timme ${m} min` : `${h} timme`
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Status badge
77
+ // ---------------------------------------------------------------------------
78
+ function StatusBadge({ status }) {
79
+ const variants = {
80
+ pending: { bg: C.pendingBg, color: C.pendingText, border: C.pendingBorder, label: 'Väntar på bekräftelse' },
81
+ confirmed: { bg: C.confirmedBg, color: C.confirmedText, border: C.confirmedBorder, label: 'Bekräftad' },
82
+ cancelled: { bg: C.cancelledBg, color: C.cancelledText, border: C.cancelledBorder, label: 'Avbokad' },
83
+ }
84
+ const v = variants[status] ?? variants.cancelled
85
+
86
+ return (
87
+ <span
88
+ style={{
89
+ display: 'inline-flex',
90
+ alignItems: 'center',
91
+ padding: '4px 12px',
92
+ borderRadius: 999,
93
+ fontSize: 13,
94
+ fontWeight: 600,
95
+ fontFamily: FONT,
96
+ background: v.bg,
97
+ color: v.color,
98
+ border: `1px solid ${v.border}`,
99
+ }}
100
+ >
101
+ {v.label}
102
+ </span>
103
+ )
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Detail row
108
+ // ---------------------------------------------------------------------------
109
+ function DetailRow({ label, value }) {
110
+ return (
111
+ <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
112
+ <span
113
+ style={{
114
+ width: 120,
115
+ flexShrink: 0,
116
+ fontSize: 13,
117
+ fontWeight: 600,
118
+ color: C.secondary,
119
+ fontFamily: FONT,
120
+ paddingTop: 1,
121
+ }}
122
+ >
123
+ {label}
124
+ </span>
125
+ <span style={{ fontSize: 14, color: C.primary, fontFamily: FONT, flex: 1 }}>
126
+ {value ?? '—'}
127
+ </span>
128
+ </div>
129
+ )
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Spinner
134
+ // ---------------------------------------------------------------------------
135
+ function Spinner() {
136
+ return (
137
+ <>
138
+ <style href="booking/detail-spinner" precedence="low">
139
+ {`@keyframes booking-detail-spin { to { transform: rotate(360deg) } }`}
140
+ </style>
141
+ <div
142
+ style={{
143
+ width: 22,
144
+ height: 22,
145
+ border: '3px solid #e4e4e7',
146
+ borderTopColor: C.primary,
147
+ borderRadius: '50%',
148
+ animation: 'booking-detail-spin 0.7s linear infinite',
149
+ margin: '48px auto',
150
+ }}
151
+ />
152
+ </>
153
+ )
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Confirm form
158
+ // ---------------------------------------------------------------------------
159
+ function ConfirmForm({ booking, onSuccess, onError }) {
160
+ const [loading, setLoading] = useState(false)
161
+
162
+ const handleConfirm = async () => {
163
+ setLoading(true)
164
+ try {
165
+ const res = await fetch('/actions/booking/confirm', {
166
+ method: 'POST',
167
+ headers: { 'Content-Type': 'application/json' },
168
+ body: JSON.stringify({ bookingId: booking.id }),
169
+ })
170
+ if (!res.ok) {
171
+ const data = await res.json().catch(() => ({}))
172
+ throw new Error(data?.message ?? 'Bekräftning misslyckades')
173
+ }
174
+ onSuccess()
175
+ } catch (err) {
176
+ onError(err.message)
177
+ } finally {
178
+ setLoading(false)
179
+ }
180
+ }
181
+
182
+ return (
183
+ <button
184
+ onClick={handleConfirm}
185
+ disabled={loading}
186
+ style={{
187
+ padding: '10px 20px',
188
+ background: loading ? C.muted : C.primary,
189
+ color: '#ffffff',
190
+ border: 'none',
191
+ borderRadius: 8,
192
+ fontFamily: FONT,
193
+ fontSize: 14,
194
+ fontWeight: 600,
195
+ cursor: loading ? 'not-allowed' : 'pointer',
196
+ }}
197
+ >
198
+ {loading ? 'Bekräftar…' : 'Bekräfta bokning'}
199
+ </button>
200
+ )
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // Decline form
205
+ // ---------------------------------------------------------------------------
206
+ function DeclineForm({ booking, onSuccess, onError }) {
207
+ const [reason, setReason] = useState('')
208
+ const [loading, setLoading] = useState(false)
209
+ const [expanded, setExpanded] = useState(false)
210
+
211
+ const handleDecline = async () => {
212
+ setLoading(true)
213
+ try {
214
+ const res = await fetch('/actions/booking/decline', {
215
+ method: 'POST',
216
+ headers: { 'Content-Type': 'application/json' },
217
+ body: JSON.stringify({ bookingId: booking.id, reason: reason.trim() || null }),
218
+ })
219
+ if (!res.ok) {
220
+ const data = await res.json().catch(() => ({}))
221
+ throw new Error(data?.message ?? 'Avböjning misslyckades')
222
+ }
223
+ onSuccess()
224
+ } catch (err) {
225
+ onError(err.message)
226
+ } finally {
227
+ setLoading(false)
228
+ }
229
+ }
230
+
231
+ if (!expanded) {
232
+ return (
233
+ <button
234
+ onClick={() => setExpanded(true)}
235
+ style={{
236
+ padding: '10px 20px',
237
+ background: 'transparent',
238
+ color: C.errorText,
239
+ border: `1px solid ${C.dangerBorder}`,
240
+ borderRadius: 8,
241
+ fontFamily: FONT,
242
+ fontSize: 14,
243
+ fontWeight: 600,
244
+ cursor: 'pointer',
245
+ }}
246
+ >
247
+ Avböj förfrågan
248
+ </button>
249
+ )
250
+ }
251
+
252
+ return (
253
+ <div
254
+ style={{
255
+ background: '#fff5f5',
256
+ border: `1px solid ${C.dangerBorder}`,
257
+ borderRadius: 10,
258
+ padding: '16px 20px',
259
+ display: 'flex',
260
+ flexDirection: 'column',
261
+ gap: 12,
262
+ }}
263
+ >
264
+ <span style={{ fontSize: 14, fontWeight: 600, color: C.errorText, fontFamily: FONT }}>
265
+ Avböj bokningsförfrågan
266
+ </span>
267
+
268
+ <div>
269
+ <label
270
+ style={{ display: 'block', fontSize: 13, color: C.secondary, fontFamily: FONT, marginBottom: 6 }}
271
+ >
272
+ Anledning (valfritt — skickas till klienten)
273
+ </label>
274
+ <textarea
275
+ value={reason}
276
+ onChange={e => setReason(e.target.value)}
277
+ rows={3}
278
+ placeholder="T.ex. Tyvärr är den begärda tiden inte längre tillgänglig…"
279
+ style={{
280
+ width: '100%',
281
+ padding: '8px 12px',
282
+ border: `1px solid ${C.inputBorder}`,
283
+ borderRadius: 6,
284
+ fontFamily: FONT,
285
+ fontSize: 13,
286
+ color: C.primary,
287
+ background: '#fff',
288
+ boxSizing: 'border-box',
289
+ resize: 'vertical',
290
+ outline: 'none',
291
+ }}
292
+ />
293
+ </div>
294
+
295
+ <div style={{ display: 'flex', gap: 8 }}>
296
+ <button
297
+ onClick={handleDecline}
298
+ disabled={loading}
299
+ style={{
300
+ padding: '8px 16px',
301
+ background: loading ? C.muted : C.errorText,
302
+ color: '#ffffff',
303
+ border: 'none',
304
+ borderRadius: 6,
305
+ fontFamily: FONT,
306
+ fontSize: 13,
307
+ fontWeight: 600,
308
+ cursor: loading ? 'not-allowed' : 'pointer',
309
+ }}
310
+ >
311
+ {loading ? 'Avböjer…' : 'Bekräfta avböjning'}
312
+ </button>
313
+ <button
314
+ onClick={() => setExpanded(false)}
315
+ style={{
316
+ padding: '8px 16px',
317
+ background: 'transparent',
318
+ color: C.secondary,
319
+ border: `1px solid ${C.border}`,
320
+ borderRadius: 6,
321
+ fontFamily: FONT,
322
+ fontSize: 13,
323
+ cursor: 'pointer',
324
+ }}
325
+ >
326
+ Avbryt
327
+ </button>
328
+ </div>
329
+ </div>
330
+ )
331
+ }
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // Cancel form
335
+ // ---------------------------------------------------------------------------
336
+ function CancelForm({ booking, onSuccess, onError }) {
337
+ const [loading, setLoading] = useState(false)
338
+ const [expanded, setExpanded] = useState(false)
339
+
340
+ const handleCancel = async () => {
341
+ setLoading(true)
342
+ try {
343
+ const res = await fetch('/actions/booking/cancel', {
344
+ method: 'POST',
345
+ headers: { 'Content-Type': 'application/json' },
346
+ body: JSON.stringify({ bookingId: booking.id }),
347
+ })
348
+ if (!res.ok) {
349
+ const data = await res.json().catch(() => ({}))
350
+ throw new Error(data?.message ?? 'Avbokning misslyckades')
351
+ }
352
+ onSuccess()
353
+ } catch (err) {
354
+ onError(err.message)
355
+ } finally {
356
+ setLoading(false)
357
+ }
358
+ }
359
+
360
+ if (!expanded) {
361
+ return (
362
+ <button
363
+ onClick={() => setExpanded(true)}
364
+ style={{
365
+ padding: '10px 20px',
366
+ background: 'transparent',
367
+ color: C.errorText,
368
+ border: `1px solid ${C.dangerBorder}`,
369
+ borderRadius: 8,
370
+ fontFamily: FONT,
371
+ fontSize: 14,
372
+ fontWeight: 600,
373
+ cursor: 'pointer',
374
+ }}
375
+ >
376
+ Avboka möte
377
+ </button>
378
+ )
379
+ }
380
+
381
+ return (
382
+ <div
383
+ style={{
384
+ background: '#fff5f5',
385
+ border: `1px solid ${C.dangerBorder}`,
386
+ borderRadius: 10,
387
+ padding: '16px 20px',
388
+ display: 'flex',
389
+ flexDirection: 'column',
390
+ gap: 12,
391
+ }}
392
+ >
393
+ <span style={{ fontSize: 14, fontWeight: 600, color: C.errorText, fontFamily: FONT }}>
394
+ Avboka bekräftad bokning
395
+ </span>
396
+ <p style={{ margin: 0, fontSize: 13, color: C.secondary, fontFamily: FONT }}>
397
+ Klienten meddelas via e-post. Åtgärden kan inte ångras.
398
+ </p>
399
+ <div style={{ display: 'flex', gap: 8 }}>
400
+ <button
401
+ onClick={handleCancel}
402
+ disabled={loading}
403
+ style={{
404
+ padding: '8px 16px',
405
+ background: loading ? C.muted : C.errorText,
406
+ color: '#ffffff',
407
+ border: 'none',
408
+ borderRadius: 6,
409
+ fontFamily: FONT,
410
+ fontSize: 13,
411
+ fontWeight: 600,
412
+ cursor: loading ? 'not-allowed' : 'pointer',
413
+ }}
414
+ >
415
+ {loading ? 'Avbokar…' : 'Bekräfta avbokning'}
416
+ </button>
417
+ <button
418
+ onClick={() => setExpanded(false)}
419
+ style={{
420
+ padding: '8px 16px',
421
+ background: 'transparent',
422
+ color: C.secondary,
423
+ border: `1px solid ${C.border}`,
424
+ borderRadius: 6,
425
+ fontFamily: FONT,
426
+ fontSize: 13,
427
+ cursor: 'pointer',
428
+ }}
429
+ >
430
+ Avbryt
431
+ </button>
432
+ </div>
433
+ </div>
434
+ )
435
+ }
436
+
437
+ // ---------------------------------------------------------------------------
438
+ // Page
439
+ // ---------------------------------------------------------------------------
440
+ export default function BookingDetailPage({ bookingId: bookingIdProp }) {
441
+ const router = useRouter()
442
+ const bookingId = bookingIdProp ?? router?.params?.bookingId
443
+
444
+ const [booking, setBooking] = useState(null)
445
+ const [loading, setLoading] = useState(true)
446
+ const [error, setError] = useState(null)
447
+ const [actionError, setActionError] = useState(null)
448
+
449
+ const loadBooking = useCallback(async () => {
450
+ if (!bookingId) return
451
+ setLoading(true)
452
+ setError(null)
453
+ try {
454
+ const res = await fetch('/actions/booking/list', {
455
+ method: 'POST',
456
+ headers: { 'Content-Type': 'application/json' },
457
+ body: JSON.stringify({}),
458
+ })
459
+ if (!res.ok) {
460
+ const data = await res.json().catch(() => ({}))
461
+ throw new Error(data?.message ?? `Fel ${res.status}`)
462
+ }
463
+ const all = await res.json()
464
+ const found = all.find(b => b.id === bookingId)
465
+ if (!found) throw new Error('Bokningen hittades inte')
466
+ setBooking(found)
467
+ } catch (err) {
468
+ setError(err.message)
469
+ } finally {
470
+ setLoading(false)
471
+ }
472
+ }, [bookingId])
473
+
474
+ useEffect(() => {
475
+ loadBooking()
476
+ }, [loadBooking])
477
+
478
+ const handleSuccess = () => {
479
+ setActionError(null)
480
+ loadBooking()
481
+ }
482
+
483
+ const goBack = () => {
484
+ if (window.history.length > 1) window.history.back()
485
+ else window.location.href = '/bokningar'
486
+ }
487
+
488
+ return (
489
+ <div
490
+ style={{
491
+ fontFamily: FONT,
492
+ height: '100%',
493
+ overflowY: 'auto',
494
+ background: C.bg,
495
+ padding: 'var(--space-m, 16px) var(--space-l, 24px)',
496
+ boxSizing: 'border-box',
497
+ }}
498
+ >
499
+ {/* Back link */}
500
+ <button
501
+ onClick={goBack}
502
+ style={{
503
+ background: 'none',
504
+ border: 'none',
505
+ cursor: 'pointer',
506
+ color: C.secondary,
507
+ fontFamily: FONT,
508
+ fontSize: 13,
509
+ padding: 0,
510
+ marginBottom: 20,
511
+ display: 'flex',
512
+ alignItems: 'center',
513
+ gap: 4,
514
+ }}
515
+ >
516
+ ← Alla bokningar
517
+ </button>
518
+
519
+ {loading ? (
520
+ <Spinner />
521
+ ) : error ? (
522
+ <div
523
+ style={{
524
+ padding: 24,
525
+ background: C.surface,
526
+ border: `1px solid ${C.border}`,
527
+ borderRadius: 12,
528
+ fontSize: 14,
529
+ color: C.errorText,
530
+ fontFamily: FONT,
531
+ textAlign: 'center',
532
+ }}
533
+ >
534
+ {error}
535
+ </div>
536
+ ) : booking ? (
537
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 680 }}>
538
+ {/* Header card */}
539
+ <div
540
+ style={{
541
+ background: C.surface,
542
+ border: `1px solid ${C.border}`,
543
+ borderRadius: 12,
544
+ padding: '24px 28px',
545
+ display: 'flex',
546
+ flexDirection: 'column',
547
+ gap: 16,
548
+ }}
549
+ >
550
+ <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
551
+ <div>
552
+ <h1 style={{ margin: 0, fontSize: 20, fontWeight: 700, color: C.primary }}>
553
+ {booking.clientName ?? '—'}
554
+ </h1>
555
+ <p style={{ margin: '4px 0 0', fontSize: 14, color: C.secondary }}>
556
+ {booking.clientEmail ?? ''}
557
+ </p>
558
+ </div>
559
+ <StatusBadge status={booking.status} />
560
+ </div>
561
+
562
+ <div
563
+ style={{
564
+ height: 1,
565
+ background: C.border,
566
+ }}
567
+ />
568
+
569
+ {/* Details */}
570
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
571
+ <DetailRow label="Datum och tid" value={formatDateTime(booking.startsAt)} />
572
+ <DetailRow label="Längd" value={formatDuration(booking.duration)} />
573
+ {booking.clientMessage && (
574
+ <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
575
+ <span
576
+ style={{
577
+ width: 120,
578
+ flexShrink: 0,
579
+ fontSize: 13,
580
+ fontWeight: 600,
581
+ color: C.secondary,
582
+ fontFamily: FONT,
583
+ paddingTop: 1,
584
+ }}
585
+ >
586
+ Meddelande
587
+ </span>
588
+ <div
589
+ style={{
590
+ flex: 1,
591
+ fontSize: 14,
592
+ color: C.primary,
593
+ fontFamily: FONT,
594
+ background: '#fafafa',
595
+ borderRadius: 6,
596
+ padding: '10px 14px',
597
+ borderLeft: `3px solid ${C.border}`,
598
+ whiteSpace: 'pre-wrap',
599
+ }}
600
+ >
601
+ {booking.clientMessage}
602
+ </div>
603
+ </div>
604
+ )}
605
+ </div>
606
+ </div>
607
+
608
+ {/* Actions card — only shown for pending or confirmed */}
609
+ {(booking.status === 'pending' || booking.status === 'confirmed') && (
610
+ <div
611
+ style={{
612
+ background: C.surface,
613
+ border: `1px solid ${C.border}`,
614
+ borderRadius: 12,
615
+ padding: '20px 28px',
616
+ display: 'flex',
617
+ flexDirection: 'column',
618
+ gap: 12,
619
+ }}
620
+ >
621
+ <h2 style={{ margin: 0, fontSize: 15, fontWeight: 600, color: C.primary }}>
622
+ {booking.status === 'pending' ? 'Hantera förfrågan' : 'Hantera bokning'}
623
+ </h2>
624
+
625
+ {actionError && (
626
+ <div style={{ fontSize: 13, color: C.errorText, fontFamily: FONT }}>
627
+ {actionError}
628
+ </div>
629
+ )}
630
+
631
+ {booking.status === 'pending' && (
632
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
633
+ <ConfirmForm
634
+ booking={booking}
635
+ onSuccess={handleSuccess}
636
+ onError={setActionError}
637
+ />
638
+ <DeclineForm
639
+ booking={booking}
640
+ onSuccess={handleSuccess}
641
+ onError={setActionError}
642
+ />
643
+ </div>
644
+ )}
645
+
646
+ {booking.status === 'confirmed' && (
647
+ <CancelForm
648
+ booking={booking}
649
+ onSuccess={handleSuccess}
650
+ onError={setActionError}
651
+ />
652
+ )}
653
+ </div>
654
+ )}
655
+ </div>
656
+ ) : null}
657
+ </div>
658
+ )
659
+ }