@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,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
- <View
17
- gap="m"
18
- surface="primary"
19
- style={{ padding: 'var(--space-m) var(--space-l)', height: '100%', overflowY: 'auto' }}
20
- >
21
- <View inset="s" gap="s">
22
- <View layout="row" gap="s" alignItems="center">
23
- <Title>{Definition.title} Availability</Title>
24
- {moduleStatusTags(Definition).length > 0 && (
25
- <Tags tags={moduleStatusTags(Definition)} size="s" />
26
- )}
27
- </View>
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
- <Text style={{ maxWidth: '400px' }}>
30
- Set your weekly availability. Clients can only book slots within these windows.
31
- </Text>
32
- </View>
31
+ <Text style={{ maxWidth: '400px' }}>
32
+ {t('booking.availability.description')}
33
+ </Text>
34
+ </View>
33
35
 
34
- <View inset="s">
35
- {/* TODO: load existing availability via booking.get-availability, save via booking.set-availability */}
36
- <AvailabilityEditor availability={[]} />
36
+ <View inset="s">
37
+ <AvailabilityEditor availability={[]} />
38
+ </View>
37
39
  </View>
38
- </View>
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-card' }
4
+ export const metadata = { id: 'resource:booking/booking/card' }
4
5
 
5
- // ---------------------------------------------------------------------------
6
- // Tokens
7
- // ---------------------------------------------------------------------------
8
- const C = {
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
- const FONT = 'system-ui, -apple-system, sans-serif'
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 weekday = SV_WEEKDAYS[d.getDay()]
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
- const tz = new Intl.DateTimeFormat('sv-SE', { timeZoneName: 'short', hour: 'numeric' })
54
- .formatToParts(d).find(p => p.type === 'timeZoneName')?.value ?? ''
55
- return `${weekday} ${day} ${month} kl. ${hh}:${mm}${tz ? ` (${tz})` : ''}`
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 `${weekday} ${day} ${month} kl. ${hh}:${mm}`
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
- // Status badge
76
- // ---------------------------------------------------------------------------
77
- function StatusBadge({ status }) {
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(booking)
57
+ await actionFn(data)
165
58
  } catch (err) {
166
- setError(err?.message ?? 'Något gick fel')
59
+ setError(err?.message ?? t('booking.card.errorGeneric'))
167
60
  } finally {
168
61
  setLoadingAction(null)
169
62
  }
170
63
  }
171
64
 
172
- const isPending = booking.status === 'pending'
173
- const isConfirmed = booking.status === 'confirmed'
65
+ const isPending = data.status === 'pending'
66
+ const isConfirmed = data.status === 'confirmed'
174
67
 
175
68
  return (
176
- <div
177
- style={{
178
- background: C.surface,
179
- border: `1px solid ${C.border}`,
180
- borderRadius: 10,
181
- padding: '16px 20px',
182
- fontFamily: FONT,
183
- display: 'flex',
184
- flexDirection: 'column',
185
- gap: 12,
186
- }}
187
- >
188
- {/* Header row */}
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
- <button
204
- onClick={() => onView(booking)}
205
- style={{
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
- </div>
88
+ </View>
220
89
 
221
- {/* Booking details */}
222
- <div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px 20px', fontSize: 13, color: C.secondary }}>
223
- <span>📅 {formatDateTime(booking.startsAt)}</span>
224
- <span>⏱ {formatDuration(booking.duration)}</span>
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
- {/* Message snippet */}
228
- {booking.clientMessage && (
229
- <div
230
- style={{
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
- <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
102
+ <View layout="row" gap="s" style={{ flexWrap: 'wrap' }}>
246
103
  {isPending && onConfirm && (
247
- <ActionButton
248
- label="Bekräfta"
249
- loading={loadingAction === 'confirm'}
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
- <ActionButton
255
- label="Avböj"
256
- danger
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
- <ActionButton
263
- label="Avboka"
264
- danger
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
- </div>
130
+ </View>
270
131
  )}
271
132
 
272
133
  {error && (
273
- <div style={{ fontSize: 12, color: C.errorText }}>{error}</div>
134
+ <Alert variant="danger">{error}</Alert>
274
135
  )}
275
- </div>
136
+ </View>
276
137
  )
277
138
  }
278
139