@hanzo/booker 0.1.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.
Files changed (124) hide show
  1. package/README.md +78 -0
  2. package/dist/cjs/Booker.cjs +153 -0
  3. package/dist/cjs/Booker.native.js +158 -0
  4. package/dist/cjs/Booker.native.js.map +1 -0
  5. package/dist/cjs/BookingForm.cjs +197 -0
  6. package/dist/cjs/BookingForm.native.js +203 -0
  7. package/dist/cjs/BookingForm.native.js.map +1 -0
  8. package/dist/cjs/CalendarEmbed.cjs +121 -0
  9. package/dist/cjs/CalendarEmbed.native.js +125 -0
  10. package/dist/cjs/CalendarEmbed.native.js.map +1 -0
  11. package/dist/cjs/Confirmation.cjs +177 -0
  12. package/dist/cjs/Confirmation.native.js +185 -0
  13. package/dist/cjs/Confirmation.native.js.map +1 -0
  14. package/dist/cjs/EventHeader.cjs +112 -0
  15. package/dist/cjs/EventHeader.native.js +118 -0
  16. package/dist/cjs/EventHeader.native.js.map +1 -0
  17. package/dist/cjs/MonthCalendar.cjs +177 -0
  18. package/dist/cjs/MonthCalendar.native.js +191 -0
  19. package/dist/cjs/MonthCalendar.native.js.map +1 -0
  20. package/dist/cjs/TimeSlots.cjs +119 -0
  21. package/dist/cjs/TimeSlots.native.js +126 -0
  22. package/dist/cjs/TimeSlots.native.js.map +1 -0
  23. package/dist/cjs/client.cjs +136 -0
  24. package/dist/cjs/client.native.js +267 -0
  25. package/dist/cjs/client.native.js.map +1 -0
  26. package/dist/cjs/hooks.cjs +193 -0
  27. package/dist/cjs/hooks.native.js +221 -0
  28. package/dist/cjs/hooks.native.js.map +1 -0
  29. package/dist/cjs/index.cjs +71 -0
  30. package/dist/cjs/index.native.js +74 -0
  31. package/dist/cjs/index.native.js.map +1 -0
  32. package/dist/cjs/time.cjs +145 -0
  33. package/dist/cjs/time.native.js +150 -0
  34. package/dist/cjs/time.native.js.map +1 -0
  35. package/dist/cjs/useBooker.cjs +151 -0
  36. package/dist/cjs/useBooker.native.js +173 -0
  37. package/dist/cjs/useBooker.native.js.map +1 -0
  38. package/dist/esm/Booker.mjs +128 -0
  39. package/dist/esm/Booker.mjs.map +1 -0
  40. package/dist/esm/Booker.native.js +130 -0
  41. package/dist/esm/Booker.native.js.map +1 -0
  42. package/dist/esm/BookingForm.mjs +172 -0
  43. package/dist/esm/BookingForm.mjs.map +1 -0
  44. package/dist/esm/BookingForm.native.js +175 -0
  45. package/dist/esm/BookingForm.native.js.map +1 -0
  46. package/dist/esm/CalendarEmbed.mjs +96 -0
  47. package/dist/esm/CalendarEmbed.mjs.map +1 -0
  48. package/dist/esm/CalendarEmbed.native.js +97 -0
  49. package/dist/esm/CalendarEmbed.native.js.map +1 -0
  50. package/dist/esm/Confirmation.mjs +152 -0
  51. package/dist/esm/Confirmation.mjs.map +1 -0
  52. package/dist/esm/Confirmation.native.js +157 -0
  53. package/dist/esm/Confirmation.native.js.map +1 -0
  54. package/dist/esm/EventHeader.mjs +87 -0
  55. package/dist/esm/EventHeader.mjs.map +1 -0
  56. package/dist/esm/EventHeader.native.js +90 -0
  57. package/dist/esm/EventHeader.native.js.map +1 -0
  58. package/dist/esm/MonthCalendar.mjs +152 -0
  59. package/dist/esm/MonthCalendar.mjs.map +1 -0
  60. package/dist/esm/MonthCalendar.native.js +163 -0
  61. package/dist/esm/MonthCalendar.native.js.map +1 -0
  62. package/dist/esm/TimeSlots.mjs +94 -0
  63. package/dist/esm/TimeSlots.mjs.map +1 -0
  64. package/dist/esm/TimeSlots.native.js +98 -0
  65. package/dist/esm/TimeSlots.native.js.map +1 -0
  66. package/dist/esm/client.mjs +104 -0
  67. package/dist/esm/client.mjs.map +1 -0
  68. package/dist/esm/client.native.js +232 -0
  69. package/dist/esm/client.native.js.map +1 -0
  70. package/dist/esm/hooks.mjs +165 -0
  71. package/dist/esm/hooks.mjs.map +1 -0
  72. package/dist/esm/hooks.native.js +190 -0
  73. package/dist/esm/hooks.native.js.map +1 -0
  74. package/dist/esm/index.js +13 -0
  75. package/dist/esm/index.js.map +1 -0
  76. package/dist/esm/index.mjs +13 -0
  77. package/dist/esm/index.mjs.map +1 -0
  78. package/dist/esm/index.native.js +13 -0
  79. package/dist/esm/index.native.js.map +1 -0
  80. package/dist/esm/time.mjs +107 -0
  81. package/dist/esm/time.mjs.map +1 -0
  82. package/dist/esm/time.native.js +109 -0
  83. package/dist/esm/time.native.js.map +1 -0
  84. package/dist/esm/useBooker.mjs +126 -0
  85. package/dist/esm/useBooker.mjs.map +1 -0
  86. package/dist/esm/useBooker.native.js +145 -0
  87. package/dist/esm/useBooker.native.js.map +1 -0
  88. package/package.json +61 -0
  89. package/src/Booker.tsx +152 -0
  90. package/src/BookingForm.tsx +180 -0
  91. package/src/CalendarEmbed.tsx +111 -0
  92. package/src/Confirmation.tsx +145 -0
  93. package/src/EventHeader.tsx +85 -0
  94. package/src/MonthCalendar.tsx +179 -0
  95. package/src/TimeSlots.tsx +102 -0
  96. package/src/client.ts +218 -0
  97. package/src/hooks.ts +180 -0
  98. package/src/index.ts +72 -0
  99. package/src/time.ts +131 -0
  100. package/src/useBooker.ts +187 -0
  101. package/types/Booker.d.ts +16 -0
  102. package/types/Booker.d.ts.map +1 -0
  103. package/types/BookingForm.d.ts +13 -0
  104. package/types/BookingForm.d.ts.map +1 -0
  105. package/types/CalendarEmbed.d.ts +15 -0
  106. package/types/CalendarEmbed.d.ts.map +1 -0
  107. package/types/Confirmation.d.ts +12 -0
  108. package/types/Confirmation.d.ts.map +1 -0
  109. package/types/EventHeader.d.ts +10 -0
  110. package/types/EventHeader.d.ts.map +1 -0
  111. package/types/MonthCalendar.d.ts +14 -0
  112. package/types/MonthCalendar.d.ts.map +1 -0
  113. package/types/TimeSlots.d.ts +15 -0
  114. package/types/TimeSlots.d.ts.map +1 -0
  115. package/types/client.d.ts +77 -0
  116. package/types/client.d.ts.map +1 -0
  117. package/types/hooks.d.ts +43 -0
  118. package/types/hooks.d.ts.map +1 -0
  119. package/types/index.d.ts +12 -0
  120. package/types/index.d.ts.map +1 -0
  121. package/types/time.d.ts +38 -0
  122. package/types/time.d.ts.map +1 -0
  123. package/types/useBooker.d.ts +41 -0
  124. package/types/useBooker.d.ts.map +1 -0
