@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.
- package/README.md +78 -0
- package/dist/cjs/Booker.cjs +153 -0
- package/dist/cjs/Booker.native.js +158 -0
- package/dist/cjs/Booker.native.js.map +1 -0
- package/dist/cjs/BookingForm.cjs +197 -0
- package/dist/cjs/BookingForm.native.js +203 -0
- package/dist/cjs/BookingForm.native.js.map +1 -0
- package/dist/cjs/CalendarEmbed.cjs +121 -0
- package/dist/cjs/CalendarEmbed.native.js +125 -0
- package/dist/cjs/CalendarEmbed.native.js.map +1 -0
- package/dist/cjs/Confirmation.cjs +177 -0
- package/dist/cjs/Confirmation.native.js +185 -0
- package/dist/cjs/Confirmation.native.js.map +1 -0
- package/dist/cjs/EventHeader.cjs +112 -0
- package/dist/cjs/EventHeader.native.js +118 -0
- package/dist/cjs/EventHeader.native.js.map +1 -0
- package/dist/cjs/MonthCalendar.cjs +177 -0
- package/dist/cjs/MonthCalendar.native.js +191 -0
- package/dist/cjs/MonthCalendar.native.js.map +1 -0
- package/dist/cjs/TimeSlots.cjs +119 -0
- package/dist/cjs/TimeSlots.native.js +126 -0
- package/dist/cjs/TimeSlots.native.js.map +1 -0
- package/dist/cjs/client.cjs +136 -0
- package/dist/cjs/client.native.js +267 -0
- package/dist/cjs/client.native.js.map +1 -0
- package/dist/cjs/hooks.cjs +193 -0
- package/dist/cjs/hooks.native.js +221 -0
- package/dist/cjs/hooks.native.js.map +1 -0
- package/dist/cjs/index.cjs +71 -0
- package/dist/cjs/index.native.js +74 -0
- package/dist/cjs/index.native.js.map +1 -0
- package/dist/cjs/time.cjs +145 -0
- package/dist/cjs/time.native.js +150 -0
- package/dist/cjs/time.native.js.map +1 -0
- package/dist/cjs/useBooker.cjs +151 -0
- package/dist/cjs/useBooker.native.js +173 -0
- package/dist/cjs/useBooker.native.js.map +1 -0
- package/dist/esm/Booker.mjs +128 -0
- package/dist/esm/Booker.mjs.map +1 -0
- package/dist/esm/Booker.native.js +130 -0
- package/dist/esm/Booker.native.js.map +1 -0
- package/dist/esm/BookingForm.mjs +172 -0
- package/dist/esm/BookingForm.mjs.map +1 -0
- package/dist/esm/BookingForm.native.js +175 -0
- package/dist/esm/BookingForm.native.js.map +1 -0
- package/dist/esm/CalendarEmbed.mjs +96 -0
- package/dist/esm/CalendarEmbed.mjs.map +1 -0
- package/dist/esm/CalendarEmbed.native.js +97 -0
- package/dist/esm/CalendarEmbed.native.js.map +1 -0
- package/dist/esm/Confirmation.mjs +152 -0
- package/dist/esm/Confirmation.mjs.map +1 -0
- package/dist/esm/Confirmation.native.js +157 -0
- package/dist/esm/Confirmation.native.js.map +1 -0
- package/dist/esm/EventHeader.mjs +87 -0
- package/dist/esm/EventHeader.mjs.map +1 -0
- package/dist/esm/EventHeader.native.js +90 -0
- package/dist/esm/EventHeader.native.js.map +1 -0
- package/dist/esm/MonthCalendar.mjs +152 -0
- package/dist/esm/MonthCalendar.mjs.map +1 -0
- package/dist/esm/MonthCalendar.native.js +163 -0
- package/dist/esm/MonthCalendar.native.js.map +1 -0
- package/dist/esm/TimeSlots.mjs +94 -0
- package/dist/esm/TimeSlots.mjs.map +1 -0
- package/dist/esm/TimeSlots.native.js +98 -0
- package/dist/esm/TimeSlots.native.js.map +1 -0
- package/dist/esm/client.mjs +104 -0
- package/dist/esm/client.mjs.map +1 -0
- package/dist/esm/client.native.js +232 -0
- package/dist/esm/client.native.js.map +1 -0
- package/dist/esm/hooks.mjs +165 -0
- package/dist/esm/hooks.mjs.map +1 -0
- package/dist/esm/hooks.native.js +190 -0
- package/dist/esm/hooks.native.js.map +1 -0
- package/dist/esm/index.js +13 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/index.mjs +13 -0
- package/dist/esm/index.mjs.map +1 -0
- package/dist/esm/index.native.js +13 -0
- package/dist/esm/index.native.js.map +1 -0
- package/dist/esm/time.mjs +107 -0
- package/dist/esm/time.mjs.map +1 -0
- package/dist/esm/time.native.js +109 -0
- package/dist/esm/time.native.js.map +1 -0
- package/dist/esm/useBooker.mjs +126 -0
- package/dist/esm/useBooker.mjs.map +1 -0
- package/dist/esm/useBooker.native.js +145 -0
- package/dist/esm/useBooker.native.js.map +1 -0
- package/package.json +61 -0
- package/src/Booker.tsx +152 -0
- package/src/BookingForm.tsx +180 -0
- package/src/CalendarEmbed.tsx +111 -0
- package/src/Confirmation.tsx +145 -0
- package/src/EventHeader.tsx +85 -0
- package/src/MonthCalendar.tsx +179 -0
- package/src/TimeSlots.tsx +102 -0
- package/src/client.ts +218 -0
- package/src/hooks.ts +180 -0
- package/src/index.ts +72 -0
- package/src/time.ts +131 -0
- package/src/useBooker.ts +187 -0
- package/types/Booker.d.ts +16 -0
- package/types/Booker.d.ts.map +1 -0
- package/types/BookingForm.d.ts +13 -0
- package/types/BookingForm.d.ts.map +1 -0
- package/types/CalendarEmbed.d.ts +15 -0
- package/types/CalendarEmbed.d.ts.map +1 -0
- package/types/Confirmation.d.ts +12 -0
- package/types/Confirmation.d.ts.map +1 -0
- package/types/EventHeader.d.ts +10 -0
- package/types/EventHeader.d.ts.map +1 -0
- package/types/MonthCalendar.d.ts +14 -0
- package/types/MonthCalendar.d.ts.map +1 -0
- package/types/TimeSlots.d.ts +15 -0
- package/types/TimeSlots.d.ts.map +1 -0
- package/types/client.d.ts +77 -0
- package/types/client.d.ts.map +1 -0
- package/types/hooks.d.ts +43 -0
- package/types/hooks.d.ts.map +1 -0
- package/types/index.d.ts +12 -0
- package/types/index.d.ts.map +1 -0
- package/types/time.d.ts +38 -0
- package/types/time.d.ts.map +1 -0
- package/types/useBooker.d.ts +41 -0
- 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
|
+
}
|