@ossy/booking 1.15.6 → 1.16.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.
@@ -1,659 +1,189 @@
1
- import React, { useState, useEffect, useCallback } from 'react'
1
+ import React, { useState, useEffect, useCallback, useMemo } from 'react'
2
2
  import { useRouter } from '@ossy/router-react'
3
+ import { View, Title, Text, Button, Badge, Alert, Textarea, useLocale } 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
- path: {
35
- sv: '/bokningar/:bookingId',
36
- en: '/bookings/:bookingId',
37
- },
7
+ path: { sv: '/bokningar/:bookingId', en: '/bookings/:bookingId' },
38
8
  }
39
9
 
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']
10
+ const STATUS_VARIANT = { pending: 'warning', confirmed: 'success', cancelled: 'neutral' }
48
11
 
49
- function formatDateTime(iso) {
12
+ function formatDateTime (iso, language) {
50
13
  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')
14
+ const locale = language === 'sv' ? 'sv-SE' : 'en-GB'
58
15
  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})` : ''}`
16
+ return new Intl.DateTimeFormat(locale, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short' }).format(new Date(iso))
62
17
  } catch {
63
- return `${weekday} ${day} ${month} ${year} kl. ${hh}:${mm}`
18
+ return new Date(iso).toLocaleString(locale)
64
19
  }
65
20
  }
66
21
 
67
- function formatDuration(minutes) {
22
+ function formatDuration (minutes, t) {
68
23
  if (!minutes) return '—'
69
- if (minutes < 60) return `${minutes} minuter`
24
+ if (minutes < 60) return t('booking.bookingDetail.durationMinutes', { minutes })
70
25
  const h = Math.floor(minutes / 60)
71
26
  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
- )
27
+ return m ? t('booking.bookingDetail.durationHoursMinutes', { hours: h, minutes: m }) : t('booking.bookingDetail.durationHours', { hours: h })
104
28
  }
105
29
 
106
- // ---------------------------------------------------------------------------
107
- // Detail row
108
- // ---------------------------------------------------------------------------
109
- function DetailRow({ label, value }) {
30
+ function DetailRow ({ label, value }) {
110
31
  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>
32
+ <View layout="row" gap="m" style={{ flexWrap: 'wrap' }}>
33
+ <Text weight="medium" color="secondary" style={{ width: 120, flexShrink: 0 }}>{label}</Text>
34
+ <Text style={{ flex: 1 }}>{value ?? '—'}</Text>
35
+ </View>
129
36
  )
130
37
  }
131
38
 
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 }) {
39
+ function ConfirmForm ({ booking, onSuccess, onError, t }) {
160
40
  const [loading, setLoading] = useState(false)
161
-
162
41
  const handleConfirm = async () => {
163
42
  setLoading(true)
164
43
  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
- }
44
+ const res = await fetch('/actions/booking/confirm', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bookingId: booking.id }) })
45
+ if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data?.message ?? t('booking.bookingDetail.errorConfirm')) }
174
46
  onSuccess()
175
- } catch (err) {
176
- onError(err.message)
177
- } finally {
178
- setLoading(false)
179
- }
47
+ } catch (err) { onError(err.message) } finally { setLoading(false) }
180
48
  }
181
-
182
49
  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>
50
+ <Button variant="primary" disabled={loading} onClick={handleConfirm}>
51
+ {loading ? t('booking.bookingDetail.confirming') : t('booking.bookingDetail.confirm')}
52
+ </Button>
200
53
  )
201
54
  }
202
55
 
203
- // ---------------------------------------------------------------------------
204
- // Decline form
205
- // ---------------------------------------------------------------------------
206
- function DeclineForm({ booking, onSuccess, onError }) {
56
+ function DeclineForm ({ booking, onSuccess, onError, t }) {
207
57
  const [reason, setReason] = useState('')
208
58
  const [loading, setLoading] = useState(false)
209
59
  const [expanded, setExpanded] = useState(false)
210
-
211
60
  const handleDecline = async () => {
212
61
  setLoading(true)
213
62
  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
- }
63
+ const res = await fetch('/actions/booking/decline', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bookingId: booking.id, reason: reason.trim() || null }) })
64
+ if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data?.message ?? t('booking.bookingDetail.errorDecline')) }
223
65
  onSuccess()
