@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,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,64 +1,29 @@
|
|
|
1
1
|
import React, { useState } from 'react'
|
|
2
|
+
import { View, Title, Text, Button, Badge, Alert, useLocale } from '@ossy/design-system'
|
|
2
3
|
|
|
3
|
-
export const metadata = { id: 'booking/booking
|
|
4
|
+
export const metadata = { id: 'resource:booking/booking/card' }
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
surface: '#ffffff',
|
|
10
|
-
border: '#e4e4e7',
|
|
11
|
-
primary: '#111111',
|
|
12
|
-
secondary: '#52525b',
|
|
13
|
-
muted: '#a1a1aa',
|
|
14
|
-
accent: '#111111',
|
|
15
|
-
pendingBg: '#fffbeb',
|
|
16
|
-
pendingText: '#92400e',
|
|
17
|
-
pendingBorder: '#fde68a',
|
|
18
|
-
confirmedBg: '#f0fdf4',
|
|
19
|
-
confirmedText: '#166534',
|
|
20
|
-
confirmedBorder: '#bbf7d0',
|
|
21
|
-
cancelledBg: '#f4f4f5',
|
|
22
|
-
cancelledText: '#52525b',
|
|
23
|
-
cancelledBorder: '#e4e4e7',
|
|
24
|
-
dangerBg: '#fef2f2',
|
|
25
|
-
dangerText: '#991b1b',
|
|
26
|
-
dangerHover: '#dc2626',
|
|
27
|
-
successBg: '#f0fdf4',
|
|
28
|
-
successText: '#166534',
|
|
29
|
-
successHover: '#16a34a',
|
|
30
|
-
errorText: '#dc2626',
|
|
6
|
+
const STATUS_VARIANT = {
|
|
7
|
+
pending: 'warning',
|
|
8
|
+
confirmed: 'success',
|
|
9
|
+
cancelled: 'neutral',
|
|
31
10
|
}
|
|
32
11
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
// ---------------------------------------------------------------------------
|
|
36
|
-
// Helpers
|
|
37
|
-
// ---------------------------------------------------------------------------
|
|
38
|
-
const SV_MONTHS = [
|
|
39
|
-
'jan', 'feb', 'mar', 'apr', 'maj', 'jun',
|
|
40
|
-
'jul', 'aug', 'sep', 'okt', 'nov', 'dec',
|
|
41
|
-
]
|
|
42
|
-
const SV_WEEKDAYS = ['Sön', 'Mån', 'Tis', 'Ons', 'Tor', 'Fre', 'Lör']
|
|
43
|
-
|
|
44
|
-
function formatDateTime(iso) {
|
|
12
|
+
function formatDateTime (iso, t, language) {
|
|
45
13
|
if (!iso) return '—'
|
|
46
14
|
const d = new Date(iso)
|
|
47
|
-
const
|
|
48
|
-
const day = d.getDate()
|
|
49
|
-
const month = SV_MONTHS[d.getMonth()]
|
|
50
|
-
const hh = String(d.getHours()).padStart(2, '0')
|
|
51
|
-
const mm = String(d.getMinutes()).padStart(2, '0')
|
|
15
|
+
const locale = language === 'sv' ? 'sv-SE' : 'en-GB'
|
|
52
16
|
try {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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)
|
|
56
21
|
} catch {
|
|
57
|
-
return
|
|
22
|
+
return d.toLocaleString(locale)
|
|
58
23
|
}
|
|
59
24
|
}
|
|
60
25
|
|
|
61
|
-
function formatDuration(minutes) {
|
|
26
|
+
function formatDuration (minutes, t) {
|
|
62
27
|
if (!minutes) return '—'
|
|
63
28
|
if (minutes < 60) return `${minutes} min`
|
|
64
29
|
const h = Math.floor(minutes / 60)
|
|
@@ -66,213 +31,109 @@ function formatDuration(minutes) {
|
|
|
66
31
|
return m ? `${h} h ${m} min` : `${h} h`
|
|
67
32
|
}
|
|
68
33
|
|
|
69
|
-
function truncate(text, max = 120) {
|
|
34
|
+
function truncate (text, max = 120) {
|
|
70
35
|
if (!text) return ''
|
|
71
36
|
return text.length > max ? `${text.slice(0, max).trimEnd()}…` : text
|
|
72
37
|
}
|
|
73
38
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const variants = {
|
|
79
|
-
pending: { bg: C.pendingBg, color: C.pendingText, border: C.pendingBorder, label: 'Väntar' },
|
|
80
|
-
confirmed: { bg: C.confirmedBg, color: C.confirmedText, border: C.confirmedBorder, label: 'Bekräftad' },
|
|
81
|
-
cancelled: { bg: C.cancelledBg, color: C.cancelledText, border: C.cancelledBorder, label: 'Avbokad' },
|
|
82
|
-
}
|
|
83
|
-
const v = variants[status] ?? variants.cancelled
|
|
84
|
-
|
|
85
|
-
return (
|
|
86
|
-
<span
|
|
87
|
-
style={{
|
|
88
|
-
display: 'inline-flex',
|
|
89
|
-
alignItems: 'center',
|
|
90
|
-
padding: '2px 10px',
|
|
91
|
-
borderRadius: 999,
|
|
92
|
-
fontSize: 11,
|
|
93
|
-
fontWeight: 600,
|
|
94
|
-
fontFamily: FONT,
|
|
95
|
-
background: v.bg,
|
|
96
|
-
color: v.color,
|
|
97
|
-
border: `1px solid ${v.border}`,
|
|
98
|
-
whiteSpace: 'nowrap',
|
|
99
|
-
}}
|
|
100
|
-
>
|
|
101
|
-
{v.label}
|
|
102
|
-
</span>
|
|
103
|
-
)
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// ---------------------------------------------------------------------------
|
|
107
|
-
// Action button
|
|
108
|
-
// ---------------------------------------------------------------------------
|
|
109
|
-
function ActionButton({ label, onClick, loading, danger, disabled }) {
|
|
110
|
-
const [hovered, setHovered] = useState(false)
|
|
111
|
-
const base = {
|
|
112
|
-
display: 'inline-flex',
|
|
113
|
-
alignItems: 'center',
|
|
114
|
-
gap: 4,
|
|
115
|
-
padding: '5px 12px',
|
|
116
|
-
borderRadius: 6,
|
|
117
|
-
fontSize: 12,
|
|
118
|
-
fontWeight: 600,
|
|
119
|
-
fontFamily: FONT,
|
|
120
|
-
cursor: loading || disabled ? 'not-allowed' : 'pointer',
|
|
121
|
-
border: '1px solid',
|
|
122
|
-
transition: 'background 0.1s, color 0.1s',
|
|
123
|
-
whiteSpace: 'nowrap',
|
|
124
|
-
opacity: loading || disabled ? 0.6 : 1,
|
|
125
|
-
}
|
|
126
|
-
const style = danger
|
|
127
|
-
? {
|
|
128
|
-
...base,
|
|
129
|
-
background: hovered ? C.dangerBg : 'transparent',
|
|
130
|
-
color: C.dangerText,
|
|
131
|
-
borderColor: C.pendingBorder,
|
|
132
|
-
}
|
|
133
|
-
: {
|
|
134
|
-
...base,
|
|
135
|
-
background: hovered ? C.accent : C.primary,
|
|
136
|
-
color: '#ffffff',
|
|
137
|
-
borderColor: 'transparent',
|
|
138
|
-
}
|
|
39
|
+
export function BookingCard ({ booking, resource, onConfirm, onDecline, onCancel, onView }) {
|
|
40
|
+
const { t, language } = useLocale()
|
|
41
|
+
const data = booking ?? resource?.content ?? resource
|
|
42
|
+
if (!data) return null
|
|
139
43
|
|
|
140
|
-
return (
|
|
141
|
-
<button
|
|
142
|
-
style={style}
|
|
143
|
-
onClick={onClick}
|
|
144
|
-
disabled={loading || disabled}
|
|
145
|
-
onMouseEnter={() => setHovered(true)}
|
|
146
|
-
onMouseLeave={() => setHovered(false)}
|
|
147
|
-
>
|
|
148
|
-
{loading ? 'Väntar…' : label}
|
|
149
|
-
</button>
|
|
150
|
-
)
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// ---------------------------------------------------------------------------
|
|
154
|
-
// BookingCard
|
|
155
|
-
// ---------------------------------------------------------------------------
|
|
156
|
-
export function BookingCard({ booking, onConfirm, onDecline, onCancel, onView }) {
|
|
157
44
|
const [loadingAction, setLoadingAction] = useState(null)
|
|
158
45
|
const [error, setError] = useState(null)
|
|
159
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
|
+
|
|
160
53
|
const handle = async (actionFn, actionName) => {
|
|
161
54
|
setLoadingAction(actionName)
|
|
162
55
|
setError(null)
|
|
163
56
|
try {
|
|
164
|
-
await actionFn(
|
|
57
|
+
await actionFn(data)
|
|
165
58
|
} catch (err) {
|
|
166
|
-
setError(err?.message ?? '
|
|
59
|
+
setError(err?.message ?? t('booking.card.errorGeneric'))
|
|
167
60
|
} finally {
|
|
168
61
|
setLoadingAction(null)
|
|
169
62
|
}
|
|
170
63
|
}
|
|
171
64
|
|
|
172
|
-
const isPending =
|
|
173
|
-
const isConfirmed =
|
|
65
|
+
const isPending = data.status === 'pending'
|
|
66
|
+
const isConfirmed = data.status === 'confirmed'
|
|
174
67
|
|
|
175
68
|
return (
|
|
176
|
-
<
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }}>
|
|
190
|
-
<div style={{ flex: 1, minWidth: 0 }}>
|
|
191
|
-
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
192
|
-
<span style={{ fontSize: 15, fontWeight: 600, color: C.primary }}>
|
|
193
|
-
{booking.clientName ?? '—'}
|
|
194
|
-
</span>
|
|
195
|
-
<StatusBadge status={booking.status} />
|
|
196
|
-
</div>
|
|
197
|
-
<div style={{ fontSize: 13, color: C.secondary, marginTop: 2 }}>
|
|
198
|
-
{booking.clientEmail ?? ''}
|
|
199
|
-
</div>
|
|
200
|
-
</div>
|
|
69
|
+
<View surface="secondary" roundness="m" inset="m" gap="s">
|
|
70
|
+
<View layout="row" justifyContent="space-between" alignItems="flex-start" gap="m">
|
|
71
|
+
<View gap="xs" style={{ flex: 1, minWidth: 0 }}>
|
|
72
|
+
<View layout="row" gap="s" alignItems="center" style={{ flexWrap: 'wrap' }}>
|
|
73
|
+
<Title variant="tertiary">{data.clientName ?? '—'}</Title>
|
|
74
|
+
<Badge
|
|
75
|
+
label={statusLabel[data.status] ?? data.status}
|
|
76
|
+
variant={STATUS_VARIANT[data.status] ?? 'neutral'}
|
|
77
|
+
size="s"
|
|
78
|
+
/>
|
|
79
|
+
</View>
|
|
80
|
+
{data.clientEmail && <Text size="s" color="secondary">{data.clientEmail}</Text>}
|
|
81
|
+
</View>
|
|
201
82
|
|
|
202
83
|
{onView && (
|
|
203
|
-
<
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
background: 'none',
|
|
207
|
-
border: 'none',
|
|
208
|
-
color: C.muted,
|
|
209
|
-
fontSize: 12,
|
|
210
|
-
fontFamily: FONT,
|
|
211
|
-
cursor: 'pointer',
|
|
212
|
-
padding: '2px 4px',
|
|
213
|
-
flexShrink: 0,
|
|
214
|
-
}}
|
|
215
|
-
>
|
|
216
|
-
Visa →
|
|
217
|
-
</button>
|
|
84
|
+
<Button variant="link" onClick={() => onView(data)}>
|
|
85
|
+
{t('booking.card.view')}
|
|
86
|
+
</Button>
|
|
218
87
|
)}
|
|
219
|
-
</
|
|
88
|
+
</View>
|
|
220
89
|
|
|
221
|
-
{
|
|
222
|
-
|
|
223
|
-
<
|
|
224
|
-
|
|
225
|
-
</div>
|
|
90
|
+
<View layout="row" gap="l" style={{ flexWrap: 'wrap' }}>
|
|
91
|
+
<Text size="s" color="secondary">{formatDateTime(data.startsAt, t, language)}</Text>
|
|
92
|
+
<Text size="s" color="secondary">{formatDuration(data.duration, t)}</Text>
|
|
93
|
+
</View>
|
|
226
94
|
|
|
227
|
-
{
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
fontSize: 13,
|
|
232
|
-
color: C.secondary,
|
|
233
|
-
background: '#fafafa',
|
|
234
|
-
borderRadius: 6,
|
|
235
|
-
padding: '8px 12px',
|
|
236
|
-
borderLeft: `3px solid ${C.border}`,
|
|
237
|
-
}}
|
|
238
|
-
>
|
|
239
|
-
{truncate(booking.clientMessage)}
|
|
240
|
-
</div>
|
|
95
|
+
{data.clientMessage && (
|
|
96
|
+
<View surface="primary" roundness="s" inset="s" style={{ borderLeft: '3px solid var(--separator)' }}>
|
|
97
|
+
<Text size="s" color="secondary">{truncate(data.clientMessage)}</Text>
|
|
98
|
+
</View>
|
|
241
99
|
)}
|
|
242
100
|
|
|
243
|
-
{/* Actions */}
|
|
244
101
|
{(isPending || isConfirmed) && (
|
|
245
|
-
<
|
|
102
|
+
<View layout="row" gap="s" style={{ flexWrap: 'wrap' }}>
|
|
246
103
|
{isPending && onConfirm && (
|
|
247
|
-
<
|
|
248
|
-
|
|
249
|
-
|
|
104
|
+
<Button
|
|
105
|
+
variant="primary"
|
|
106
|
+
disabled={loadingAction === 'confirm'}
|
|
250
107
|
onClick={() => handle(onConfirm, 'confirm')}
|
|
251
|
-
|
|
108
|
+
>
|
|
109
|
+
{loadingAction === 'confirm' ? t('booking.card.waiting') : t('booking.card.confirm')}
|
|
110
|
+
</Button>
|
|
252
111
|
)}
|
|
253
112
|
{isPending && onDecline && (
|
|
254
|
-
<
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
loading={loadingAction === 'decline'}
|
|
113
|
+
<Button
|
|
114
|
+
variant="danger"
|
|
115
|
+
disabled={loadingAction === 'decline'}
|
|
258
116
|
onClick={() => handle(onDecline, 'decline')}
|
|
259
|
-
|
|
117
|
+
>
|
|
118
|
+
{loadingAction === 'decline' ? t('booking.card.waiting') : t('booking.card.decline')}
|
|
119
|
+
</Button>
|
|
260
120
|
)}
|
|
261
121
|
{isConfirmed && onCancel && (
|
|
262
|
-
<
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
loading={loadingAction === 'cancel'}
|
|
122
|
+
<Button
|
|
123
|
+
variant="danger"
|
|
124
|
+
disabled={loadingAction === 'cancel'}
|
|
266
125
|
onClick={() => handle(onCancel, 'cancel')}
|
|
267
|
-
|
|
126
|
+
>
|
|
127
|
+
{loadingAction === 'cancel' ? t('booking.card.waiting') : t('booking.card.cancel')}
|
|
128
|
+
</Button>
|
|
268
129
|
)}
|
|
269
|
-
</
|
|
130
|
+
</View>
|
|
270
131
|
)}
|
|
271
132
|
|
|
272
133
|
{error && (
|
|
273
|
-
<
|
|
134
|
+
<Alert variant="danger">{error}</Alert>
|
|
274
135
|
)}
|
|
275
|
-
</
|
|
136
|
+
</View>
|
|
276
137
|
)
|
|
277
138
|
}
|
|
278
139
|
|