@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.
@@ -1,22 +1,22 @@
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'
3
+ import { View, Title, Text, Button, Input, Textarea, Alert, useLocale } from '@ossy/design-system'
4
4
 
5
- const SV_MONTHS = [
6
- 'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni',
7
- 'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December',
5
+ const MONTH_KEYS = [
6
+ 'publicBooking.month.january', 'publicBooking.month.february', 'publicBooking.month.march',
7
+ 'publicBooking.month.april', 'publicBooking.month.may', 'publicBooking.month.june',
8
+ 'publicBooking.month.july', 'publicBooking.month.august', 'publicBooking.month.september',
9
+ 'publicBooking.month.october', 'publicBooking.month.november', 'publicBooking.month.december',
10
+ ]
11
+ const WEEKDAY_SHORT_KEYS = [
12
+ 'publicBooking.weekday.sun', 'publicBooking.weekday.mon', 'publicBooking.weekday.tue',
13
+ 'publicBooking.weekday.wed', 'publicBooking.weekday.thu', 'publicBooking.weekday.fri', 'publicBooking.weekday.sat',
8
14
  ]
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
15
 
12
- function formatDate (isoString) {
16
+ function formatDate (isoString, t, language) {
13
17
  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}`
18
+ const locale = language === 'sv' ? 'sv-SE' : 'en-GB'
19
+ return new Intl.DateTimeFormat(locale, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' }).format(d)
20
20
  }
21
21
 
22
22
  function formatTime (isoString) {
@@ -24,20 +24,10 @@ function formatTime (isoString) {
24
24
  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
25
25
  }
26
26
 
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) {
27
+ function formatSlotRange (startIso, durationMinutes, language) {
38
28
  const start = new Date(startIso)
39
29
  const end = new Date(start.getTime() + durationMinutes * 60 * 1000)
40
- const tz = getTimezoneLabel(startIso)
30
+ const tz = new Intl.DateTimeFormat(language === 'sv' ? 'sv-SE' : 'en-GB', { timeZoneName: 'short', hour: 'numeric' }).formatToParts(start).find(p => p.type === 'timeZoneName')?.value ?? ''
41
31
  const range = `${formatTime(startIso)}–${formatTime(end.toISOString())}`
42
32
  return tz ? `${range} (${tz})` : range
43
33
  }
@@ -53,21 +43,14 @@ function groupSlotsByDate (slots) {
53
43
  return groups
54
44
  }
55
45
 
56
- function getDaysInMonth (year, month) {
57
- return new Date(year, month + 1, 0).getDate()
58
- }
46
+ function getDaysInMonth (year, month) { return new Date(year, month + 1, 0).getDate() }
47
+ function getFirstDayOfMonth (year, month) { return (new Date(year, month, 1).getDay() + 6) % 7 }
59
48
 
60
- function getFirstDayOfMonth (year, month) {
61
- const raw = new Date(year, month, 1).getDay()
62
- return (raw + 6) % 7
63
- }
64
-
65
- function MonthCalendar ({ year, month, slotsGrouped, selectedDate, onSelectDate, onPrevMonth, onNextMonth }) {
49
+ function MonthCalendar ({ year, month, slotsGrouped, selectedDate, onSelectDate, onPrevMonth, onNextMonth, t }) {
66
50
  const daysInMonth = getDaysInMonth(year, month)
67
51
  const firstDayOffset = getFirstDayOfMonth(year, month)
68
52
  const today = new Date()
69
53
  const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
70
-
71
54
  const cells = []
72
55
  for (let i = 0; i < firstDayOffset; i++) cells.push(null)
73
56
  for (let d = 1; d <= daysInMonth; d++) cells.push(d)
@@ -76,38 +59,25 @@ function MonthCalendar ({ year, month, slotsGrouped, selectedDate, onSelectDate,
76
59
  <View gap="m">
77
60
  <View layout="row" justifyContent="space-between" alignItems="center">
78
61
  <Button variant="neutral" onClick={onPrevMonth}>‹</Button>
79
- <Text weight="medium">{SV_MONTHS[month]} {year}</Text>
62
+ <Text weight="medium">{t(MONTH_KEYS[month])} {year}</Text>
80
63
  <Button variant="neutral" onClick={onNextMonth}>›</Button>
81
64
  </View>
82
-
83
65
  <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>
66
+ {WEEKDAY_SHORT_KEYS.map((key, i) => (
67
+ <Text key={key} variant="small" style={{ textAlign: 'center', opacity: 0.6 }}>{t(key)}</Text>
88
68
  ))}
89
69
  </View>
90
-
91
70
  <View gap="xs" style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
92
71
  {cells.map((day, idx) => {
93
72
  if (!day) return <View key={`empty-${idx}`} />
94
-
95
73
  const dateKey = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
96
74
  const hasSlots = !!slotsGrouped[dateKey]
97
75
  const isSelected = selectedDate === dateKey
98
76
  const isPast = dateKey < todayKey
99
77
  const disabled = !hasSlots || isPast
100
-
101
78
  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>
79
+ <Button key={dateKey} variant={isSelected ? 'tab-active' : hasSlots && !isPast ? 'tab' : 'neutral'} disabled={disabled}
80
+ onClick={() => !disabled && onSelectDate(dateKey)} style={{ padding: '8px 4px', minWidth: 0 }}>{day}</Button>
111
81
  )
112
82
  })}
113
83
  </View>
@@ -115,30 +85,20 @@ function MonthCalendar ({ year, month, slotsGrouped, selectedDate, onSelectDate,
115
85
  )
116
86
  }
117
87
 
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
-
88
+ function SlotPicker ({ slots, selectedSlot, onSelectSlot, t, language }) {
89
+ if (!slots?.length) return <Text color="secondary">{t('publicBooking.noSlots')}</Text>
123
90
  return (
124
91
  <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
- })}
92
+ {slots.map(slot => (
93
+ <Button key={slot.startAt} variant={selectedSlot?.startAt === slot.startAt ? 'tab-active' : 'tab'} onClick={() => onSelectSlot(slot)}>
94
+ {formatSlotRange(slot.startAt, slot.duration, language)}
95
+ </Button>
96
+ ))}
137
97
  </View>
138
98
  )
139
99
  }
140
100
 
141
- function BookingForm ({ slot, consultantSlug, onSuccess }) {
101
+ function BookingForm ({ slot, consultantSlug, onSuccess, t }) {
142
102
  const [clientName, setClientName] = useState('')
143
103
  const [clientEmail, setClientEmail] = useState('')
144
104
  const [clientMessage, setClientMessage] = useState('')
@@ -148,93 +108,47 @@ function BookingForm ({ slot, consultantSlug, onSuccess }) {
148
108
  const handleSubmit = async (e) => {
149
109
  e.preventDefault()
150
110
  if (!clientName.trim() || !clientEmail.trim()) return
151
-
152
- setSubmitting(true)
153
- setError(null)
154
-
111
+ setSubmitting(true); setError(null)
155
112
  try {
156
113
  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
- }),
114
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
115
+ body: JSON.stringify({ consultantId: consultantSlug, startAt: slot.startAt, duration: slot.duration, clientName: clientName.trim(), clientEmail: clientEmail.trim(), clientMessage: clientMessage.trim() }),
167
116
  })
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
- }
117
+ if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data?.message ?? t('publicBooking.form.error', { status: res.status })) }
118
+ await res.json(); onSuccess()
119
+ } catch (err) { setError(err.message) } finally { setSubmitting(false) }
181
120
  }
182
121
 
183
122
  return (
184
123
  <form onSubmit={handleSubmit}>
185
124
  <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>
125
+ <View gap="xs">
126
+ <Text weight="medium">{t('publicBooking.form.name')}</Text>
127
+ <Input type="text" required value={clientName} onChange={e => setClientName(e.target.value)} placeholder={t('publicBooking.form.namePlaceholder')} />
128
+ </View>
129
+ <View gap="xs">
130
+ <Text weight="medium">{t('publicBooking.form.email')}</Text>
131
+ <Input type="email" required value={clientEmail} onChange={e => setClientEmail(e.target.value)} placeholder={t('publicBooking.form.emailPlaceholder')} />
132
+ </View>
133
+ <View gap="xs">
134
+ <Text weight="medium">{t('publicBooking.form.message')}</Text>
135
+ <Textarea value={clientMessage} onChange={e => setClientMessage(e.target.value)} placeholder={t('publicBooking.form.messagePlaceholder')} rows={3} />
136
+ </View>
137
+ {error && <Alert variant="danger">{error}</Alert>}
138
+ <Button type="submit" variant="primary" disabled={submitting}>{submitting ? t('publicBooking.form.submitting') : t('publicBooking.form.submit')}</Button>
223
139
  </View>
224
140
  </form>
225
141
  )
226
142
  }
227
143
 
228
- function PendingScreen ({ slot }) {
144
+ function PendingScreen ({ slot, t, language }) {
229
145
  return (
230
146
  <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>
147
+ <Title variant="secondary">{t('publicBooking.pending.title')}</Title>
148
+ <Text color="secondary">{t('publicBooking.pending.body')}</Text>
149
+ <Alert variant="warning" title={t('publicBooking.pending.requestedTime')}>
150
+ <Text>{formatDate(slot.startAt, t, language)}</Text>
151
+ <Text color="secondary">{t('publicBooking.pending.duration', { minutes: slot.duration })}</Text>
238
152
  </Alert>
239
153
  </View>
240
154
  )
@@ -245,134 +159,79 @@ export const metadata = {
245
159
  path: { sv: '/boka/:consultantSlug', en: '/book/:consultantSlug' },
246
160
  }
247
161
 
248
- export default function PublicBookingPage ({ consultantSlug, ...props }) {
162
+ export default function PublicBookingPage ({ consultantSlug }) {
163
+ const { t, language } = useLocale()
249
164
  const router = useRouter()
250
165
  const slug = consultantSlug ?? router?.params?.consultantSlug
251
-
252
166
  const today = new Date()
253
167
  const [calYear, setCalYear] = useState(today.getFullYear())
254
168
  const [calMonth, setCalMonth] = useState(today.getMonth())
255
-
256
169
  const [slots, setSlots] = useState([])
257
170
  const [loadingSlots, setLoadingSlots] = useState(true)
258
171
  const [slotsError, setSlotsError] = useState(null)
259
-
260
172
  const [selectedDate, setSelectedDate] = useState(null)
261
173
  const [selectedSlot, setSelectedSlot] = useState(null)
262
174
  const [step, setStep] = useState('pick-slot')
263
175
 
264
176
  useEffect(() => {
265
177
  if (!slug) return
266
-
267
- setLoadingSlots(true)
268
- setSlotsError(null)
269
-
178
+ setLoadingSlots(true); setSlotsError(null)
270
179
  const from = new Date().toISOString()
271
180
  const to = new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString()
272
-
273
181
  fetch(`/actions/booking/get-available-slots?consultantId=${encodeURIComponent(slug)}&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`)
274
182
  .then(r => r.ok ? r.json() : Promise.reject(r))
275
183
  .then(data => { setSlots(data); setLoadingSlots(false) })
276
- .catch(() => { setSlotsError('Kunde inte ladda tillgängliga tider.'); setLoadingSlots(false) })
277
- }, [slug])
184
+ .catch(() => { setSlotsError(t('publicBooking.slotsError')); setLoadingSlots(false) })
185
+ }, [slug, t])
278
186
 
279
187
  const slotsGrouped = groupSlotsByDate(slots)
280
188
  const selectedDateSlots = selectedDate ? (slotsGrouped[selectedDate] ?? []) : []
281
189
 
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
190
  return (
293
- <View
294
- surface="base"
295
- layout="off-center-s"
296
- inset="s"
297
- style={{ minHeight: '100dvh' }}
298
- >
191
+ <View surface="base" layout="off-center-s" inset="s" style={{ minHeight: '100dvh' }}>
299
192
  <View inset="m" roundness="l" surface="primary" slot="content" gap="l" style={{ maxWidth: 720, width: '100%' }}>
300
193
  <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>
194
+ <Title variant="secondary">{t('publicBooking.title')}</Title>
195
+ <Text color="secondary">{t('publicBooking.description')}</Text>
303
196
  </View>
304
-
305
197
  {step === 'confirmed' && selectedSlot ? (
306
- <PendingScreen slot={selectedSlot} />
198
+ <PendingScreen slot={selectedSlot} t={t} language={language} />
307
199
  ) : step === 'fill-form' && selectedSlot ? (
308
200
  <View gap="m">
309
- <Button variant="link" onClick={() => setStep('pick-slot')}>
310
- ← Tillbaka
311
- </Button>
312
-
201
+ <Button variant="link" onClick={() => setStep('pick-slot')}>{t('publicBooking.back')}</Button>
313
202
  <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>
203
+ <Text weight="medium">{t('publicBooking.selectedTime', { datetime: formatDate(selectedSlot.startAt, t, language) })}</Text>
204
+ <Text color="secondary">{t('publicBooking.durationMin', { minutes: selectedSlot.duration })}</Text>
316
205
  </View>
317
-
318
- <BookingForm
319
- slot={selectedSlot}
320
- consultantSlug={slug}
321
- onSuccess={() => setStep('confirmed')}
322
- />
206
+ <BookingForm slot={selectedSlot} consultantSlug={slug} onSuccess={() => setStep('confirmed')} t={t} />
323
207
  </View>
324
208
  ) : (
325
209
  <View gap="l" layout="row" style={{ flexWrap: 'wrap' }}>
326
210
  <View style={{ flex: 1, minWidth: 260 }}>
327
211
  {loadingSlots ? (
328
- <Text color="secondary" style={{ textAlign: 'center', padding: 'var(--space-l)' }}>
329
- Laddar tider…
330
- </Text>
212
+ <Text color="secondary" style={{ textAlign: 'center', padding: 'var(--space-l)' }}>{t('publicBooking.loadingSlots')}</Text>
331
213
  ) : slotsError ? (
332
214
  <Alert variant="danger">{slotsError}</Alert>
333
215
  ) : (
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
- />
216
+ <MonthCalendar year={calYear} month={calMonth} slotsGrouped={slotsGrouped} selectedDate={selectedDate}
217
+ onSelectDate={(dateKey) => { setSelectedDate(dateKey); setSelectedSlot(null) }}
218
+ onPrevMonth={() => { if (calMonth === 0) { setCalMonth(11); setCalYear(y => y - 1) } else setCalMonth(m => m - 1) }}
219
+ onNextMonth={() => { if (calMonth === 11) { setCalMonth(0); setCalYear(y => y + 1) } else setCalMonth(m => m + 1) }}
220
+ t={t} />
346
221
  )}
347
222
  </View>
348
-
349
223
  <View gap="m" style={{ flex: 1, minWidth: 260 }}>
350
224
  {selectedDate ? (
351
225
  <>
352
226
  <Title variant="tertiary">
353
- {(() => {
354
- const [y, m, d] = selectedDate.split('-').map(Number)
355
- return `${d} ${SV_MONTHS[m - 1]} ${y}`
356
- })()}
227
+ {(() => { const [y, m, d] = selectedDate.split('-').map(Number); return `${d} ${t(MONTH_KEYS[m - 1])} ${y}` })()}
357
228
  </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
- )}
229
+ <SlotPicker slots={selectedDateSlots} selectedSlot={selectedSlot} onSelectSlot={setSelectedSlot} t={t} language={language} />
230
+ {selectedSlot && <Button variant="primary" onClick={() => setStep('fill-form')}>{t('publicBooking.continue')}</Button>}
370
231
  </>
371
232
  ) : (
372
233
  <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>
234
+ <Text color="secondary" style={{ textAlign: 'center' }}>{t('publicBooking.pickDate')}</Text>
376
235
  </View>
377
236
  )}
378
237
  </View>
@@ -1,5 +1,5 @@
1
1
  import React from 'react'
2
- import { View, Title, Text, Button, Tags } from '@ossy/design-system'
2
+ import { View, Title, Text, Button, Tags, useLocale } from '@ossy/design-system'
3
3
  import { Definition } from './Definition.js'
4
4
  import { moduleStatusTags } from './moduleStatus.js'
5
5
  import { ServiceCard } from './ServiceCard.jsx'
@@ -12,53 +12,54 @@ export const metadata = {
12
12
  },
13
13
  }
14
14
 
15
- const ServicesPage = () => (
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" justifyContent="space-between" alignItems="center" style={{ flexShrink: 0 }}>
23
- <View layout="row" gap="s" alignItems="center">
24
- <Title>{Definition.title} Services</Title>
25
- {moduleStatusTags(Definition).length > 0 && (
26
- <Tags tags={moduleStatusTags(Definition)} size="s" />
27
- )}
15
+ const ServicesPage = () => {
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" justifyContent="space-between" alignItems="center" style={{ flexShrink: 0 }}>
25
+ <View layout="row" gap="s" alignItems="center">
26
+ <Title>{Definition.title} {t('booking.services.titleSuffix')}</Title>
27
+ {moduleStatusTags(Definition).length > 0 && (
28
+ <Tags tags={moduleStatusTags(Definition)} size="s" />
29
+ )}
30
+ </View>
31
+ <Button variant="primary" size="s">{t('booking.services.addService')}</Button>
28
32
  </View>
29
- {/* TODO: wire to booking.create-service action */}
30
- <Button variant="primary" size="s">Add service</Button>
31
- </View>
32
33
 
33
- <Text style={{ maxWidth: '400px' }}>
34
- Define what you offer — name, duration, and price. Clients will see these when booking.
35
- </Text>
36
- </View>
34
+ <Text style={{ maxWidth: '400px' }}>
35
+ {t('booking.services.description')}
36
+ </Text>
37
+ </View>
37
38
 
38
- <View gap="s" inset="s">
39
- <Title variant="secondary">Your services</Title>
39
+ <View gap="s" inset="s">
40
+ <Title variant="secondary">{t('booking.services.yourServices')}</Title>
40
41
 
41
- {/* TODO: load services via booking.get-services view */}
42
- <View gap="s">
43
- <ServiceCard
44
- name="Intro call"
45
- duration={30}
46
- price={0}
47
- currency="SEK"
48
- description="A free 30-minute intro to see if we're a good fit."
49
- active
50
- />
51
- <ServiceCard
52
- name="Strategy session"
53
- duration={60}
54
- price={150000}
55
- currency="SEK"
56
- description="Deep-dive strategy session."
57
- active
58
- />
42
+ <View gap="s">
43
+ <ServiceCard
44
+ name={t('booking.services.example.introCall.name')}
45
+ duration={30}
46
+ price={0}
47
+ currency="SEK"
48
+ description={t('booking.services.example.introCall.description')}
49
+ active
50
+ />
51
+ <ServiceCard
52
+ name={t('booking.services.example.strategy.name')}
53
+ duration={60}
54
+ price={150000}
55
+ currency="SEK"
56
+ description={t('booking.services.example.strategy.description')}
57
+ active
58
+ />
59
+ </View>
59
60
  </View>
60
61
  </View>
61
- </View>
62
- )
62
+ )
63
+ }
63
64
 
64
65
  export default ServicesPage