224
- } catch (err) {
225
- onError(err.message)
226
- } finally {
227
- setLoading(false)
228
- }
66
+ } catch (err) { onError(err.message) } finally { setLoading(false) }
229
67
  }
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
-
68
+ if (!expanded) return <Button variant="danger" onClick={() => setExpanded(true)}>{t('booking.bookingDetail.decline')}</Button>
252
69
  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>
70
+ <Alert variant="danger" title={t('booking.bookingDetail.declineTitle')}>
71
+ <View gap="s">
72
+ <Textarea value={reason} onChange={e => setReason(e.target.value)} rows={3} placeholder={t('booking.bookingDetail.declinePlaceholder')} />
73
+ <Text variant="small" color="secondary">{t('booking.bookingDetail.declineReasonHint')}</Text>
74
+ <View layout="row" gap="s">
75
+ <Button variant="danger" disabled={loading} onClick={handleDecline}>{loading ? t('booking.bookingDetail.declining') : t('booking.bookingDetail.declineConfirm')}</Button>
76
+ <Button variant="neutral" onClick={() => setExpanded(false)}>{t('booking.bookingDetail.cancelButton')}</Button>
77
+ </View>
78
+ </View>
79
+ </Alert>
330
80
  )
331
81
  }
332
82
 
333
- // ---------------------------------------------------------------------------
334
- // Cancel form
335
- // ---------------------------------------------------------------------------
336
- function CancelForm({ booking, onSuccess, onError }) {
83
+ function CancelForm ({ booking, onSuccess, onError, t }) {
337
84
  const [loading, setLoading] = useState(false)
338
85
  const [expanded, setExpanded] = useState(false)
339
-
340
86
  const handleCancel = async () => {
341
87
  setLoading(true)
342
88
  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
- }
89
+ const res = await fetch('/actions/booking/cancel', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bookingId: booking.id }) })
90
+ if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data?.message ?? t('booking.bookingDetail.errorCancel')) }
352
91
  onSuccess()
353
- } catch (err) {
354
- onError(err.message)
355
- } finally {
356
- setLoading(false)
357
- }
92
+ } catch (err) { onError(err.message) } finally { setLoading(false) }
358
93
  }
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
-
94
+ if (!expanded) return <Button variant="danger" onClick={() => setExpanded(true)}>{t('booking.bookingDetail.cancelMeeting')}</Button>
381
95
  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>
96
+ <Alert variant="danger" title={t('booking.bookingDetail.cancelTitle')}>
97
+ <View gap="s">
98
+ <Text color="secondary">{t('booking.bookingDetail.cancelBody')}</Text>
99
+ <View layout="row" gap="s">
100
+ <Button variant="danger" disabled={loading} onClick={handleCancel}>{loading ? t('booking.bookingDetail.cancelling') : t('booking.bookingDetail.cancelConfirm')}</Button>
101
+ <Button variant="neutral" onClick={() => setExpanded(false)}>{t('booking.bookingDetail.cancelButton')}</Button>
102
+ </View>
103
+ </View>
104
+ </Alert>
434
105
  )
435
106
  }
436
107
 
437
- // ---------------------------------------------------------------------------
438
- // Page
439
- // ---------------------------------------------------------------------------
440
- export default function BookingDetailPage({ bookingId: bookingIdProp }) {
108
+ export default function BookingDetailPage ({ bookingId: bookingIdProp }) {
109
+ const { t, language } = useLocale()
441
110
  const router = useRouter()
442
111
  const bookingId = bookingIdProp ?? router?.params?.bookingId
443
-
444
112
  const [booking, setBooking] = useState(null)
445
113
  const [loading, setLoading] = useState(true)
446
114
  const [error, setError] = useState(null)
447
115
  const [actionError, setActionError] = useState(null)
448
116
 
117
+ const statusLabel = useMemo(() => ({
118
+ pending: t('booking.bookingDetail.status.pending'),
119
+ confirmed: t('booking.bookingDetail.status.confirmed'),
120
+ cancelled: t('booking.bookingDetail.status.cancelled'),
121
+ }), [t])
122
+
449
123
  const loadBooking = useCallback(async () => {
450
124
  if (!bookingId) return
451
- setLoading(true)
452
- setError(null)
125
+ setLoading(true); setError(null)
453
126
  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
- }
127
+ const res = await fetch('/actions/booking/list', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) })
128
+ if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data?.message ?? t('booking.bookingDetail.errorStatus', { status: res.status })) }
463
129
  const all = await res.json()
