@ossy/booking 1.13.2 → 1.14.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.
@@ -0,0 +1,279 @@
1
+ import React, { useState } from 'react'
2
+
3
+ export const metadata = { id: 'booking/booking-card' }
4
+
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',
31
+ }
32
+
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) {
45
+ if (!iso) return '—'
46
+ 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')
52
+ 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})` : ''}`
56
+ } catch {
57
+ return `${weekday} ${day} ${month} kl. ${hh}:${mm}`
58
+ }
59
+ }
60
+
61
+ function formatDuration(minutes) {
62
+ if (!minutes) return '—'
63
+ if (minutes < 60) return `${minutes} min`
64
+ const h = Math.floor(minutes / 60)
65
+ const m = minutes % 60
66
+ return m ? `${h} h ${m} min` : `${h} h`
67
+ }
68
+
69
+ function truncate(text, max = 120) {
70
+ if (!text) return ''
71
+ return text.length > max ? `${text.slice(0, max).trimEnd()}…` : text
72
+ }
73
+
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
+ }
139
+
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
+ const [loadingAction, setLoadingAction] = useState(null)
158
+ const [error, setError] = useState(null)
159
+
160
+ const handle = async (actionFn, actionName) => {
161
+ setLoadingAction(actionName)
162
+ setError(null)
163
+ try {
164
+ await actionFn(booking)
165
+ } catch (err) {
166
+ setError(err?.message ?? 'Något gick fel')
167
+ } finally {
168
+ setLoadingAction(null)
169
+ }
170
+ }
171
+
172
+ const isPending = booking.status === 'pending'
173
+ const isConfirmed = booking.status === 'confirmed'
174
+
175
+ 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>
201
+
202
+ {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>
218
+ )}
219
+ </div>
220
+
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>
226
+
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>
241
+ )}
242
+
243
+ {/* Actions */}
244
+ {(isPending || isConfirmed) && (
245
+ <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
246
+ {isPending && onConfirm && (
247
+ <ActionButton
248
+ label="Bekräfta"
249
+ loading={loadingAction === 'confirm'}
250
+ onClick={() => handle(onConfirm, 'confirm')}
251
+ />
252
+ )}
253
+ {isPending && onDecline && (
254
+ <ActionButton
255
+ label="Avböj"
256
+ danger
257
+ loading={loadingAction === 'decline'}
258
+ onClick={() => handle(onDecline, 'decline')}
259
+ />
260
+ )}
261
+ {isConfirmed && onCancel && (
262
+ <ActionButton
263
+ label="Avboka"
264
+ danger
265
+ loading={loadingAction === 'cancel'}
266
+ onClick={() => handle(onCancel, 'cancel')}
267
+ />
268
+ )}
269
+ </div>
270
+ )}
271
+
272
+ {error && (
273
+ <div style={{ fontSize: 12, color: C.errorText }}>{error}</div>
274
+ )}
275
+ </div>
276
+ )
277
+ }
278
+
279
+ export default BookingCard