@@ -0,0 +1,111 @@
1
+ import { SizableText, Spinner, XStack, YStack } from 'hanzogui'
2
+ import type { Booking } from './client'
3
+ import { BookingForm } from './BookingForm'
4
+ import { Confirmation } from './Confirmation'
5
+ import { EventHeader } from './EventHeader'
6
+ import { TimeSlots } from './TimeSlots'
7
+ import { formatMonthLabel } from './time'
8
+ import { useBooker } from './useBooker'
9
+
10
+ export interface CalendarEmbedProps {
11
+ host: string
12
+ eventSlug: string
13
+ apiUrl?: string
14
+ timeZone?: string
15
+ weekStartsOn?: 0 | 1
16
+ onBooked?: (booking: Booking) => void
17
+ }
18
+
19
+ /**
20
+ * Compact booking card for a marketing hero — soonest availability inline,
21
+ * no month grid. Same state machine as <Booker>.
22
+ */
23
+ export function CalendarEmbed(props: CalendarEmbedProps) {
24
+ const m = useBooker({ ...props, autoSelectFirst: true })
25
+
26
+ return (
27
+ <YStack
28
+ backgroundColor="$background"
29
+ borderColor="$borderColor"
30
+ borderWidth={1}
31
+ borderRadius="$6"
32
+ padding="$4"
33
+ gap="$3"
34
+ width="100%"
35
+ maxWidth={420}
36
+ >
37
+ {m.event.loading ? (
38
+ <YStack
39
+ alignItems="center"
40
+ justifyContent="center"
41
+ gap="$2"
42
+ padding="$6"
43
+ minHeight={160}
44
+ >
45
+ <Spinner size="large" color="$color9" />
46
+ </YStack>
47
+ ) : m.event.error || !m.event.data ? (
48
+ <YStack
49
+ alignItems="center"
50
+ justifyContent="center"
51
+ padding="$5"
52
+ minHeight={140}
53
+ gap="$1"
54
+ >
55
+ <SizableText size="$4" fontWeight="600" color="$color12">
56
+ Unavailable
57
+ </SizableText>
58
+ <SizableText size="$2" color="$color10" textAlign="center">
59
+ {m.event.error ?? 'Event not found.'}
60
+ </SizableText>
61
+ </YStack>
62
+ ) : m.phase === 'done' && m.booking ? (
63
+ <Confirmation booking={m.booking} timeZone={m.timeZone} onDone={m.reset} />
64
+ ) : m.phase === 'form' && m.selectedSlot ? (
65
+ <BookingForm
66
+ slotIso={m.selectedSlot}
67
+ timeZone={m.timeZone}
68
+ eventLength={m.event.data.length}
69
+ submitting={m.submitting}
70
+ error={m.error}
71
+ onSubmit={m.submit}
72
+ onBack={m.back}
73
+ />
74
+ ) : (
75
+ <>
76
+ <EventHeader
77
+ event={m.event.data}
78
+ host={props.host}
79
+ timeZone={m.timeZone}
80
+ compact
81
+ />
82
+ {m.conflict ? (
83
+ <SizableText size="$1" color="$yellow11">
84
+ That time was just taken — pick another.
85
+ </SizableText>
86
+ ) : null}
87
+ {m.slots.loading && m.availableKeys.size === 0 ? (
88
+ <YStack alignItems="center" padding="$4">
89
+ <Spinner color="$color9" />
90
+ </YStack>
91
+ ) : m.availableKeys.size === 0 ? (
92
+ <SizableText size="$2" color="$color10">
93
+ No availability in {formatMonthLabel(m.viewDate)}.
94
+ </SizableText>
95
+ ) : (
96
+ <TimeSlots
97
+ dayKey={m.selectedDay}
98
+ slots={m.daySlots}
99
+ timeZone={m.timeZone}
100
+ onPick={m.selectSlot}
101
+ selectedIso={m.selectedSlot}
102
+ loading={m.slots.loading}
103
+ compact
104
+ maxHeight={220}
105
+ />
106
+ )}
107
+ </>
108
+ )}
109
+ </YStack>
110
+ )
111
+ }
@@ -0,0 +1,145 @@
1
+ import {
2
+ Button,
3
+ Paragraph,
4
+ Separator,
5
+ SizableText,
6
+ Spinner,
7
+ XStack,
8
+ YStack,
9
+ } from 'hanzogui'
10
+ import { DEFAULT_API_URL, type Booking } from './client'
11
+ import { useBooking } from './hooks'
12
+ import { visitorTimeZone } from './time'
13
+ import { formatBookingWhen } from './time'
14
+
15
+ export interface ConfirmationProps {
16
+ /** Provide a booking directly, or a uid to fetch one. */
17
+ booking?: Booking
18
+ uid?: string
19
+ apiUrl?: string
20
+ timeZone?: string
21
+ onDone?: () => void
22
+ }
23
+
24
+ /** Booking confirmation card. Accepts a booking object or fetches by uid. */
25
+ export function Confirmation({
26
+ booking,
27
+ uid,
28
+ apiUrl,
29
+ timeZone,
30
+ onDone,
31
+ }: ConfirmationProps) {
32
+ const api = apiUrl ?? DEFAULT_API_URL
33
+ const tz = timeZone ?? visitorTimeZone()
34
+ const fetched = useBooking(api, booking ? undefined : uid)
35
+ const data = booking ?? fetched.data
36
+
37
+ if (!data && fetched.loading) {
38
+ return (
39
+ <YStack
40
+ alignItems="center"
41
+ justifyContent="center"
42
+ padding="$6"
43
+ gap="$3"
44
+ minHeight={200}
45
+ >
46
+ <Spinner size="large" color="$color9" />
47
+ <SizableText size="$3" color="$color10">
48
+ Loading your booking…
49
+ </SizableText>
50
+ </YStack>
51
+ )
52
+ }
53
+
54
+ if (!data) {
55
+ return (
56
+ <YStack
57
+ alignItems="center"
58
+ justifyContent="center"
59
+ padding="$6"
60
+ gap="$2"
61
+ minHeight={200}
62
+ >
63
+ <SizableText size="$4" fontWeight="600" color="$color12">
64
+ Booking not found
65
+ </SizableText>
66
+ <SizableText size="$3" color="$color10" textAlign="center">
67
+ {fetched.error ?? 'We could not load this booking.'}
68
+ </SizableText>
69
+ </YStack>
70
+ )
71
+ }
72
+
73
+ const attendee = data.attendees?.[0]
74
+ const location = data.location
75
+
76
+ return (
77
+ <YStack gap="$3" padding="$1" alignItems="center">
78
+ <YStack
79
+ width={52}
80
+ height={52}
81
+ borderRadius={9999}
82
+ backgroundColor="$green4"
83
+ alignItems="center"
84
+ justifyContent="center"
85
+ >
86
+ <SizableText size="$8" color="$green11">
87
+ {'✓'}
88
+ </SizableText>
89
+ </YStack>
90
+
91
+ <YStack gap="$1" alignItems="center">
92
+ <SizableText size="$7" fontWeight="700" color="$color12" textAlign="center">
93
+ You're booked
94
+ </SizableText>
95
+ <SizableText size="$4" color="$color11" textAlign="center">
96
+ {data.title}
97
+ </SizableText>
98
+ </YStack>
99
+
100
+ <Separator width="100%" />
101
+
102
+ <YStack gap="$2.5" width="100%" maxWidth={420}>
103
+ <Row label="When" value={formatBookingWhen(data.start, tz)} />
104
+ {location ? <Row label="Where" value={location} /> : null}
105
+ {attendee?.email ? <Row label="Guest" value={attendee.email} /> : null}
106
+ <Row label="Status" value={titleCase(data.status)} />
107
+ <Row label="Reference" value={data.uid} />
108
+ </YStack>
109
+
110
+ <Paragraph size="$2" color="$color10" textAlign="center" marginTop="$1">
111
+ A calendar invite is on its way to {attendee?.email ?? 'your inbox'}.
112
+ </Paragraph>
113
+
114
+ {onDone ? (
115
+ <Button size="$3" chromeless onPress={onDone} marginTop="$1">
116
+ Book another time
117
+ </Button>
118
+ ) : null}
119
+ </YStack>
120
+ )
121
+ }
122
+
123
+ function Row({ label, value }: { label: string; value: string }) {
124
+ return (
125
+ <XStack justifyContent="space-between" gap="$3" alignItems="flex-start">
126
+ <SizableText size="$3" color="$color10">
127
+ {label}
128
+ </SizableText>
129
+ <SizableText
130
+ size="$3"
131
+ color="$color12"
132
+ fontWeight="500"
133
+ textAlign="right"
134
+ flexShrink={1}
135
+ >
136
+ {value}
137
+ </SizableText>
138
+ </XStack>
139
+ )
140
+ }
141
+
142
+ function titleCase(s: string): string {
143
+ if (!s) return ''
144
+ return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()
145
+ }
@@ -0,0 +1,85 @@
1
+ import type { ReactNode } from 'react'
2
+ import { Paragraph, SizableText, XStack, YStack } from 'hanzogui'
3
+ import type { EventType } from './client'
4
+
5
+ export interface EventHeaderProps {
6
+ event: EventType
7
+ host: string
8
+ timeZone: string
9
+ compact?: boolean
10
+ }
11
+
12
+ /** Event identity: host, title, duration, timezone, description. */
13
+ export function EventHeader({ event, host, timeZone, compact }: EventHeaderProps) {
14
+ const name = event.profile?.name || host
15
+ const initial = (name?.[0] || 'H').toUpperCase()
16
+ return (
17
+ <YStack gap="$2">
18
+ <XStack alignItems="center" gap="$2.5">
19
+ <YStack
20
+ width={28}
21
+ height={28}
22
+ borderRadius={9999}
23
+ backgroundColor="$color5"
24
+ alignItems="center"
25
+ justifyContent="center"
26
+ >
27
+ <SizableText size="$2" color="$color11" fontWeight="600">
28
+ {initial}
29
+ </SizableText>
30
+ </YStack>
31
+ <SizableText size="$3" color="$color10">
32
+ {name}
33
+ </SizableText>
34
+ </XStack>
35
+
36
+ <SizableText size={compact ? '$6' : '$8'} fontWeight="700" color="$color12">
37
+ {event.title}
38
+ </SizableText>
39
+
40
+ <XStack gap="$2" alignItems="center" flexWrap="wrap">
41
+ <Pill>{event.length} min</Pill>
42
+ <Pill>{shortZone(timeZone)}</Pill>
43
+ {event.locations?.[0]?.type ? (
44
+ <Pill>{prettyLocation(event.locations[0].type)}</Pill>
45
+ ) : null}
46
+ </XStack>
47
+
48
+ {!compact && event.description ? (
49
+ <Paragraph size="$3" color="$color11" marginTop="$1">
50
+ {event.description}
51
+ </Paragraph>
52
+ ) : null}
53
+ </YStack>
54
+ )
55
+ }
56
+
57
+ function Pill({ children }: { children: ReactNode }) {
58
+ return (
59
+ <XStack
60
+ backgroundColor="$color3"
61
+ paddingHorizontal="$2.5"
62
+ paddingVertical="$1"
63
+ borderRadius="$10"
64
+ alignItems="center"
65
+ >
66
+ <SizableText size="$1" color="$color11">
67
+ {children}
68
+ </SizableText>
69
+ </XStack>
70
+ )
71
+ }
72
+
73
+ function shortZone(tz: string): string {
74
+ const city = tz.split('/').pop() || tz
75
+ return city.replace(/_/g, ' ')
76
+ }
77
+
78
+ function prettyLocation(type: string): string {
79
+ const t = type.toLowerCase()
80
+ if (t.includes('google')) return 'Google Meet'
81
+ if (t.includes('zoom')) return 'Zoom'
82
+ if (t.includes('phone')) return 'Phone'
83
+ if (t.includes('person')) return 'In person'
84
+ return 'Video call'
85
+ }
@@ -0,0 +1,179 @@
1
+ import { SizableText, Spinner, XStack, YStack } from 'hanzogui'
2
+ import {
3
+ formatMonthLabel,
4
+ isBeforeToday,
5
+ monthMatrix,
6
+ weekdayLabels,
7
+ type MonthCell,
8
+ } from './time'
9
+
10
+ export interface MonthCalendarProps {
11
+ viewDate: Date
12
+ availableKeys: Set<string>
13
+ selectedDay: string | null
14
+ onSelectDay: (key: string) => void
15
+ onPrev: () => void
16
+ onNext: () => void
17
+ atFirstMonth: boolean
18
+ weekStartsOn?: 0 | 1
19
+ loading?: boolean
20
+ }
21
+
22
+ /** Navigable month grid; days with availability are selectable. */
23
+ export function MonthCalendar({
24
+ viewDate,
25
+ availableKeys,
26
+ selectedDay,
27
+ onSelectDay,
28
+ onPrev,
29
+ onNext,
30
+ atFirstMonth,
31
+ weekStartsOn = 0,
32
+ loading,
33
+ }: MonthCalendarProps) {
34
+ const cells = monthMatrix(viewDate, weekStartsOn)
35
+ const rows: MonthCell[][] = []
36
+ for (let i = 0; i < cells.length; i += 7) rows.push(cells.slice(i, i + 7))
37
+ const labels = weekdayLabels(weekStartsOn)
38
+
39
+ return (
40
+ <YStack gap="$2" flex={1} minWidth={260}>
41
+ <XStack alignItems="center" justifyContent="space-between" paddingVertical="$1">
42
+ <NavArrow label="Previous month" disabled={atFirstMonth} onPress={onPrev}>
43
+ {'‹'}
44
+ </NavArrow>
45
+ <XStack alignItems="center" gap="$2">
46
+ <SizableText size="$5" fontWeight="700" color="$color12">
47
+ {formatMonthLabel(viewDate)}
48
+ </SizableText>
49
+ {loading ? <Spinner size="small" color="$color9" /> : null}
50
+ </XStack>
51
+ <NavArrow label="Next month" onPress={onNext}>
52
+ {'›'}
53
+ </NavArrow>
54
+ </XStack>
55
+
56
+ <XStack>
57
+ {labels.map((l) => (
58
+ <YStack
59
+ key={l}
60
+ flexBasis={0}
61
+ flexGrow={1}
62
+ alignItems="center"
63
+ paddingVertical="$1"
64
+ >
65
+ <SizableText size="$1" color="$color10">
66
+ {l}
67
+ </SizableText>
68
+ </YStack>
69
+ ))}
70
+ </XStack>
71
+
72
+ <YStack gap="$1.5">
73
+ {rows.map((row, r) => (
74
+ <XStack key={r} gap="$1.5">
75
+ {row.map((cell) => (
76
+ <DayCell
77
+ key={cell.key}
78
+ cell={cell}
79
+ available={availableKeys.has(cell.key)}
80
+ past={isBeforeToday(cell.key)}
81
+ selected={selectedDay === cell.key}
82
+ onSelectDay={onSelectDay}
83
+ />
84
+ ))}
85
+ </XStack>
86
+ ))}
87
+ </YStack>
88
+ </YStack>
89
+ )
90
+ }
91
+
92
+ function DayCell({
93
+ cell,
94
+ available,
95
+ past,
96
+ selected,
97
+ onSelectDay,
98
+ }: {
99
+ cell: MonthCell
100
+ available: boolean
101
+ past: boolean
102
+ selected: boolean
103
+ onSelectDay: (key: string) => void
104
+ }) {
105
+ const selectable = cell.inMonth && available && !past
106
+ const dayNum = cell.date.getDate()
107
+
108
+ return (
109
+ <YStack
110
+ flexBasis={0}
111
+ flexGrow={1}
112
+ aspectRatio={1}
113
+ alignItems="center"
114
+ justifyContent="center"
115
+ borderRadius="$4"
116
+ borderWidth={1}
117
+ borderColor={selected ? '$color12' : selectable ? '$color5' : 'transparent'}
118
+ backgroundColor={selected ? '$color12' : selectable ? '$color2' : 'transparent'}
119
+ opacity={cell.inMonth ? 1 : 0}
120
+ cursor={selectable ? 'pointer' : 'default'}
121
+ hoverStyle={selectable && !selected ? { backgroundColor: '$color4' } : undefined}
122
+ pressStyle={selectable ? { backgroundColor: '$color6' } : undefined}
123
+ onPress={selectable ? () => onSelectDay(cell.key) : undefined}
124
+ aria-label={selectable ? `Select ${cell.key}` : undefined}
125
+ role={selectable ? 'button' : undefined}
126
+ >
127
+ <SizableText
128
+ size="$3"
129
+ fontWeight={selectable ? '600' : '400'}
130
+ color={selected ? '$color1' : selectable ? '$color12' : '$color8'}
131
+ >
132
+ {dayNum}
133
+ </SizableText>
134
+ {selectable && !selected ? (
135
+ <YStack
136
+ position="absolute"
137
+ bottom={4}
138
+ width={4}
139
+ height={4}
140
+ borderRadius={9999}
141
+ backgroundColor="$color10"
142
+ />
143
+ ) : null}
144
+ </YStack>
145
+ )
146
+ }
147
+
148
+ function NavArrow({
149
+ children,
150
+ onPress,
151
+ disabled,
152
+ label,
153
+ }: {
154
+ children: string
155
+ onPress: () => void
156
+ disabled?: boolean
157
+ label: string
158
+ }) {
159
+ return (
160
+ <YStack
161
+ width={34}
162
+ height={34}
163
+ alignItems="center"
164
+ justifyContent="center"
165
+ borderRadius="$4"
166
+ opacity={disabled ? 0.35 : 1}
167
+ cursor={disabled ? 'default' : 'pointer'}
168
+ hoverStyle={disabled ? undefined : { backgroundColor: '$color4' }}
169
+ pressStyle={disabled ? undefined : { backgroundColor: '$color6' }}
170
+ onPress={disabled ? undefined : onPress}
171
+ aria-label={label}
172
+ role="button"
173
+ >
174
+ <SizableText size="$6" color="$color11">
175
+ {children}
176
+ </SizableText>
177
+ </YStack>
178
+ )
179
+ }
@@ -0,0 +1,102 @@
1
+ import type { ReactNode } from 'react'
2
+ import { Button, ScrollView, SizableText, XStack, YStack } from 'hanzogui'
3
+ import type { Slot } from './client'
4
+ import { dateFromKey, formatDayLong, formatSlotTime } from './time'
5
+
6
+ export interface TimeSlotsProps {
7
+ dayKey: string | null
8
+ slots: Slot[]
9
+ timeZone: string
10
+ onPick: (iso: string) => void
11
+ selectedIso?: string | null
12
+ loading?: boolean
13
+ /** compact = wrap into a flowing grid instead of a tall column */
14
+ compact?: boolean
15
+ maxHeight?: number
16
+ }
17
+
18
+ /** Time-slot pills for the selected day, shown in the visitor's time zone. */
19
+ export function TimeSlots({
20
+ dayKey,
21
+ slots,
22
+ timeZone,
23
+ onPick,
24
+ selectedIso,
25
+ loading,
26
+ compact,
27
+ maxHeight = 360,
28
+ }: TimeSlotsProps) {
29
+ if (!dayKey) {
30
+ return (
31
+ <Centered>
32
+ <SizableText size="$3" color="$color10" textAlign="center">
33
+ Select a day to see available times.
34
+ </SizableText>
35
+ </Centered>
36
+ )
37
+ }
38
+
39
+ const heading = formatDayLong(dateFromKey(dayKey))
40
+
41
+ return (
42
+ <YStack gap="$2" flex={1} minWidth={200}>
43
+ <SizableText size="$4" fontWeight="600" color="$color12">
44
+ {heading}
45
+ </SizableText>
46
+
47
+ {loading ? (
48
+ <Centered>
49
+ <SizableText size="$2" color="$color10">
50
+ Loading times…
51
+ </SizableText>
52
+ </Centered>
53
+ ) : slots.length === 0 ? (
54
+ <Centered>
55
+ <SizableText size="$3" color="$color10" textAlign="center">
56
+ No times left on this day. Try another.
57
+ </SizableText>
58
+ </Centered>
59
+ ) : (
60
+ <ScrollView maxHeight={maxHeight} showsVerticalScrollIndicator={false}>
61
+ <XStack flexWrap="wrap" gap="$2" flexDirection={compact ? 'row' : 'column'}>
62
+ {slots.map((slot) => {
63
+ const active = selectedIso === slot.time
64
+ return (
65
+ <Button
66
+ key={slot.time}
67
+ size="$3"
68
+ theme={active ? 'blue' : undefined}
69
+ chromeless={!active}
70
+ borderWidth={1}
71
+ borderColor={active ? '$color8' : '$color6'}
72
+ backgroundColor={active ? undefined : '$color1'}
73
+ hoverStyle={{ borderColor: '$color10' }}
74
+ pressStyle={{ backgroundColor: '$color4' }}
75
+ onPress={() => onPick(slot.time)}
76
+ aria-label={`Book ${formatSlotTime(slot.time, timeZone)}`}
77
+ {...(compact ? {} : { alignSelf: 'stretch' })}
78
+ >
79
+ {formatSlotTime(slot.time, timeZone)}
80
+ </Button>
81
+ )
82
+ })}
83
+ </XStack>
84
+ </ScrollView>
85
+ )}
86
+ </YStack>
87
+ )
88
+ }
89
+
90
+ function Centered({ children }: { children: ReactNode }) {
91
+ return (
92
+ <YStack
93
+ flex={1}
94
+ minHeight={120}
95
+ alignItems="center"
96
+ justifyContent="center"
97
+ padding="$4"
98
+ >
99
+ {children}
100
+ </YStack>
101
+ )
102
+ }