@ossy/booking 1.15.7 → 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/package.json +7 -7
- package/src/AvailabilityEditor.jsx +22 -15
- package/src/BookingForm.jsx +18 -28
- package/src/Definition.js +1 -23
- package/src/SalesSection.jsx +9 -4
- package/src/availability-setup.page.jsx +77 -110
- package/src/availability.page.jsx +25 -23
- package/src/booking-card.component.jsx +24 -33
- package/src/booking-detail.page.jsx +61 -205
- package/src/booking-page.page.jsx +4 -7
- package/src/bookings.page.jsx +32 -22
- package/src/en.translations.json +213 -0
- package/src/home.page.jsx +48 -5
- package/src/public-booking.page.jsx +81 -222
- package/src/services.page.jsx +44 -43
- package/src/sv.translations.json +213 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
|
-
import { View, Title, Text, Tags } from '@ossy/design-system'
|
|
2
|
+
import { View, Title, Text, Tags, useLocale } from '@ossy/design-system'
|
|
3
3
|
import { Definition } from './Definition.js'
|
|
4
4
|
import { moduleStatusTags } from './moduleStatus.js'
|
|
5
5
|
import { AvailabilityEditor } from './AvailabilityEditor.jsx'
|
|
@@ -12,30 +12,32 @@ export const metadata = {
|
|
|
12
12
|
},
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
const AvailabilityPage = () =>
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
15
|
+
const AvailabilityPage = () => {
|
|
16
|
+
const { t } = useLocale()
|
|
17
|
+
return (
|
|
18
|
+
<View
|
|
19
|
+
gap="m"
|
|
20
|
+
surface="primary"
|
|
21
|
+
style={{ padding: 'var(--space-m) var(--space-l)', height: '100%', overflowY: 'auto' }}
|
|
22
|
+
>
|
|
23
|
+
<View inset="s" gap="s">
|
|
24
|
+
<View layout="row" gap="s" alignItems="center">
|
|
25
|
+
<Title>{Definition.title} — {t('booking.availability.titleSuffix')}</Title>
|
|
26
|
+
{moduleStatusTags(Definition).length > 0 && (
|
|
27
|
+
<Tags tags={moduleStatusTags(Definition)} size="s" />
|
|
28
|
+
)}
|
|
29
|
+
</View>
|
|
28
30
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
<Text style={{ maxWidth: '400px' }}>
|
|
32
|
+
{t('booking.availability.description')}
|
|
33
|
+
</Text>
|
|
34
|
+
</View>
|
|
33
35
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
<View inset="s">
|
|
37
|
+
<AvailabilityEditor availability={[]} />
|
|
38
|
+
</View>
|
|
37
39
|
</View>
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
)
|
|
41
|
+
}
|
|
40
42
|
|
|
41
43
|
export default AvailabilityPage
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { useState } from 'react'
|
|
2
|
-
import { View, Title, Text, Button, Badge, Alert } from '@ossy/design-system'
|
|
2
|
+
import { View, Title, Text, Button, Badge, Alert, useLocale } from '@ossy/design-system'
|
|
3
3
|
|
|
4
4
|
export const metadata = { id: 'resource:booking/booking/card' }
|
|
5
5
|
|
|
@@ -9,36 +9,21 @@ const STATUS_VARIANT = {
|
|
|
9
9
|
cancelled: 'neutral',
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
pending: 'Väntar',
|
|
14
|
-
confirmed: 'Bekräftad',
|
|
15
|
-
cancelled: 'Avbokad',
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const SV_MONTHS = [
|
|
19
|
-
'jan', 'feb', 'mar', 'apr', 'maj', 'jun',
|
|
20
|
-
'jul', 'aug', 'sep', 'okt', 'nov', 'dec',
|
|
21
|
-
]
|
|
22
|
-
const SV_WEEKDAYS = ['Sön', 'Mån', 'Tis', 'Ons', 'Tor', 'Fre', 'Lör']
|
|
23
|
-
|
|
24
|
-
function formatDateTime (iso) {
|
|
12
|
+
function formatDateTime (iso, t, language) {
|
|
25
13
|
if (!iso) return '—'
|
|
26
14
|
const d = new Date(iso)
|
|
27
|
-
const
|
|
28
|
-
const day = d.getDate()
|
|
29
|
-
const month = SV_MONTHS[d.getMonth()]
|
|
30
|
-
const hh = String(d.getHours()).padStart(2, '0')
|
|
31
|
-
const mm = String(d.getMinutes()).padStart(2, '0')
|
|
15
|
+
const locale = language === 'sv' ? 'sv-SE' : 'en-GB'
|
|
32
16
|
try {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
17
|
+
return new Intl.DateTimeFormat(locale, {
|
|
18
|
+
weekday: 'short', day: 'numeric', month: 'short',
|
|
19
|
+
hour: '2-digit', minute: '2-digit', timeZoneName: 'short',
|
|
20
|
+
}).format(d)
|
|
36
21
|
} catch {
|
|
37
|
-
return
|
|
22
|
+
return d.toLocaleString(locale)
|
|
38
23
|
}
|
|
39
24
|
}
|
|
40
25
|
|
|
41
|
-
function formatDuration (minutes) {
|
|
26
|
+
function formatDuration (minutes, t) {
|
|
42
27
|
if (!minutes) return '—'
|
|
43
28
|
if (minutes < 60) return `${minutes} min`
|
|
44
29
|
const h = Math.floor(minutes / 60)
|
|
@@ -51,21 +36,27 @@ function truncate (text, max = 120) {
|
|
|
51
36
|
return text.length > max ? `${text.slice(0, max).trimEnd()}…` : text
|
|
52
37
|
}
|
|
53
38
|
|
|
54
|
-
/** Booking card view — registered at `resource:booking/booking/card`. */
|
|
55
39
|
export function BookingCard ({ booking, resource, onConfirm, onDecline, onCancel, onView }) {
|
|
40
|
+
const { t, language } = useLocale()
|
|
56
41
|
const data = booking ?? resource?.content ?? resource
|
|
57
42
|
if (!data) return null
|
|
58
43
|
|
|
59
44
|
const [loadingAction, setLoadingAction] = useState(null)
|
|
60
45
|
const [error, setError] = useState(null)
|
|
61
46
|
|
|
47
|
+
const statusLabel = {
|
|
48
|
+
pending: t('booking.card.status.pending'),
|
|
49
|
+
confirmed: t('booking.card.status.confirmed'),
|
|
50
|
+
cancelled: t('booking.card.status.cancelled'),
|
|
51
|
+
}
|
|
52
|
+
|
|
62
53
|
const handle = async (actionFn, actionName) => {
|
|
63
54
|
setLoadingAction(actionName)
|
|
64
55
|
setError(null)
|
|
65
56
|
try {
|
|
66
57
|
await actionFn(data)
|
|
67
58
|
} catch (err) {
|
|
68
|
-
setError(err?.message ?? '
|
|
59
|
+
setError(err?.message ?? t('booking.card.errorGeneric'))
|
|
69
60
|
} finally {
|
|
70
61
|
setLoadingAction(null)
|
|
71
62
|
}
|
|
@@ -81,7 +72,7 @@ export function BookingCard ({ booking, resource, onConfirm, onDecline, onCancel
|
|
|
81
72
|
<View layout="row" gap="s" alignItems="center" style={{ flexWrap: 'wrap' }}>
|
|
82
73
|
<Title variant="tertiary">{data.clientName ?? '—'}</Title>
|
|
83
74
|
<Badge
|
|
84
|
-
label={
|
|
75
|
+
label={statusLabel[data.status] ?? data.status}
|
|
85
76
|
variant={STATUS_VARIANT[data.status] ?? 'neutral'}
|
|
86
77
|
size="s"
|
|
87
78
|
/>
|
|
@@ -91,14 +82,14 @@ export function BookingCard ({ booking, resource, onConfirm, onDecline, onCancel
|
|
|
91
82
|
|
|
92
83
|
{onView && (
|
|
93
84
|
<Button variant="link" onClick={() => onView(data)}>
|
|
94
|
-
|
|
85
|
+
{t('booking.card.view')}
|
|
95
86
|
</Button>
|
|
96
87
|
)}
|
|
97
88
|
</View>
|
|
98
89
|
|
|
99
90
|
<View layout="row" gap="l" style={{ flexWrap: 'wrap' }}>
|
|
100
|
-
<Text size="s" color="secondary">{formatDateTime(data.startsAt)}</Text>
|
|
101
|
-
<Text size="s" color="secondary">{formatDuration(data.duration)}</Text>
|
|
91
|
+
<Text size="s" color="secondary">{formatDateTime(data.startsAt, t, language)}</Text>
|
|
92
|
+
<Text size="s" color="secondary">{formatDuration(data.duration, t)}</Text>
|
|
102
93
|
</View>
|
|
103
94
|
|
|
104
95
|
{data.clientMessage && (
|
|
@@ -115,7 +106,7 @@ export function BookingCard ({ booking, resource, onConfirm, onDecline, onCancel
|
|
|
115
106
|
disabled={loadingAction === 'confirm'}
|
|
116
107
|
onClick={() => handle(onConfirm, 'confirm')}
|
|
117
108
|
>
|
|
118
|
-
{loadingAction === 'confirm' ? '
|
|
109
|
+
{loadingAction === 'confirm' ? t('booking.card.waiting') : t('booking.card.confirm')}
|
|
119
110
|
</Button>
|
|
120
111
|
)}
|
|
121
112
|
{isPending && onDecline && (
|
|
@@ -124,7 +115,7 @@ export function BookingCard ({ booking, resource, onConfirm, onDecline, onCancel
|
|
|
124
115
|
disabled={loadingAction === 'decline'}
|
|
125
116
|
onClick={() => handle(onDecline, 'decline')}
|
|
126
117
|
>
|
|
127
|
-
{loadingAction === 'decline' ? '
|
|
118
|
+
{loadingAction === 'decline' ? t('booking.card.waiting') : t('booking.card.decline')}
|
|
128
119
|
</Button>
|
|
129
120
|
)}
|
|
130
121
|
{isConfirmed && onCancel && (
|
|
@@ -133,7 +124,7 @@ export function BookingCard ({ booking, resource, onConfirm, onDecline, onCancel
|
|
|
133
124
|
disabled={loadingAction === 'cancel'}
|
|
134
125
|
onClick={() => handle(onCancel, 'cancel')}
|
|
135
126
|
>
|
|
136
|
-
{loadingAction === 'cancel' ? '
|
|
127
|
+
{loadingAction === 'cancel' ? t('booking.card.waiting') : t('booking.card.cancel')}
|
|
137
128
|
</Button>
|
|
138
129
|
)}
|
|
139
130
|
</View>
|
|
@@ -1,57 +1,30 @@
|
|
|
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 } from '@ossy/design-system'
|
|
3
|
+
import { View, Title, Text, Button, Badge, Alert, Textarea, useLocale } from '@ossy/design-system'
|
|
4
4
|
|
|
5
5
|
export const metadata = {
|
|
6
6
|
id: 'booking/booking-detail',
|
|
7
|
-
path: {
|
|
8
|
-
sv: '/bokningar/:bookingId',
|
|
9
|
-
en: '/bookings/:bookingId',
|
|
10
|
-
},
|
|
7
|
+
path: { sv: '/bokningar/:bookingId', en: '/bookings/:bookingId' },
|
|
11
8
|
}
|
|
12
9
|
|
|
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
|
-
|
|
25
|
-
const SV_MONTHS = [
|
|
26
|
-
'januari', 'februari', 'mars', 'april', 'maj', 'juni',
|
|
27
|
-
'juli', 'augusti', 'september', 'oktober', 'november', 'december',
|
|
28
|
-
]
|
|
29
|
-
const SV_WEEKDAYS = ['Söndag', 'Måndag', 'Tisdag', 'Onsdag', 'Torsdag', 'Fredag', 'Lördag']
|
|
10
|
+
const STATUS_VARIANT = { pending: 'warning', confirmed: 'success', cancelled: 'neutral' }
|
|
30
11
|
|
|
31
|
-
function formatDateTime (iso) {
|
|
12
|
+
function formatDateTime (iso, language) {
|
|
32
13
|
if (!iso) return '—'
|
|
33
|
-
const
|
|
34
|
-
const weekday = SV_WEEKDAYS[d.getDay()]
|
|
35
|
-
const day = d.getDate()
|
|
36
|
-
const month = SV_MONTHS[d.getMonth()]
|
|
37
|
-
const year = d.getFullYear()
|
|
38
|
-
const hh = String(d.getHours()).padStart(2, '0')
|
|
39
|
-
const mm = String(d.getMinutes()).padStart(2, '0')
|
|
14
|
+
const locale = language === 'sv' ? 'sv-SE' : 'en-GB'
|
|
40
15
|
try {
|
|
41
|
-
|
|
42
|
-
.formatToParts(d).find(p => p.type === 'timeZoneName')?.value ?? ''
|
|
43
|
-
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))
|
|
44
17
|
} catch {
|
|
45
|
-
return
|
|
18
|
+
return new Date(iso).toLocaleString(locale)
|
|
46
19
|
}
|
|
47
20
|
}
|
|
48
21
|
|
|
49
|
-
function formatDuration (minutes) {
|
|
22
|
+
function formatDuration (minutes, t) {
|
|
50
23
|
if (!minutes) return '—'
|
|
51
|
-
if (minutes < 60) return
|
|
24
|
+
if (minutes < 60) return t('booking.bookingDetail.durationMinutes', { minutes })
|
|
52
25
|
const h = Math.floor(minutes / 60)
|
|
53
26
|
const m = minutes % 60
|
|
54
|
-
return m ?
|
|
27
|
+
return m ? t('booking.bookingDetail.durationHoursMinutes', { hours: h, minutes: m }) : t('booking.bookingDetail.durationHours', { hours: h })
|
|
55
28
|
}
|
|
56
29
|
|
|
57
30
|
function DetailRow ({ label, value }) {
|
|
@@ -63,139 +36,69 @@ function DetailRow ({ label, value }) {
|
|
|
63
36
|
)
|
|
64
37
|
}
|
|
65
38
|
|
|
66
|
-
function ConfirmForm ({ booking, onSuccess, onError }) {
|
|
39
|
+
function ConfirmForm ({ booking, onSuccess, onError, t }) {
|
|
67
40
|
const [loading, setLoading] = useState(false)
|
|
68
|
-
|
|
69
41
|
const handleConfirm = async () => {
|
|
70
42
|
setLoading(true)
|
|
71
43
|
try {
|
|
72
|
-
const res = await fetch('/actions/booking/confirm', {
|
|
73
|
-
|
|
74
|
-
headers: { 'Content-Type': 'application/json' },
|
|
75
|
-
body: JSON.stringify({ bookingId: booking.id }),
|
|
76
|
-
})
|
|
77
|
-
if (!res.ok) {
|
|
78
|
-
const data = await res.json().catch(() => ({}))
|
|
79
|
-
throw new Error(data?.message ?? 'Bekräftning misslyckades')
|
|
80
|
-
}
|
|
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')) }
|
|
81
46
|
onSuccess()
|
|
82
|
-
} catch (err) {
|
|
83
|
-
onError(err.message)
|
|
84
|
-
} finally {
|
|
85
|
-
setLoading(false)
|
|
86
|
-
}
|
|
47
|
+
} catch (err) { onError(err.message) } finally { setLoading(false) }
|
|
87
48
|
}
|
|
88
|
-
|
|
89
49
|
return (
|
|
90
50
|
<Button variant="primary" disabled={loading} onClick={handleConfirm}>
|
|
91
|
-
{loading ? '
|
|
51
|
+
{loading ? t('booking.bookingDetail.confirming') : t('booking.bookingDetail.confirm')}
|
|
92
52
|
</Button>
|
|
93
53
|
)
|
|
94
54
|
}
|
|
95
55
|
|
|
96
|
-
function DeclineForm ({ booking, onSuccess, onError }) {
|
|
56
|
+
function DeclineForm ({ booking, onSuccess, onError, t }) {
|
|
97
57
|
const [reason, setReason] = useState('')
|
|
98
58
|
const [loading, setLoading] = useState(false)
|
|
99
59
|
const [expanded, setExpanded] = useState(false)
|
|
100
|
-
|
|
101
60
|
const handleDecline = async () => {
|
|
102
61
|
setLoading(true)
|
|
103
62
|
try {
|
|
104
|
-
const res = await fetch('/actions/booking/decline', {
|
|
105
|
-
|
|
106
|
-
headers: { 'Content-Type': 'application/json' },
|
|
107
|
-
body: JSON.stringify({ bookingId: booking.id, reason: reason.trim() || null }),
|
|
108
|
-
})
|
|
109
|
-
if (!res.ok) {
|
|
110
|
-
const data = await res.json().catch(() => ({}))
|
|
111
|
-
throw new Error(data?.message ?? 'Avböjning misslyckades')
|
|
112
|
-
}
|
|
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')) }
|
|
113
65
|
onSuccess()
|
|
114
|
-
} catch (err) {
|
|
115
|
-
onError(err.message)
|
|
116
|
-
} finally {
|
|
117
|
-
setLoading(false)
|
|
118
|
-
}
|
|
66
|
+
} catch (err) { onError(err.message) } finally { setLoading(false) }
|
|
119
67
|
}
|
|
120
|
-
|
|
121
|
-
if (!expanded) {
|
|
122
|
-
return (
|
|
123
|
-
<Button variant="danger" onClick={() => setExpanded(true)}>
|
|
124
|
-
Avböj förfrågan
|
|
125
|
-
</Button>
|
|
126
|
-
)
|
|
127
|
-
}
|
|
128
|
-
|
|
68
|
+
if (!expanded) return <Button variant="danger" onClick={() => setExpanded(true)}>{t('booking.bookingDetail.decline')}</Button>
|
|
129
69
|
return (
|
|
130
|
-
<Alert variant="danger" title=
|
|
70
|
+
<Alert variant="danger" title={t('booking.bookingDetail.declineTitle')}>
|
|
131
71
|
<View gap="s">
|
|
132
|
-
<Textarea
|
|
133
|
-
|
|
134
|
-
onChange={e => setReason(e.target.value)}
|
|
135
|
-
rows={3}
|
|
136
|
-
placeholder="T.ex. Tyvärr är den begärda tiden inte längre tillgänglig…"
|
|
137
|
-
/>
|
|
138
|
-
<Text variant="small" color="secondary">
|
|
139
|
-
Anledning (valfritt — skickas till klienten)
|
|
140
|
-
</Text>
|
|
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>
|
|
141
74
|
<View layout="row" gap="s">
|
|
142
|
-
<Button variant="danger" disabled={loading} onClick={handleDecline}>
|
|
143
|
-
|
|
144
|
-
</Button>
|
|
145
|
-
<Button variant="neutral" onClick={() => setExpanded(false)}>
|
|
146
|
-
Avbryt
|
|
147
|
-
</Button>
|
|
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>
|
|
148
77
|
</View>
|
|
149
78
|
</View>
|
|
150
79
|
</Alert>
|
|
151
80
|
)
|
|
152
81
|
}
|
|
153
82
|
|
|
154
|
-
function CancelForm ({ booking, onSuccess, onError }) {
|
|
83
|
+
function CancelForm ({ booking, onSuccess, onError, t }) {
|
|
155
84
|
const [loading, setLoading] = useState(false)
|
|
156
85
|
const [expanded, setExpanded] = useState(false)
|
|
157
|
-
|
|
158
86
|
const handleCancel = async () => {
|
|
159
87
|
setLoading(true)
|
|
160
88
|
try {
|
|
161
|
-
const res = await fetch('/actions/booking/cancel', {
|
|
162
|
-
|
|
163
|
-
headers: { 'Content-Type': 'application/json' },
|
|
164
|
-
body: JSON.stringify({ bookingId: booking.id }),
|
|
165
|
-
})
|
|
166
|
-
if (!res.ok) {
|
|
167
|
-
const data = await res.json().catch(() => ({}))
|
|
168
|
-
throw new Error(data?.message ?? 'Avbokning misslyckades')
|
|
169
|
-
}
|
|
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')) }
|
|
170
91
|
onSuccess()
|
|
171
|
-
} catch (err) {
|
|
172
|
-
onError(err.message)
|
|
173
|
-
} finally {
|
|
174
|
-
setLoading(false)
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
if (!expanded) {
|
|
179
|
-
return (
|
|
180
|
-
<Button variant="danger" onClick={() => setExpanded(true)}>
|
|
181
|
-
Avboka möte
|
|
182
|
-
</Button>
|
|
183
|
-
)
|
|
92
|
+
} catch (err) { onError(err.message) } finally { setLoading(false) }
|
|
184
93
|
}
|
|
185
|
-
|
|
94
|
+
if (!expanded) return <Button variant="danger" onClick={() => setExpanded(true)}>{t('booking.bookingDetail.cancelMeeting')}</Button>
|
|
186
95
|
return (
|
|
187
|
-
<Alert variant="danger" title=
|
|
96
|
+
<Alert variant="danger" title={t('booking.bookingDetail.cancelTitle')}>
|
|
188
97
|
<View gap="s">
|
|
189
|
-
<Text color="secondary">
|
|
190
|
-
Klienten meddelas via e-post. Åtgärden kan inte ångras.
|
|
191
|
-
</Text>
|
|
98
|
+
<Text color="secondary">{t('booking.bookingDetail.cancelBody')}</Text>
|
|
192
99
|
<View layout="row" gap="s">
|
|
193
|
-
<Button variant="danger" disabled={loading} onClick={handleCancel}>
|
|
194
|
-
|
|
195
|
-
</Button>
|
|
196
|
-
<Button variant="neutral" onClick={() => setExpanded(false)}>
|
|
197
|
-
Avbryt
|
|
198
|
-
</Button>
|
|
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>
|
|
199
102
|
</View>
|
|
200
103
|
</View>
|
|
201
104
|
</Alert>
|
|
@@ -203,67 +106,42 @@ function CancelForm ({ booking, onSuccess, onError }) {
|
|
|
203
106
|
}
|
|
204
107
|
|
|
205
108
|
export default function BookingDetailPage ({ bookingId: bookingIdProp }) {
|
|
109
|
+
const { t, language } = useLocale()
|
|
206
110
|
const router = useRouter()
|
|
207
111
|
const bookingId = bookingIdProp ?? router?.params?.bookingId
|
|
208
|
-
|
|
209
112
|
const [booking, setBooking] = useState(null)
|
|
210
113
|
const [loading, setLoading] = useState(true)
|
|
211
114
|
const [error, setError] = useState(null)
|
|
212
115
|
const [actionError, setActionError] = useState(null)
|
|
213
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
|
+
|
|
214
123
|
const loadBooking = useCallback(async () => {
|
|
215
124
|
if (!bookingId) return
|
|
216
|
-
setLoading(true)
|
|
217
|
-
setError(null)
|
|
125
|
+
setLoading(true); setError(null)
|
|
218
126
|
try {
|
|
219
|
-
const res = await fetch('/actions/booking/list', {
|
|
220
|
-
|
|
221
|
-
headers: { 'Content-Type': 'application/json' },
|
|
222
|
-
body: JSON.stringify({}),
|
|
223
|
-
})
|
|
224
|
-
if (!res.ok) {
|
|
225
|
-
const data = await res.json().catch(() => ({}))
|
|
226
|
-
throw new Error(data?.message ?? `Fel ${res.status}`)
|
|
227
|
-
}
|
|
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 })) }
|
|
228
129
|
const all = await res.json()
|
|
229
130
|
const found = all.find(b => b.id === bookingId)
|
|
230
|
-
if (!found) throw new Error('
|
|
131
|
+
if (!found) throw new Error(t('booking.bookingDetail.notFound'))
|
|
231
132
|
setBooking(found)
|
|
232
|
-
} catch (err) {
|
|
233
|
-
|
|
234
|
-
} finally {
|
|
235
|
-
setLoading(false)
|
|
236
|
-
}
|
|
237
|
-
}, [bookingId])
|
|
133
|
+
} catch (err) { setError(err.message) } finally { setLoading(false) }
|
|
134
|
+
}, [bookingId, t])
|
|
238
135
|
|
|
239
|
-
useEffect(() => {
|
|
240
|
-
loadBooking()
|
|
241
|
-
}, [loadBooking])
|
|
136
|
+
useEffect(() => { loadBooking() }, [loadBooking])
|
|
242
137
|
|
|
243
|
-
const
|
|
244
|
-
setActionError(null)
|
|
245
|
-
loadBooking()
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
const goBack = () => {
|
|
249
|
-
if (window.history.length > 1) window.history.back()
|
|
250
|
-
else window.location.href = '/bokningar'
|
|
251
|
-
}
|
|
138
|
+
const goBack = () => { if (window.history.length > 1) window.history.back(); else window.location.href = '/bokningar' }
|
|
252
139
|
|
|
253
140
|
return (
|
|
254
|
-
<View
|
|
255
|
-
|
|
256
|
-
surface="primary"
|
|
257
|
-
style={{ padding: 'var(--space-m) var(--space-l)', height: '100%', overflowY: 'auto' }}
|
|
258
|
-
>
|
|
259
|
-
<Button variant="link" onClick={goBack}>
|
|
260
|
-
← Alla bokningar
|
|
261
|
-
</Button>
|
|
262
|
-
|
|
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>
|
|
263
143
|
{loading ? (
|
|
264
|
-
<Text color="secondary" style={{ textAlign: 'center', padding: 'var(--space-l)' }}>
|
|
265
|
-
Laddar…
|
|
266
|
-
</Text>
|
|
144
|
+
<Text color="secondary" style={{ textAlign: 'center', padding: 'var(--space-l)' }}>{t('booking.bookingDetail.loading')}</Text>
|
|
267
145
|
) : error ? (
|
|
268
146
|
<Alert variant="danger">{error}</Alert>
|
|
269
147
|
) : booking ? (
|
|
@@ -274,18 +152,14 @@ export default function BookingDetailPage ({ bookingId: bookingIdProp }) {
|
|
|
274
152
|
<Title variant="secondary">{booking.clientName ?? '—'}</Title>
|
|
275
153
|
{booking.clientEmail && <Text color="secondary">{booking.clientEmail}</Text>}
|
|
276
154
|
</View>
|
|
277
|
-
<Badge
|
|
278
|
-
label={STATUS_LABEL[booking.status] ?? booking.status}
|
|
279
|
-
variant={STATUS_VARIANT[booking.status] ?? 'neutral'}
|
|
280
|
-
/>
|
|
155
|
+
<Badge label={statusLabel[booking.status] ?? booking.status} variant={STATUS_VARIANT[booking.status] ?? 'neutral'} />
|
|
281
156
|
</View>
|
|
282
|
-
|
|
283
157
|
<View gap="s">
|
|
284
|
-
<DetailRow label=
|
|
285
|
-
<DetailRow label=
|
|
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)} />
|
|
286
160
|
{booking.clientMessage && (
|
|
287
161
|
<View gap="xs">
|
|
288
|
-
<Text weight="medium" color="secondary">
|
|
162
|
+
<Text weight="medium" color="secondary">{t('booking.bookingDetail.label.message')}</Text>
|
|
289
163
|
<View surface="primary" roundness="s" inset="s" style={{ borderLeft: '3px solid var(--separator)' }}>
|
|
290
164
|
<Text style={{ whiteSpace: 'pre-wrap' }}>{booking.clientMessage}</Text>
|
|
291
165
|
</View>
|
|
@@ -293,36 +167,18 @@ export default function BookingDetailPage ({ bookingId: bookingIdProp }) {
|
|
|
293
167
|
)}
|
|
294
168
|
</View>
|
|
295
169
|
</View>
|
|
296
|
-
|
|
297
170
|
{(booking.status === 'pending' || booking.status === 'confirmed') && (
|
|
298
171
|
<View gap="s" surface="secondary" roundness="m" inset="l">
|
|
299
|
-
<Title variant="tertiary">
|
|
300
|
-
{booking.status === 'pending' ? 'Hantera förfrågan' : 'Hantera bokning'}
|
|
301
|
-
</Title>
|
|
302
|
-
|
|
172
|
+
<Title variant="tertiary">{booking.status === 'pending' ? t('booking.bookingDetail.manageRequest') : t('booking.bookingDetail.manageBooking')}</Title>
|
|
303
173
|
{actionError && <Alert variant="danger">{actionError}</Alert>}
|
|
304
|
-
|
|
305
174
|
{booking.status === 'pending' && (
|
|
306
175
|
<View gap="s">
|
|
307
|
-
<ConfirmForm
|
|
308
|
-
|
|
309
|
-
onSuccess={handleSuccess}
|
|
310
|
-
onError={setActionError}
|
|
311
|
-
/>
|
|
312
|
-
<DeclineForm
|
|
313
|
-
booking={booking}
|
|
314
|
-
onSuccess={handleSuccess}
|
|
315
|
-
onError={setActionError}
|
|
316
|
-
/>
|
|
176
|
+
<ConfirmForm booking={booking} onSuccess={() => { setActionError(null); loadBooking() }} onError={setActionError} t={t} />
|
|
177
|
+
<DeclineForm booking={booking} onSuccess={() => { setActionError(null); loadBooking() }} onError={setActionError} t={t} />
|
|
317
178
|
</View>
|
|
318
179
|
)}
|
|
319
|
-
|
|
320
180
|
{booking.status === 'confirmed' && (
|
|
321
|
-
<CancelForm
|
|
322
|
-
booking={booking}
|
|
323
|
-
onSuccess={handleSuccess}
|
|
324
|
-
onError={setActionError}
|
|
325
|
-
/>
|
|
181
|
+
<CancelForm booking={booking} onSuccess={() => { setActionError(null); loadBooking() }} onError={setActionError} t={t} />
|
|
326
182
|
)}
|
|
327
183
|
</View>
|
|
328
184
|
)}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
|
-
import { View, Title, Text } from '@ossy/design-system'
|
|
2
|
+
import { View, Title, Text, useLocale } from '@ossy/design-system'
|
|
3
3
|
import { useRouter } from '@ossy/router-react'
|
|
4
4
|
import { BookingForm } from './BookingForm.jsx'
|
|
5
5
|
|
|
6
6
|
export const metadata = {
|
|
7
7
|
id: 'booking/book',
|
|
8
|
-
// Public-facing — no auth required
|
|
9
8
|
public: true,
|
|
10
9
|
path: {
|
|
11
10
|
sv: '/boka/:workspaceId',
|
|
@@ -14,6 +13,7 @@ export const metadata = {
|
|
|
14
13
|
}
|
|
15
14
|
|
|
16
15
|
const BookingPage = () => {
|
|
16
|
+
const { t } = useLocale()
|
|
17
17
|
const router = useRouter()
|
|
18
18
|
const { workspaceId } = router.params
|
|
19
19
|
|
|
@@ -24,15 +24,12 @@ const BookingPage = () => {
|
|
|
24
24
|
inset="s"
|
|
25
25
|
style={{ minHeight: '100dvh' }}
|
|
26
26
|
>
|
|
27
|
-
<title>Book a session</title>
|
|
28
|
-
|
|
29
27
|
<View inset="m" roundness="l" surface="primary" slot="content" gap="m">
|
|
30
28
|
<View gap="xs">
|
|
31
|
-
<Title>
|
|
32
|
-
<Text>
|
|
29
|
+
<Title>{t('booking.book.title')}</Title>
|
|
30
|
+
<Text>{t('booking.book.description')}</Text>
|
|
33
31
|
</View>
|
|
34
32
|
|
|
35
|
-
{/* TODO: load services for workspaceId, let client pick one */}
|
|
36
33
|
<BookingForm workspaceId={workspaceId} />
|
|
37
34
|
</View>
|
|
38
35
|
</View>
|