@ossy/booking 1.15.6 → 1.15.7

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.
@@ -1,34 +1,7 @@
1
1
  import React, { useState, useEffect, useCallback } from 'react'
2
2
  import { useRouter } from '@ossy/router-react'
3
+ import { View, Title, Text, Button, Badge, Alert, Textarea } from '@ossy/design-system'
3
4
 
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
5
  export const metadata = {
33
6
  id: 'booking/booking-detail',
34
7
  path: {
@@ -37,16 +10,25 @@ export const metadata = {
37
10
  },
38
11
  }
39
12
 
40
- // ---------------------------------------------------------------------------
41
- // Helpers
42
- // ---------------------------------------------------------------------------
13
+ const STATUS_VARIANT = {
14
+ pending: 'warning',
15
+ confirmed: 'success',
16
+ cancelled: 'neutral',
17
+ }
18
+
19
+ const STATUS_LABEL = {
20
+ pending: 'Väntar på bekräftelse',
21
+ confirmed: 'Bekräftad',
22
+ cancelled: 'Avbokad',
23
+ }
24
+
43
25
  const SV_MONTHS = [
44
26
  'januari', 'februari', 'mars', 'april', 'maj', 'juni',
45
27
  'juli', 'augusti', 'september', 'oktober', 'november', 'december',
46
28
  ]
47
29
  const SV_WEEKDAYS = ['Söndag', 'Måndag', 'Tisdag', 'Onsdag', 'Torsdag', 'Fredag', 'Lördag']
48
30
 
49
- function formatDateTime(iso) {
31
+ function formatDateTime (iso) {
50
32
  if (!iso) return '—'
51
33
  const d = new Date(iso)
52
34
  const weekday = SV_WEEKDAYS[d.getDay()]
@@ -64,7 +46,7 @@ function formatDateTime(iso) {
64
46
  }
65
47
  }
66
48
 
67
- function formatDuration(minutes) {
49
+ function formatDuration (minutes) {
68
50
  if (!minutes) return '—'
69
51
  if (minutes < 60) return `${minutes} minuter`
70
52
  const h = Math.floor(minutes / 60)
@@ -72,91 +54,16 @@ function formatDuration(minutes) {
72
54
  return m ? `${h} timme ${m} min` : `${h} timme`
73
55
  }
74
56
 
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 }) {
57
+ function DetailRow ({ label, value }) {
110
58
  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>
59
+ <View layout="row" gap="m" style={{ flexWrap: 'wrap' }}>
60
+ <Text weight="medium" color="secondary" style={{ width: 120, flexShrink: 0 }}>{label}</Text>
61
+ <Text style={{ flex: 1 }}>{value ?? '—'}</Text>
62
+ </View>
129
63
  )
130
64
  }
131
65
 
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 }) {
66
+ function ConfirmForm ({ booking, onSuccess, onError }) {
160
67
  const [loading, setLoading] = useState(false)
161
68
 
162
69
  const handleConfirm = async () => {
@@ -180,30 +87,13 @@ function ConfirmForm({ booking, onSuccess, onError }) {
180
87
  }
181
88
 
182
89
  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
- >
90
+ <Button variant="primary" disabled={loading} onClick={handleConfirm}>
198
91
  {loading ? 'Bekräftar…' : 'Bekräfta bokning'}
199
- </button>
92
+ </Button>
200
93
  )
201
94
  }
202
95
 
203
- // ---------------------------------------------------------------------------
204
- // Decline form
205
- // ---------------------------------------------------------------------------
206
- function DeclineForm({ booking, onSuccess, onError }) {
96
+ function DeclineForm ({ booking, onSuccess, onError }) {
207
97
  const [reason, setReason] = useState('')
208
98
  const [loading, setLoading] = useState(false)
209
99
  const [expanded, setExpanded] = useState(false)
@@ -230,110 +120,38 @@ function DeclineForm({ booking, onSuccess, onError }) {
230
120
 
231
121
  if (!expanded) {
232
122
  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
- >
123
+ <Button variant="danger" onClick={() => setExpanded(true)}>
247
124
  Avböj förfrågan
248
- </button>
125
+ </Button>
249
126
  )
250
127
  }
251
128
 
252
129
  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
130
+ <Alert variant="danger" title="Avböj bokningsförfrågan">
131
+ <View gap="s">
132
+ <Textarea
275
133
  value={reason}
276
134
  onChange={e => setReason(e.target.value)}
277
135
  rows={3}
278
136
  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
137
  />
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>
138
+ <Text variant="small" color="secondary">
139
+ Anledning (valfritt — skickas till klienten)
140
+ </Text>
141
+ <View layout="row" gap="s">
142
+ <Button variant="danger" disabled={loading} onClick={handleDecline}>
143
+ {loading ? 'Avböjer…' : 'Bekräfta avböjning'}
144
+ </Button>
145
+ <Button variant="neutral" onClick={() => setExpanded(false)}>
146
+ Avbryt
147
+ </Button>
148
+ </View>
149
+ </View>
150
+ </Alert>
330
151
  )
331
152
  }
332
153
 
333
- // ---------------------------------------------------------------------------
334
- // Cancel form
335
- // ---------------------------------------------------------------------------
336
- function CancelForm({ booking, onSuccess, onError }) {
154
+ function CancelForm ({ booking, onSuccess, onError }) {
337
155
  const [loading, setLoading] = useState(false)
338
156
  const [expanded, setExpanded] = useState(false)
339
157
 
@@ -359,85 +177,32 @@ function CancelForm({ booking, onSuccess, onError }) {
359
177
 
360
178
  if (!expanded) {
361
179
  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
- >
180
+ <Button variant="danger" onClick={() => setExpanded(true)}>
376
181
  Avboka möte
377
- </button>
182
+ </Button>
378
183
  )
379
184
  }
380
185
 
381
186
  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>
187
+ <Alert variant="danger" title="Avboka bekräftad bokning">
188
+ <View gap="s">
189
+ <Text color="secondary">
190
+ Klienten meddelas via e-post. Åtgärden kan inte ångras.
191
+ </Text>
192
+ <View layout="row" gap="s">
193
+ <Button variant="danger" disabled={loading} onClick={handleCancel}>
194
+ {loading ? 'Avbokar…' : 'Bekräfta avbokning'}
195
+ </Button>
196
+ <Button variant="neutral" onClick={() => setExpanded(false)}>
197
+ Avbryt
198
+ </Button>
199
+ </View>
200
+ </View>
201
+ </Alert>
434
202
  )
435
203
  }
436
204
 
437
- // ---------------------------------------------------------------------------
438
- // Page
439
- // ---------------------------------------------------------------------------
440
- export default function BookingDetailPage({ bookingId: bookingIdProp }) {
205
+ export default function BookingDetailPage ({ bookingId: bookingIdProp }) {
441
206
  const router = useRouter()
442
207
  const bookingId = bookingIdProp ?? router?.params?.bookingId
443
208
 
@@ -486,150 +251,59 @@ export default function BookingDetailPage({ bookingId: bookingIdProp }) {
486
251
  }
487
252
 
488
253
  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
- }}
254
+ <View
255
+ gap="m"
256
+ surface="primary"
257
+ style={{ padding: 'var(--space-m) var(--space-l)', height: '100%', overflowY: 'auto' }}
498
258
  >
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
- >
259
+ <Button variant="link" onClick={goBack}>
516
260
  ← Alla bokningar
517
- </button>
261
+ </Button>
518
262
 
519
263
  {loading ? (
520
- <Spinner />
264
+ <Text color="secondary" style={{ textAlign: 'center', padding: 'var(--space-l)' }}>
265
+ Laddar…
266
+ </Text>
521
267
  ) : 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>
268
+ <Alert variant="danger">{error}</Alert>
536
269
  ) : 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 }}>