464
130
  const found = all.find(b => b.id === bookingId)
465
- if (!found) throw new Error('Bokningen hittades inte')
131
+ if (!found) throw new Error(t('booking.bookingDetail.notFound'))
466
132
  setBooking(found)
467
- } catch (err) {
468
- setError(err.message)
469
- } finally {
470
- setLoading(false)
471
- }
472
- }, [bookingId])
133
+ } catch (err) { setError(err.message) } finally { setLoading(false) }
134
+ }, [bookingId, t])
473
135
 
474
- useEffect(() => {
475
- loadBooking()
476
- }, [loadBooking])
136
+ useEffect(() => { loadBooking() }, [loadBooking])
477
137
 
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
- }
138
+ const goBack = () => { if (window.history.length > 1) window.history.back(); else window.location.href = '/bokningar' }
487
139
 
488
140
  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
-
141
+ <View gap="m" surface="primary" style={{ padding: 'var(--space-m) var(--space-l)', height: '100%', overflowY: 'auto' }}>
142
+ <Button variant="link" onClick={goBack}>{t('booking.bookingDetail.back')}</Button>
519
143
  {loading ? (
520
- <Spinner />
144
+ <Text color="secondary" style={{ textAlign: 'center', padding: 'var(--space-l)' }}>{t('booking.bookingDetail.loading')}</Text>
521
145
  ) : 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>
146
+ <Alert variant="danger">{error}</Alert>
536
147
  ) : 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)} />
148
+ <View gap="m" style={{ maxWidth: 680 }}>
149
+ <View gap="m" surface="secondary" roundness="m" inset="l">
150
+ <View layout="row" justifyContent="space-between" alignItems="flex-start" gap="m" style={{ flexWrap: 'wrap' }}>
151
+ <View gap="xs">
152
+ <Title variant="secondary">{booking.clientName ?? '—'}</Title>
153
+ {booking.clientEmail && <Text color="secondary">{booking.clientEmail}</Text>}
154
+ </View>
155
+ <Badge label={statusLabel[booking.status] ?? booking.status} variant={STATUS_VARIANT[booking.status] ?? 'neutral'} />
156
+ </View>
157
+ <View gap="s">
158
+ <DetailRow label={t('booking.bookingDetail.label.dateTime')} value={formatDateTime(booking.startsAt, language)} />
159
+ <DetailRow label={t('booking.bookingDetail.label.duration')} value={formatDuration(booking.duration, t)} />
573
160
  {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>
161
+ <View gap="xs">
162
+ <Text weight="medium" color="secondary">{t('booking.bookingDetail.label.message')}</Text>
163
+ <View surface="primary" roundness="s" inset="s" style={{ borderLeft: '3px solid var(--separator)' }}>
164
+ <Text style={{ whiteSpace: 'pre-wrap' }}>{booking.clientMessage}</Text>
165
+ </View>
166
+ </View>
604
167
  )}
605
- </div>
606
- </div>
607
-
608
- {/* Actions card — only shown for pending or confirmed */}
168
+ </View>
169
+ </View>
609
170
  {(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
-
171
+ <View gap="s" surface="secondary" roundness="m" inset="l">
172
+ <Title variant="tertiary">{booking.status === 'pending' ? t('booking.bookingDetail.manageRequest') : t('booking.bookingDetail.manageBooking')}</Title>
173
+ {actionError && <Alert variant="danger">{actionError}</Alert>}
631
174
  {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>
175
+ <View gap="s">
176
+ <ConfirmForm booking={booking} onSuccess={() => { setActionError(null); loadBooking() }} onError={setActionError} t={t} />
177
+ <DeclineForm booking={booking} onSuccess={() => { setActionError(null); loadBooking() }} onError={setActionError} t={t} />
178
+ </View>
644
179
  )}
645
-
646
180
  {booking.status === 'confirmed' && (
647
- <CancelForm
648
- booking={booking}
649
- onSuccess={handleSuccess}
650
- onError={setActionError}
651
- />
181
+ <CancelForm booking={booking} onSuccess={() => { setActionError(null); loadBooking() }} onError={setActionError} t={t} />
652
182
  )}
653
- </div>
183
+ </View>
654
184
  )}
655
- </div>
185
+ </View>
656
186
  ) : null}
657
- </div>
187
+ </View>
658
188
  )
659
189
  }