@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.
- package/README.md +12 -0
- package/package.json +7 -7
- package/src/AvailabilityEditor.jsx +30 -19
- package/src/BookingForm.jsx +21 -31
- package/src/Definition.js +1 -23
- package/src/HeroCover.jsx +99 -0
- package/src/SalesSection.jsx +77 -0
- package/src/availability-setup.page.jsx +147 -397
- package/src/availability.page.jsx +25 -23
- package/src/booking-card.component.jsx +76 -215
- package/src/booking-detail.page.jsx +103 -573
- package/src/booking-page.page.jsx +4 -7
- package/src/bookings.page.jsx +62 -196
- package/src/cover.js +8 -0
- package/src/en.translations.json +213 -0
- package/src/features.js +27 -0
- package/src/home.page.jsx +47 -364
- package/src/public-booking.page.jsx +138 -636
- package/src/service-card-slot.component.jsx +48 -0
- package/src/service-detail.component.jsx +38 -0
- package/src/services.page.jsx +44 -43
- package/src/sv.translations.json +213 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 ?
|
|
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
|
-
<
|
|
112
|
-
<
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
131
|
+
if (!found) throw new Error(t('booking.bookingDetail.notFound'))
|
|
466
132
|
setBooking(found)
|
|
467
|
-
} catch (err) {
|
|
468
|
-
|
|
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
|
|
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
|
-
<
|
|
490
|
-
|
|
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
|
-
<
|
|
144
|
+
<Text color="secondary" style={{ textAlign: 'center', padding: 'var(--space-l)' }}>{t('booking.bookingDetail.loading')}</Text>
|
|
521
145
|
) : error ? (
|
|
522
|
-
<
|
|
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
|
-
<
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
<
|
|
575
|
-
<
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
</
|
|
606
|
-
</
|
|
607
|
-
|
|
608
|
-
{/* Actions card — only shown for pending or confirmed */}
|
|
168
|
+
</View>
|
|
169
|
+
</View>
|
|
609
170
|
{(booking.status === 'pending' || booking.status === 'confirmed') && (
|
|
610
|
-
<
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
<
|
|
633
|
-
<ConfirmForm
|
|
634
|
-
|
|
635
|
-
|
|
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
|
-
</
|
|
183
|
+
</View>
|
|
654
184
|
)}
|
|
655
|
-
</
|
|
185
|
+
</View>
|
|
656
186
|
) : null}
|
|
657
|
-
</
|
|
187
|
+
</View>
|
|
658
188
|
)
|
|
659
189
|
}
|