270
+ <View gap="m" style={{ maxWidth: 680 }}>
271
+ <View gap="m" surface="secondary" roundness="m" inset="l">
272
+ <View layout="row" justifyContent="space-between" alignItems="flex-start" gap="m" style={{ flexWrap: 'wrap' }}>
273
+ <View gap="xs">
274
+ <Title variant="secondary">{booking.clientName ?? '—'}</Title>
275
+ {booking.clientEmail && <Text color="secondary">{booking.clientEmail}</Text>}
276
+ </View>
277
+ <Badge
278
+ label={STATUS_LABEL[booking.status] ?? booking.status}
279
+ variant={STATUS_VARIANT[booking.status] ?? 'neutral'}
280
+ />
281
+ </View>
282
+
283
+ <View gap="s">
571
284
  <DetailRow label="Datum och tid" value={formatDateTime(booking.startsAt)} />
572
285
  <DetailRow label="Längd" value={formatDuration(booking.duration)} />
573
286
  {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>
287
+ <View gap="xs">
288
+ <Text weight="medium" color="secondary">Meddelande</Text>
289
+ <View surface="primary" roundness="s" inset="s" style={{ borderLeft: '3px solid var(--separator)' }}>
290
+ <Text style={{ whiteSpace: 'pre-wrap' }}>{booking.clientMessage}</Text>
291
+ </View>
292
+ </View>
604
293
  )}
605
- </div>
606
- </div>
294
+ </View>
295
+ </View>
607
296
 
608
- {/* Actions card — only shown for pending or confirmed */}
609
297
  {(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 }}>
298
+ <View gap="s" surface="secondary" roundness="m" inset="l">
299
+ <Title variant="tertiary">
622
300
  {booking.status === 'pending' ? 'Hantera förfrågan' : 'Hantera bokning'}
623
- </h2>
301
+ </Title>
624
302
 
625
- {actionError && (
626
- <div style={{ fontSize: 13, color: C.errorText, fontFamily: FONT }}>
627
- {actionError}
628
- </div>
629
- )}
303
+ {actionError && <Alert variant="danger">{actionError}</Alert>}
630
304
 
631
305
  {booking.status === 'pending' && (
632
- <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
306
+ <View gap="s">
633
307
  <ConfirmForm
634
308
  booking={booking}
635
309
  onSuccess={handleSuccess}
@@ -640,7 +314,7 @@ export default function BookingDetailPage({ bookingId: bookingIdProp }) {
640
314
  onSuccess={handleSuccess}
641
315
  onError={setActionError}
642
316
  />
643
- </div>
317
+ </View>
644
318
  )}
645
319
 
646
320
  {booking.status === 'confirmed' && (
@@ -650,10 +324,10 @@ export default function BookingDetailPage({ bookingId: bookingIdProp }) {
650
324
  onError={setActionError}
651
325
  />
652
326
  )}
653
- </div>
327
+ </View>
654
328
  )}
655
- </div>
329
+ </View>
656
330
  ) : null}
657
- </div>
331
+ </View>
658
332
  )
659
333
  }