@ossy/booking 1.15.7 → 1.16.1

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.
Files changed (46) hide show
  1. package/README.md +26 -19
  2. package/package.json +7 -7
  3. package/src/AvailabilityEditor.jsx +22 -15
  4. package/src/Definition.js +1 -23
  5. package/src/SalesSection.jsx +9 -4
  6. package/src/availability-setup.page.jsx +94 -126
  7. package/src/booking-card.component.jsx +30 -33
  8. package/src/booking-detail.page.jsx +65 -204
  9. package/src/booking-resources.js +10 -0
  10. package/src/bookings.page.jsx +54 -56
  11. package/src/cancel-booking.action.js +1 -61
  12. package/src/cancel-booking.task.js +60 -0
  13. package/src/confirm-booking.action.js +1 -82
  14. package/src/confirm-booking.task.js +81 -0
  15. package/src/create-booking.action.js +1 -168
  16. package/src/create-booking.task.js +170 -0
  17. package/src/create-service.action.js +1 -0
  18. package/src/create-service.task.js +46 -0
  19. package/src/decline-booking.action.js +1 -68
  20. package/src/decline-booking.task.js +67 -0
  21. package/src/delete-service.action.js +1 -0
  22. package/src/delete-service.task.js +41 -0
  23. package/src/en.translations.json +246 -0
  24. package/src/get-availability.action.js +1 -52
  25. package/src/get-availability.task.js +51 -0
  26. package/src/get-available-slots.action.js +1 -89
  27. package/src/get-available-slots.task.js +99 -0
  28. package/src/get-services.action.js +1 -0
  29. package/src/get-services.task.js +37 -0
  30. package/src/home.page.jsx +48 -5
  31. package/src/index.js +20 -1
  32. package/src/invoke-error-message.js +10 -0
  33. package/src/list-bookings.action.js +1 -36
  34. package/src/list-bookings.task.js +35 -0
  35. package/src/locations.js +14 -0
  36. package/src/public-booking.page.jsx +200 -245
  37. package/src/save-availability.action.js +1 -73
  38. package/src/save-availability.task.js +74 -0
  39. package/src/send-booking-reminder.task.js +2 -2
  40. package/src/services.page.jsx +167 -46
  41. package/src/sv.translations.json +246 -0
  42. package/src/update-service.action.js +1 -0
  43. package/src/update-service.task.js +52 -0
  44. package/src/BookingForm.jsx +0 -82
  45. package/src/availability.page.jsx +0 -41
  46. package/src/booking-page.page.jsx +0 -42
@@ -1,22 +1,27 @@
1
- import React, { useState, useEffect } from 'react'
1
+ import React, { useState, useEffect, useMemo } from 'react'
2
2
  import { useRouter } from '@ossy/router-react'
3
- import { View, Title, Text, Button, Input, Textarea, Alert } from '@ossy/design-system'
4
-
5
- const SV_MONTHS = [
6
- 'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni',
7
- 'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December',
3
+ import { View, Title, Text, Button, Input, Textarea, Alert, useLocale } from '@ossy/design-system'
4
+ import { useSdk } from '@ossy/sdk-react'
5
+ import { metadata as CreateBooking } from './create-booking.action.js'
6
+ import { metadata as GetServices } from './get-services.action.js'
7
+ import { metadata as GetAvailableSlots } from './get-available-slots.action.js'
8
+ import { invokeErrorMessage } from './invoke-error-message.js'
9
+
10
+ const MONTH_KEYS = [
11
+ 'publicBooking.month.january', 'publicBooking.month.february', 'publicBooking.month.march',
12
+ 'publicBooking.month.april', 'publicBooking.month.may', 'publicBooking.month.june',
13
+ 'publicBooking.month.july', 'publicBooking.month.august', 'publicBooking.month.september',
14
+ 'publicBooking.month.october', 'publicBooking.month.november', 'publicBooking.month.december',
15
+ ]
16
+ const WEEKDAY_SHORT_KEYS = [
17
+ 'publicBooking.weekday.sun', 'publicBooking.weekday.mon', 'publicBooking.weekday.tue',
18
+ 'publicBooking.weekday.wed', 'publicBooking.weekday.thu', 'publicBooking.weekday.fri', 'publicBooking.weekday.sat',
8
19
  ]
9
- const SV_WEEKDAYS_SHORT = ['Sön', 'Mån', 'Tis', 'Ons', 'Tor', 'Fre', 'Lör']
10
- const SV_WEEKDAYS_LONG = ['Söndag', 'Måndag', 'Tisdag', 'Onsdag', 'Torsdag', 'Fredag', 'Lördag']
11
20
 
12
- function formatDate (isoString) {
21
+ function formatDate (isoString, t, language) {
13
22
  const d = new Date(isoString)
14
- const weekday = SV_WEEKDAYS_LONG[d.getDay()]
15
- const day = d.getDate()
16
- const month = SV_MONTHS[d.getMonth()]
17
- const hours = String(d.getHours()).padStart(2, '0')
18
- const minutes = String(d.getMinutes()).padStart(2, '0')
19
- return `${weekday} ${day} ${month} kl. ${hours}:${minutes}`
23
+ const locale = language === 'sv' ? 'sv-SE' : 'en-GB'
24
+ return new Intl.DateTimeFormat(locale, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' }).format(d)
20
25
  }
21
26
 
22
27
  function formatTime (isoString) {
@@ -24,20 +29,10 @@ function formatTime (isoString) {
24
29
  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
25
30
  }
26
31
 
27
- function getTimezoneLabel (isoString) {
28
- try {
29
- return new Intl.DateTimeFormat('sv-SE', { timeZoneName: 'short', hour: 'numeric' })
30
- .formatToParts(new Date(isoString))
31
- .find(p => p.type === 'timeZoneName')?.value ?? ''
32
- } catch {
33
- return ''
34
- }
35
- }
36
-
37
- function formatSlotRange (startIso, durationMinutes) {
32
+ function formatSlotRange (startIso, durationMinutes, language) {
38
33
  const start = new Date(startIso)
39
34
  const end = new Date(start.getTime() + durationMinutes * 60 * 1000)
40
- const tz = getTimezoneLabel(startIso)
35
+ const tz = new Intl.DateTimeFormat(language === 'sv' ? 'sv-SE' : 'en-GB', { timeZoneName: 'short', hour: 'numeric' }).formatToParts(start).find(p => p.type === 'timeZoneName')?.value ?? ''
41
36
  const range = `${formatTime(startIso)}–${formatTime(end.toISOString())}`
42
37
  return tz ? `${range} (${tz})` : range
43
38
  }
@@ -53,21 +48,14 @@ function groupSlotsByDate (slots) {
53
48
  return groups
54
49
  }
55
50
 
56
- function getDaysInMonth (year, month) {
57
- return new Date(year, month + 1, 0).getDate()
58
- }
59
-
60
- function getFirstDayOfMonth (year, month) {
61
- const raw = new Date(year, month, 1).getDay()
62
- return (raw + 6) % 7
63
- }
51
+ function getDaysInMonth (year, month) { return new Date(year, month + 1, 0).getDate() }
52
+ function getFirstDayOfMonth (year, month) { return (new Date(year, month, 1).getDay() + 6) % 7 }
64
53
 
65
- function MonthCalendar ({ year, month, slotsGrouped, selectedDate, onSelectDate, onPrevMonth, onNextMonth }) {
54
+ function MonthCalendar ({ year, month, slotsGrouped, selectedDate, onSelectDate, onPrevMonth, onNextMonth, t }) {
66
55
  const daysInMonth = getDaysInMonth(year, month)
67
56
  const firstDayOffset = getFirstDayOfMonth(year, month)
68
57
  const today = new Date()
69
58
  const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
70
-
71
59
  const cells = []
72
60
  for (let i = 0; i < firstDayOffset; i++) cells.push(null)
73
61
  for (let d = 1; d <= daysInMonth; d++) cells.push(d)
@@ -76,38 +64,25 @@ function MonthCalendar ({ year, month, slotsGrouped, selectedDate, onSelectDate,
76
64
  <View gap="m">
77
65
  <View layout="row" justifyContent="space-between" alignItems="center">
78
66
  <Button variant="neutral" onClick={onPrevMonth}>‹</Button>
79
- <Text weight="medium">{SV_MONTHS[month]} {year}</Text>
67
+ <Text weight="medium">{t(MONTH_KEYS[month])} {year}</Text>
80
68
  <Button variant="neutral" onClick={onNextMonth}>›</Button>
81
69
  </View>
82
-
83
70
  <View layout="row" gap="xs" style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
84
- {SV_WEEKDAYS_SHORT.map((_, i) => (
85
- <Text key={i} variant="small" style={{ textAlign: 'center', opacity: 0.6 }}>
86
- {SV_WEEKDAYS_SHORT[(i + 1) % 7]}
87
- </Text>
71
+ {WEEKDAY_SHORT_KEYS.map((key, i) => (
72
+ <Text key={key} variant="small" style={{ textAlign: 'center', opacity: 0.6 }}>{t(key)}</Text>
88
73
  ))}
89
74
  </View>
90
-
91
75
  <View gap="xs" style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
92
76
  {cells.map((day, idx) => {
93
77
  if (!day) return <View key={`empty-${idx}`} />
94
-
95
78
  const dateKey = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
96
79
  const hasSlots = !!slotsGrouped[dateKey]
97
80
  const isSelected = selectedDate === dateKey
98
81
  const isPast = dateKey < todayKey
99
82
  const disabled = !hasSlots || isPast
100
-
101
83
  return (
102
- <Button
103
- key={dateKey}
104
- variant={isSelected ? 'tab-active' : hasSlots && !isPast ? 'tab' : 'neutral'}
105
- disabled={disabled}
106
- onClick={() => !disabled && onSelectDate(dateKey)}
107
- style={{ padding: '8px 4px', minWidth: 0 }}
108
- >
109
- {day}
110
- </Button>
84
+ <Button key={dateKey} variant={isSelected ? 'tab-active' : hasSlots && !isPast ? 'tab' : 'neutral'} disabled={disabled}
85
+ onClick={() => !disabled && onSelectDate(dateKey)} style={{ padding: '8px 4px', minWidth: 0 }}>{day}</Button>
111
86
  )
112
87
  })}
113
88
  </View>
@@ -115,30 +90,21 @@ function MonthCalendar ({ year, month, slotsGrouped, selectedDate, onSelectDate,
115
90
  )
116
91
  }
117
92
 
118
- function SlotPicker ({ slots, selectedSlot, onSelectSlot }) {
119
- if (!slots?.length) {
120
- return <Text color="secondary">Inga tillgängliga tider för valt datum.</Text>
121
- }
122
-
93
+ function SlotPicker ({ slots, selectedSlot, onSelectSlot, t, language }) {
94
+ if (!slots?.length) return <Text color="secondary">{t('publicBooking.noSlots')}</Text>
123
95
  return (
124
96
  <View layout="row" gap="s" style={{ flexWrap: 'wrap' }}>
125
- {slots.map(slot => {
126
- const isSelected = selectedSlot?.startAt === slot.startAt
127
- return (
128
- <Button
129
- key={slot.startAt}
130
- variant={isSelected ? 'tab-active' : 'tab'}
131
- onClick={() => onSelectSlot(slot)}
132
- >
133
- {formatSlotRange(slot.startAt, slot.duration)}
134
- </Button>
135
- )
136
- })}
97
+ {slots.map(slot => (
98
+ <Button key={slot.startAt} variant={selectedSlot?.startAt === slot.startAt ? 'tab-active' : 'tab'} onClick={() => onSelectSlot(slot)}>
99
+ {formatSlotRange(slot.startAt, slot.duration, language)}
100
+ </Button>
101
+ ))}
137
102
  </View>
138
103
  )
139
104
  }
140
105
 
141
- function BookingForm ({ slot, consultantSlug, onSuccess }) {
106
+ function BookingForm ({ slot, service, consultantSlug, onSuccess, t }) {
107
+ const sdk = useSdk();
142
108
  const [clientName, setClientName] = useState('')
143
109
  const [clientEmail, setClientEmail] = useState('')
144
110
  const [clientMessage, setClientMessage] = useState('')
@@ -148,93 +114,51 @@ function BookingForm ({ slot, consultantSlug, onSuccess }) {
148
114
  const handleSubmit = async (e) => {
149
115
  e.preventDefault()
150
116
  if (!clientName.trim() || !clientEmail.trim()) return
151
-
152
- setSubmitting(true)
153
- setError(null)
154
-
117
+ setSubmitting(true); setError(null)
155
118
  try {
156
- const res = await fetch('/actions/booking/create', {
157
- method: 'POST',
158
- headers: { 'Content-Type': 'application/json' },
159
- body: JSON.stringify({
160
- consultantId: consultantSlug,
161
- startAt: slot.startAt,
162
- duration: slot.duration,
163
- clientName: clientName.trim(),
164
- clientEmail: clientEmail.trim(),
165
- clientMessage: clientMessage.trim(),
166
- }),
119
+ await sdk.invoke(CreateBooking, {
120
+ consultantId: consultantSlug,
121
+ startAt: slot.startAt,
122
+ duration: slot.duration,
123
+ serviceId: service?.id,
124
+ clientName: clientName.trim(),
125
+ clientEmail: clientEmail.trim(),
126
+ clientMessage: clientMessage.trim(),
167
127
  })
168
-
169
- if (!res.ok) {
170
- const data = await res.json().catch(() => ({}))
171
- throw new Error(data?.message ?? `Bokning misslyckades (${res.status})`)
172
- }
173
-
174
- const booking = await res.json()
175
- onSuccess(booking)
176
- } catch (err) {
177
- setError(err.message)
178
- } finally {
179
- setSubmitting(false)
180
- }
128
+ onSuccess()
129
+ } catch (err) { setError(await invokeErrorMessage(err, t('publicBooking.form.error', { status: '' }))) } finally { setSubmitting(false) }
181
130
  }
182
131
 
183
132
  return (
184
133
  <form onSubmit={handleSubmit}>
185
134
  <View gap="m">
186
- <View gap="xs">
187
- <Text weight="medium">Ditt namn</Text>
188
- <Input
189
- type="text"
190
- required
191
- value={clientName}
192
- onChange={e => setClientName(e.target.value)}
193
- placeholder="Anna Svensson"
194
- />
195
- </View>
196
-
197
- <View gap="xs">
198
- <Text weight="medium">E-postadress</Text>
199
- <Input
200
- type="email"
201
- required
202
- value={clientEmail}
203
- onChange={e => setClientEmail(e.target.value)}
204
- placeholder="anna@exempel.se"
205
- />
206
- </View>
207
-
208
- <View gap="xs">
209
- <Text weight="medium">Meddelande (valfritt)</Text>
210
- <Textarea
211
- value={clientMessage}
212
- onChange={e => setClientMessage(e.target.value)}
213
- placeholder="Berätta gärna vad du vill prata om..."
214
- rows={3}
215
- />
216
- </View>
217
-
218
- {error && <Alert variant="danger">{error}</Alert>}
219
-
220
- <Button type="submit" variant="primary" disabled={submitting}>
221
- {submitting ? 'Skickar…' : 'Skicka förfrågan'}
222
- </Button>
135
+ <View gap="xs">
136
+ <Text weight="medium">{t('publicBooking.form.name')}</Text>
137
+ <Input type="text" required value={clientName} onChange={e => setClientName(e.target.value)} placeholder={t('publicBooking.form.namePlaceholder')} />
138
+ </View>
139
+ <View gap="xs">
140
+ <Text weight="medium">{t('publicBooking.form.email')}</Text>
141
+ <Input type="email" required value={clientEmail} onChange={e => setClientEmail(e.target.value)} placeholder={t('publicBooking.form.emailPlaceholder')} />
142
+ </View>
143
+ <View gap="xs">
144
+ <Text weight="medium">{t('publicBooking.form.message')}</Text>
145
+ <Textarea value={clientMessage} onChange={e => setClientMessage(e.target.value)} placeholder={t('publicBooking.form.messagePlaceholder')} rows={3} />
146
+ </View>
147
+ {error && <Alert variant="danger">{error}</Alert>}
148
+ <Button data-action={CreateBooking.id} type="submit" variant="primary" disabled={submitting}>{submitting ? t('publicBooking.form.submitting') : t('publicBooking.form.submit')}</Button>
223
149
  </View>
224
150
  </form>
225
151
  )
226
152
  }
227
153
 
228
- function PendingScreen ({ slot }) {
154
+ function PendingScreen ({ slot, t, language }) {
229
155
  return (
230
156
  <View gap="m" alignItems="center" style={{ textAlign: 'center' }}>
231
- <Title variant="secondary">Din förfrågan är skickad!</Title>
232
- <Text color="secondary">
233
- Konsulten bekräftar inom kort. Du får ett mejl med kalenderinbjudan när bokningen är bekräftad.
234
- </Text>
235
- <Alert variant="warning" title="Önskad tid">
236
- <Text>{formatDate(slot.startAt)}</Text>
237
- <Text color="secondary">{slot.duration} minuter</Text>
157
+ <Title variant="secondary">{t('publicBooking.pending.title')}</Title>
158
+ <Text color="secondary">{t('publicBooking.pending.body')}</Text>
159
+ <Alert variant="warning" title={t('publicBooking.pending.requestedTime')}>
160
+ <Text>{formatDate(slot.startAt, t, language)}</Text>
161
+ <Text color="secondary">{t('publicBooking.pending.duration', { minutes: slot.duration })}</Text>
238
162
  </Alert>
239
163
  </View>
240
164
  )
@@ -242,141 +166,172 @@ function PendingScreen ({ slot }) {
242
166
 
243
167
  export const metadata = {
244
168
  id: 'public-booking',
169
+ public: true,
245
170
  path: { sv: '/boka/:consultantSlug', en: '/book/:consultantSlug' },
246
171
  }
247
172
 
248
- export default function PublicBookingPage ({ consultantSlug, ...props }) {
173
+ function ServicePicker ({ services, selectedService, onSelect, t, language }) {
174
+ if (!services?.length) {
175
+ return <Text color="secondary">{t('publicBooking.noServices')}</Text>
176
+ }
177
+ const formatPrice = (cents, currency) => {
178
+ if (!cents) return t('publicBooking.serviceFree')
179
+ return new Intl.NumberFormat(language === 'sv' ? 'sv-SE' : 'en-GB', { style: 'currency', currency: currency || 'SEK' }).format(cents / 100)
180
+ }
181
+ return (
182
+ <View gap="s">
183
+ {services.map(service => (
184
+ <Button
185
+ key={service.id}
186
+ variant={selectedService?.id === service.id ? 'tab-active' : 'tab'}
187
+ onClick={() => onSelect(service)}
188
+ style={{ justifyContent: 'flex-start', textAlign: 'left' }}
189
+ >
190
+ <View gap="xs">
191
+ <Text weight="medium">{service.name}</Text>
192
+ <Text size="s" color="secondary">
193
+ {t('publicBooking.serviceMeta', { duration: service.duration, price: formatPrice(service.price, service.currency) })}
194
+ </Text>
195
+ </View>
196
+ </Button>
197
+ ))}
198
+ </View>
199
+ )
200
+ }
201
+
202
+ export default function PublicBookingPage ({ consultantSlug }) {
203
+ const { t, language } = useLocale()
204
+ const sdk = useSdk()
249
205
  const router = useRouter()
250
206
  const slug = consultantSlug ?? router?.params?.consultantSlug
251
-
252
207
  const today = new Date()
208
+ const [services, setServices] = useState([])
209
+ const [loadingServices, setLoadingServices] = useState(true)
210
+ const [servicesError, setServicesError] = useState(null)
211
+ const [selectedService, setSelectedService] = useState(null)
253
212
  const [calYear, setCalYear] = useState(today.getFullYear())
254
213
  const [calMonth, setCalMonth] = useState(today.getMonth())
255
-
256
214
  const [slots, setSlots] = useState([])
257
- const [loadingSlots, setLoadingSlots] = useState(true)
215
+ const [loadingSlots, setLoadingSlots] = useState(false)
258
216
  const [slotsError, setSlotsError] = useState(null)
259
-
260
217
  const [selectedDate, setSelectedDate] = useState(null)
261
218
  const [selectedSlot, setSelectedSlot] = useState(null)
262
- const [step, setStep] = useState('pick-slot')
219
+ const [step, setStep] = useState('pick-service')
263
220
 
264
221
  useEffect(() => {
265
222
  if (!slug) return
223
+ setLoadingServices(true)
224
+ setServicesError(null)
225
+ sdk.invoke(GetServices, { consultantId: slug })
226
+ .then(data => {
227
+ const list = Array.isArray(data) ? data : []
228
+ setServices(list)
229
+ if (list.length === 1) setSelectedService(list[0])
230
+ setLoadingServices(false)
231
+ })
232
+ .catch(() => { setServicesError(t('publicBooking.servicesError')); setLoadingServices(false) })
233
+ }, [slug, sdk, t])
266
234
 
267
- setLoadingSlots(true)
268
- setSlotsError(null)
269
-
235
+ useEffect(() => {
236
+ if (!slug || !selectedService || step === 'pick-service') return
237
+ setLoadingSlots(true); setSlotsError(null)
270
238
  const from = new Date().toISOString()
271
239
  const to = new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString()
272
-
273
- fetch(`/actions/booking/get-available-slots?consultantId=${encodeURIComponent(slug)}&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`)
274
- .then(r => r.ok ? r.json() : Promise.reject(r))
240
+ sdk.invoke(GetAvailableSlots, {
241
+ consultantId: slug,
242
+ serviceId: selectedService.id,
243
+ from,
244
+ to,
245
+ })
275
246
  .then(data => { setSlots(data); setLoadingSlots(false) })
276
- .catch(() => { setSlotsError('Kunde inte ladda tillgängliga tider.'); setLoadingSlots(false) })
277
- }, [slug])
247
+ .catch(() => { setSlotsError(t('publicBooking.slotsError')); setLoadingSlots(false) })
248
+ }, [slug, selectedService, step, sdk, t])
278
249
 
279
250
  const slotsGrouped = groupSlotsByDate(slots)
280
251
  const selectedDateSlots = selectedDate ? (slotsGrouped[selectedDate] ?? []) : []
281
252
 
282
- const handlePrevMonth = () => {
283
- if (calMonth === 0) { setCalMonth(11); setCalYear(y => y - 1) }
284
- else setCalMonth(m => m - 1)
285
- }
286
-
287
- const handleNextMonth = () => {
288
- if (calMonth === 11) { setCalMonth(0); setCalYear(y => y + 1) }
289
- else setCalMonth(m => m + 1)
290
- }
291
-
292
253
  return (
293
- <View
294
- surface="base"
295
- layout="off-center-s"
296
- inset="s"
297
- style={{ minHeight: '100dvh' }}
298
- >
254
+ <View surface="base" layout="off-center-s" inset="s" style={{ minHeight: '100dvh' }}>
299
255
  <View inset="m" roundness="l" surface="primary" slot="content" gap="l" style={{ maxWidth: 720, width: '100%' }}>
300
256
  <View gap="xs" style={{ borderBottom: '1px solid var(--separator)', paddingBottom: 'var(--space-m)' }}>
301
- <Title variant="secondary">Boka en tid</Title>
302
- <Text color="secondary">Välj ett datum och en tid som passar dig.</Text>
257
+ <Title variant="secondary">{t('publicBooking.title')}</Title>
258
+ <Text color="secondary">{t('publicBooking.description')}</Text>
303
259
  </View>
304
-
305
260
  {step === 'confirmed' && selectedSlot ? (
306
- <PendingScreen slot={selectedSlot} />
261
+ <PendingScreen slot={selectedSlot} t={t} language={language} />
307
262
  ) : step === 'fill-form' && selectedSlot ? (
308
263
  <View gap="m">
309
- <Button variant="link" onClick={() => setStep('pick-slot')}>
310
- Tillbaka
311
- </Button>
312
-
264
+ <Button variant="link" onClick={() => setStep('pick-slot')}>{t('publicBooking.back')}</Button>
265
+ {selectedService && (
266
+ <View surface="secondary" roundness="m" inset="s">
267
+ <Text weight="medium">{selectedService.name}</Text>
268
+ <Text color="secondary">{t('publicBooking.durationMin', { minutes: selectedService.duration })}</Text>
269
+ </View>
270
+ )}
313
271
  <View surface="secondary" roundness="m" inset="s">
314
- <Text weight="medium">Vald tid: {formatDate(selectedSlot.startAt)}</Text>
315
- <Text color="secondary">({selectedSlot.duration} min)</Text>
272
+ <Text weight="medium">{t('publicBooking.selectedTime', { datetime: formatDate(selectedSlot.startAt, t, language) })}</Text>
273
+ <Text color="secondary">{t('publicBooking.durationMin', { minutes: selectedSlot.duration })}</Text>
316
274
  </View>
317
-
318
- <BookingForm
319
- slot={selectedSlot}
320
- consultantSlug={slug}
321
- onSuccess={() => setStep('confirmed')}
322
- />
275
+ <BookingForm slot={selectedSlot} service={selectedService} consultantSlug={slug} onSuccess={() => setStep('confirmed')} t={t} />
323
276
  </View>
324
- ) : (
325
- <View gap="l" layout="row" style={{ flexWrap: 'wrap' }}>
326
- <View style={{ flex: 1, minWidth: 260 }}>
327
- {loadingSlots ? (
328
- <Text color="secondary" style={{ textAlign: 'center', padding: 'var(--space-l)' }}>
329
- Laddar tider…
330
- </Text>
331
- ) : slotsError ? (
332
- <Alert variant="danger">{slotsError}</Alert>
333
- ) : (
334
- <MonthCalendar
335
- year={calYear}
336
- month={calMonth}
337
- slotsGrouped={slotsGrouped}
338
- selectedDate={selectedDate}
339
- onSelectDate={(dateKey) => {
340
- setSelectedDate(dateKey)
341
- setSelectedSlot(null)
342
- }}
343
- onPrevMonth={handlePrevMonth}
344
- onNextMonth={handleNextMonth}
345
- />
346
- )}
277
+ ) : step === 'pick-slot' && selectedService ? (
278
+ <View gap="m">
279
+ <Button variant="link" onClick={() => { setStep('pick-service'); setSelectedSlot(null); setSelectedDate(null) }}>{t('publicBooking.back')}</Button>
280
+ <View surface="secondary" roundness="m" inset="s">
281
+ <Text weight="medium">{selectedService.name}</Text>
282
+ <Text color="secondary">{t('publicBooking.durationMin', { minutes: selectedService.duration })}</Text>
347
283
  </View>
348
-
349
- <View gap="m" style={{ flex: 1, minWidth: 260 }}>
350
- {selectedDate ? (
351
- <>
352
- <Title variant="tertiary">
353
- {(() => {
354
- const [y, m, d] = selectedDate.split('-').map(Number)
355
- return `${d} ${SV_MONTHS[m - 1]} ${y}`
356
- })()}
357
- </Title>
358
-
359
- <SlotPicker
360
- slots={selectedDateSlots}
361
- selectedSlot={selectedSlot}
362
- onSelectSlot={setSelectedSlot}
363
- />
364
-
365
- {selectedSlot && (
366
- <Button variant="primary" onClick={() => setStep('fill-form')}>
367
- Fortsätt →
368
- </Button>
369
- )}
370
- </>
371
- ) : (
372
- <View gap="s" alignItems="center" justifyContent="center" inset="l" style={{ minHeight: 180 }}>
373
- <Text color="secondary" style={{ textAlign: 'center' }}>
374
- Välj ett markerat datum i kalendern
375
- </Text>
376
- </View>
377
- )}
284
+ <View gap="l" layout="row" style={{ flexWrap: 'wrap' }}>
285
+ <View style={{ flex: 1, minWidth: 260 }}>
286
+ {loadingSlots ? (
287
+ <Text color="secondary" style={{ textAlign: 'center', padding: 'var(--space-l)' }}>{t('publicBooking.loadingSlots')}</Text>
288
+ ) : slotsError ? (
289
+ <Alert variant="danger">{slotsError}</Alert>
290
+ ) : (
291
+ <MonthCalendar year={calYear} month={calMonth} slotsGrouped={slotsGrouped} selectedDate={selectedDate}
292
+ onSelectDate={(dateKey) => { setSelectedDate(dateKey); setSelectedSlot(null) }}
293
+ onPrevMonth={() => { if (calMonth === 0) { setCalMonth(11); setCalYear(y => y - 1) } else setCalMonth(m => m - 1) }}
294
+ onNextMonth={() => { if (calMonth === 11) { setCalMonth(0); setCalYear(y => y + 1) } else setCalMonth(m => m + 1) }}
295
+ t={t} />
296
+ )}
297
+ </View>
298
+ <View gap="m" style={{ flex: 1, minWidth: 260 }}>
299
+ {selectedDate ? (
300
+ <>
301
+ <Title variant="tertiary">
302
+ {(() => { const [y, m, d] = selectedDate.split('-').map(Number); return `${d} ${t(MONTH_KEYS[m - 1])} ${y}` })()}
303
+ </Title>
304
+ <SlotPicker slots={selectedDateSlots} selectedSlot={selectedSlot} onSelectSlot={setSelectedSlot} t={t} language={language} />
305
+ {selectedSlot && <Button variant="primary" onClick={() => setStep('fill-form')}>{t('publicBooking.continue')}</Button>}
306
+ </>
307
+ ) : (
308
+ <View gap="s" alignItems="center" justifyContent="center" inset="l" style={{ minHeight: 180 }}>
309
+ <Text color="secondary" style={{ textAlign: 'center' }}>{t('publicBooking.pickDate')}</Text>
310
+ </View>
311
+ )}
312
+ </View>
378
313
  </View>
379
314
  </View>
315
+ ) : (
316
+ <View gap="m">
317
+ <Title variant="tertiary">{t('publicBooking.chooseService')}</Title>
318
+ {loadingServices ? (
319
+ <Text color="secondary">{t('publicBooking.loadingServices')}</Text>
320
+ ) : servicesError ? (
321
+ <Alert variant="danger">{servicesError}</Alert>
322
+ ) : (
323
+ <ServicePicker
324
+ services={services}
325
+ selectedService={selectedService}
326
+ onSelect={setSelectedService}
327
+ t={t}
328
+ language={language}
329
+ />
330
+ )}
331
+ {selectedService && (
332
+ <Button variant="primary" onClick={() => setStep('pick-slot')}>{t('publicBooking.continue')}</Button>
333
+ )}
334
+ </View>
380
335
  )}
381
336
  </View>
382
337
  </View>
@@ -1,73 +1 @@
1
- import { nanoid } from 'nanoid'
2
- import { Aggregate } from '@ossy/event-store'
3
- import { Resource, ResourcesEvents, ResourcesQueries } from '@ossy/resources'
4
-
5
- export const id = 'booking/save-availability'
6
- export const access = 'workspace'
7
-
8
- export async function run({ payload, req, log }) {
9
- const workspaceId = req?.workspaceId
10
-
11
- if (!workspaceId) {
12
- throw Object.assign(new Error('workspaceId is required'), { status: 400 })
13
- }
14
-
15
- const { weeklyWindows, sessionDurations, bufferMinutes, timezone } = payload ?? {}
16
-
17
- log?.info(`[booking/save-availability] Saving availability for workspace ${workspaceId}`)
18
-
19
- const content = {
20
- weeklyWindows: weeklyWindows ?? [],
21
- sessionDurations: Array.isArray(sessionDurations) && sessionDurations.length
22
- ? sessionDurations
23
- : [60],
24
- bufferMinutes: bufferMinutes ?? 0,
25
- timezone: timezone ?? 'Europe/Stockholm',
26
- }
27
-
28
- // Check if a config resource already exists for this workspace
29
- const existing = await ResourcesQueries.GetResources({
30
- type: '@ossy/booking/availability',
31
- belongsTo: workspaceId,
32
- })
33
-
34
- // Identify the single config resource (new format has weeklyWindows array in content)
35
- const configResource = existing.find(r => Array.isArray(r.content?.weeklyWindows))
36
-
37
- if (configResource) {
38
- log?.info(`[booking/save-availability] Updating existing resource ${configResource.id}`)
39
-
40
- await Aggregate.Of(Resource, configResource.id)
41
- .then(
42
- Aggregate.Add(
43
- ResourcesEvents.ContentUpdated({
44
- createdBy: req?.userId ?? 'system',
45
- content,
46
- }),
47
- ),
48
- )
49
- .then(Aggregate.Save())
50
-
51
- return { id: configResource.id, content }
52
- }
53
-
54
- // No config resource found — create a new one
55
- const resourceId = nanoid()
56
- log?.info(`[booking/save-availability] Creating new resource ${resourceId}`)
57
-
58
- const event = ResourcesEvents.Created({
59
- aggregateId: resourceId,
60
- type: '@ossy/booking/availability',
61
- createdBy: req?.userId ?? 'system',
62
- belongsTo: workspaceId,
63
- location: '/availability/',
64
- name: `availability-config.json`,
65
- content,
66
- })
67
-
68
- await Aggregate.Of(Resource, event).then(Aggregate.View())
69
-
70
- log?.info(`[booking/save-availability] Resource ${resourceId} created`)
71
-
72
- return { id: resourceId, content }
73
- }
1
+ export const metadata = { id: 'booking/save-availability', access: 'workspace' }