@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
package/src/client.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// Data plane for the Hanzo Calendar (Cal API-v2 shapes).
|
|
2
|
+
// Pure fetch layer — no React, no DOM. Runs on web and React Native.
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_API_URL = 'https://api.hanzo.ai/v1/calendar'
|
|
5
|
+
export const CAL_API_VERSION = '2024-08-13'
|
|
6
|
+
|
|
7
|
+
export interface Location {
|
|
8
|
+
type?: string
|
|
9
|
+
link?: string
|
|
10
|
+
address?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Profile {
|
|
14
|
+
name?: string | null
|
|
15
|
+
username?: string | null
|
|
16
|
+
image?: string | null
|
|
17
|
+
avatarUrl?: string | null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface EventType {
|
|
21
|
+
id: number
|
|
22
|
+
slug: string
|
|
23
|
+
title: string
|
|
24
|
+
description: string
|
|
25
|
+
/** duration in minutes */
|
|
26
|
+
length: number
|
|
27
|
+
locations: Location[]
|
|
28
|
+
price: number
|
|
29
|
+
currency: string
|
|
30
|
+
profile?: Profile
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface Slot {
|
|
34
|
+
/** ISO-8601 UTC instant, e.g. "2026-07-24T16:00:00Z" */
|
|
35
|
+
time: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Slots grouped by day key "YYYY-MM-DD" (in the requested time zone). */
|
|
39
|
+
export type SlotsByDay = Record<string, Slot[]>
|
|
40
|
+
|
|
41
|
+
export interface Attendee {
|
|
42
|
+
name?: string
|
|
43
|
+
email?: string
|
|
44
|
+
timeZone?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface Booking {
|
|
48
|
+
uid: string
|
|
49
|
+
status: string
|
|
50
|
+
start: string
|
|
51
|
+
end: string
|
|
52
|
+
title: string
|
|
53
|
+
location?: string
|
|
54
|
+
attendees?: Attendee[]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface BookingResponses {
|
|
58
|
+
name: string
|
|
59
|
+
email: string
|
|
60
|
+
notes?: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface CreateBookingInput {
|
|
64
|
+
eventTypeSlug: string
|
|
65
|
+
username: string
|
|
66
|
+
/** ISO instant of the chosen slot */
|
|
67
|
+
start: string
|
|
68
|
+
timeZone: string
|
|
69
|
+
responses: BookingResponses
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface SlotsInput {
|
|
73
|
+
eventTypeSlug: string
|
|
74
|
+
username: string
|
|
75
|
+
startTime: string
|
|
76
|
+
endTime: string
|
|
77
|
+
timeZone: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Error carrying the HTTP status; `conflict` marks a 409 (slot just taken). */
|
|
81
|
+
export class CalendarError extends Error {
|
|
82
|
+
status: number
|
|
83
|
+
conflict: boolean
|
|
84
|
+
constructor(message: string, status: number) {
|
|
85
|
+
super(message)
|
|
86
|
+
this.name = 'CalendarError'
|
|
87
|
+
this.status = status
|
|
88
|
+
this.conflict = status === 409
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface Envelope<T> {
|
|
93
|
+
data?: T
|
|
94
|
+
error?: { message?: string }
|
|
95
|
+
message?: string
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function messageOf(body: unknown, status: number): string {
|
|
99
|
+
const b = body as Envelope<unknown> | null
|
|
100
|
+
return b?.error?.message || b?.message || `Request failed (${status})`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function readJson(res: Response): Promise<unknown> {
|
|
104
|
+
return res.json().catch(() => null)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function getJson<T>(url: string, signal?: AbortSignal): Promise<T> {
|
|
108
|
+
const res = await fetch(url, { headers: { Accept: 'application/json' }, signal })
|
|
109
|
+
const body = await readJson(res)
|
|
110
|
+
if (!res.ok) throw new CalendarError(messageOf(body, res.status), res.status)
|
|
111
|
+
return (body as Envelope<T>).data as T
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function normalizeEventType(raw: Record<string, unknown>): EventType {
|
|
115
|
+
const length =
|
|
116
|
+
(raw.length as number) ??
|
|
117
|
+
(raw.lengthInMinutes as number) ??
|
|
118
|
+
(raw.durationMinutes as number) ??
|
|
119
|
+
30
|
|
120
|
+
return {
|
|
121
|
+
id: Number(raw.id ?? 0),
|
|
122
|
+
slug: String(raw.slug ?? ''),
|
|
123
|
+
title: String(raw.title ?? ''),
|
|
124
|
+
description: String(raw.description ?? ''),
|
|
125
|
+
length,
|
|
126
|
+
locations: (raw.locations as Location[]) ?? [],
|
|
127
|
+
price: Number(raw.price ?? 0),
|
|
128
|
+
currency: String(raw.currency ?? 'usd'),
|
|
129
|
+
profile: raw.profile as Profile | undefined,
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function fetchEventType(
|
|
134
|
+
api: string,
|
|
135
|
+
eventSlug: string,
|
|
136
|
+
username: string,
|
|
137
|
+
signal?: AbortSignal
|
|
138
|
+
): Promise<EventType> {
|
|
139
|
+
const url = `${api}/atoms/event-types/${encodeURIComponent(eventSlug)}/public?username=${encodeURIComponent(username)}`
|
|
140
|
+
const raw = await getJson<Record<string, unknown>>(url, signal)
|
|
141
|
+
return normalizeEventType(raw)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function fetchSlots(
|
|
145
|
+
api: string,
|
|
146
|
+
input: SlotsInput,
|
|
147
|
+
signal?: AbortSignal
|
|
148
|
+
): Promise<SlotsByDay> {
|
|
149
|
+
const q = new URLSearchParams({
|
|
150
|
+
eventTypeSlug: input.eventTypeSlug,
|
|
151
|
+
usernameList: input.username,
|
|
152
|
+
startTime: input.startTime,
|
|
153
|
+
endTime: input.endTime,
|
|
154
|
+
timeZone: input.timeZone,
|
|
155
|
+
})
|
|
156
|
+
const data = await getJson<{ slots?: SlotsByDay } | SlotsByDay>(
|
|
157
|
+
`${api}/slots/available?${q.toString()}`,
|
|
158
|
+
signal
|
|
159
|
+
)
|
|
160
|
+
// API returns { data: { slots: {...} } }; tolerate a bare map too.
|
|
161
|
+
const slots = (data as { slots?: SlotsByDay }).slots ?? (data as SlotsByDay)
|
|
162
|
+
return slots ?? {}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function createBooking(
|
|
166
|
+
api: string,
|
|
167
|
+
input: CreateBookingInput,
|
|
168
|
+
signal?: AbortSignal
|
|
169
|
+
): Promise<Booking> {
|
|
170
|
+
const res = await fetch(`${api}/bookings`, {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
headers: {
|
|
173
|
+
'Content-Type': 'application/json',
|
|
174
|
+
Accept: 'application/json',
|
|
175
|
+
'cal-api-version': CAL_API_VERSION,
|
|
176
|
+
},
|
|
177
|
+
body: JSON.stringify({
|
|
178
|
+
eventTypeSlug: input.eventTypeSlug,
|
|
179
|
+
user: input.username,
|
|
180
|
+
start: input.start,
|
|
181
|
+
timeZone: input.timeZone,
|
|
182
|
+
responses: input.responses,
|
|
183
|
+
}),
|
|
184
|
+
signal,
|
|
185
|
+
})
|
|
186
|
+
const body = await readJson(res)
|
|
187
|
+
if (!res.ok) throw new CalendarError(messageOf(body, res.status), res.status)
|
|
188
|
+
return (body as Envelope<Booking>).data as Booking
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function fetchBooking(
|
|
192
|
+
api: string,
|
|
193
|
+
uid: string,
|
|
194
|
+
signal?: AbortSignal
|
|
195
|
+
): Promise<Booking> {
|
|
196
|
+
return getJson<Booking>(`${api}/bookings/${encodeURIComponent(uid)}`, signal)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function cancelBooking(
|
|
200
|
+
api: string,
|
|
201
|
+
uid: string,
|
|
202
|
+
reason?: string,
|
|
203
|
+
signal?: AbortSignal
|
|
204
|
+
): Promise<Booking> {
|
|
205
|
+
const res = await fetch(`${api}/bookings/${encodeURIComponent(uid)}/cancel`, {
|
|
206
|
+
method: 'POST',
|
|
207
|
+
headers: {
|
|
208
|
+
'Content-Type': 'application/json',
|
|
209
|
+
Accept: 'application/json',
|
|
210
|
+
'cal-api-version': CAL_API_VERSION,
|
|
211
|
+
},
|
|
212
|
+
body: JSON.stringify({ reason: reason ?? '' }),
|
|
213
|
+
signal,
|
|
214
|
+
})
|
|
215
|
+
const body = await readJson(res)
|
|
216
|
+
if (!res.ok) throw new CalendarError(messageOf(body, res.status), res.status)
|
|
217
|
+
return (body as Envelope<Booking>).data as Booking
|
|
218
|
+
}
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// React data hooks over the client. useState/useEffect only — no query lib.
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
createBooking,
|
|
6
|
+
fetchBooking,
|
|
7
|
+
fetchEventType,
|
|
8
|
+
fetchSlots,
|
|
9
|
+
CalendarError,
|
|
10
|
+
type Booking,
|
|
11
|
+
type BookingResponses,
|
|
12
|
+
type EventType,
|
|
13
|
+
type SlotsByDay,
|
|
14
|
+
} from './client'
|
|
15
|
+
|
|
16
|
+
export interface AsyncState<T> {
|
|
17
|
+
data: T | null
|
|
18
|
+
loading: boolean
|
|
19
|
+
error: string | null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useEventType(
|
|
23
|
+
api: string,
|
|
24
|
+
eventSlug: string,
|
|
25
|
+
username: string
|
|
26
|
+
): AsyncState<EventType> {
|
|
27
|
+
const [state, setState] = useState<AsyncState<EventType>>({
|
|
28
|
+
data: null,
|
|
29
|
+
loading: true,
|
|
30
|
+
error: null,
|
|
31
|
+
})
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const ctrl = new AbortController()
|
|
34
|
+
setState({ data: null, loading: true, error: null })
|
|
35
|
+
fetchEventType(api, eventSlug, username, ctrl.signal)
|
|
36
|
+
.then((data) => setState({ data, loading: false, error: null }))
|
|
37
|
+
.catch((err: unknown) => {
|
|
38
|
+
if (ctrl.signal.aborted) return
|
|
39
|
+
setState({ data: null, loading: false, error: errorText(err) })
|
|
40
|
+
})
|
|
41
|
+
return () => ctrl.abort()
|
|
42
|
+
}, [api, eventSlug, username])
|
|
43
|
+
return state
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SlotsQuery {
|
|
47
|
+
eventTypeSlug: string
|
|
48
|
+
username: string
|
|
49
|
+
startTime: string
|
|
50
|
+
endTime: string
|
|
51
|
+
timeZone: string
|
|
52
|
+
enabled?: boolean
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SlotsState extends AsyncState<SlotsByDay> {
|
|
56
|
+
refetch: () => void
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function useAvailableSlots(api: string, query: SlotsQuery): SlotsState {
|
|
60
|
+
const { eventTypeSlug, username, startTime, endTime, timeZone, enabled = true } = query
|
|
61
|
+
const [state, setState] = useState<AsyncState<SlotsByDay>>({
|
|
62
|
+
data: null,
|
|
63
|
+
loading: enabled,
|
|
64
|
+
error: null,
|
|
65
|
+
})
|
|
66
|
+
const [nonce, setNonce] = useState(0)
|
|
67
|
+
const refetch = useCallback(() => setNonce((n) => n + 1), [])
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!enabled) return
|
|
71
|
+
const ctrl = new AbortController()
|
|
72
|
+
setState((s) => ({ data: s.data, loading: true, error: null }))
|
|
73
|
+
fetchSlots(
|
|
74
|
+
api,
|
|
75
|
+
{ eventTypeSlug, username, startTime, endTime, timeZone },
|
|
76
|
+
ctrl.signal
|
|
77
|
+
)
|
|
78
|
+
.then((data) => setState({ data, loading: false, error: null }))
|
|
79
|
+
.catch((err: unknown) => {
|
|
80
|
+
if (ctrl.signal.aborted) return
|
|
81
|
+
setState({ data: null, loading: false, error: errorText(err) })
|
|
82
|
+
})
|
|
83
|
+
return () => ctrl.abort()
|
|
84
|
+
}, [api, eventTypeSlug, username, startTime, endTime, timeZone, enabled, nonce])
|
|
85
|
+
|
|
86
|
+
return { ...state, refetch }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type SubmitResult =
|
|
90
|
+
| { ok: true; booking: Booking }
|
|
91
|
+
| { ok: false; conflict: boolean; message: string }
|
|
92
|
+
|
|
93
|
+
export interface CreateBookingState {
|
|
94
|
+
submit: (input: {
|
|
95
|
+
eventTypeSlug: string
|
|
96
|
+
username: string
|
|
97
|
+
start: string
|
|
98
|
+
timeZone: string
|
|
99
|
+
responses: BookingResponses
|
|
100
|
+
}) => Promise<SubmitResult>
|
|
101
|
+
submitting: boolean
|
|
102
|
+
error: string | null
|
|
103
|
+
conflict: boolean
|
|
104
|
+
reset: () => void
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function useCreateBooking(api: string): CreateBookingState {
|
|
108
|
+
const [submitting, setSubmitting] = useState(false)
|
|
109
|
+
const [error, setError] = useState<string | null>(null)
|
|
110
|
+
const [conflict, setConflict] = useState(false)
|
|
111
|
+
const mounted = useRef(true)
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
mounted.current = true
|
|
114
|
+
return () => {
|
|
115
|
+
mounted.current = false
|
|
116
|
+
}
|
|
117
|
+
}, [])
|
|
118
|
+
|
|
119
|
+
const reset = useCallback(() => {
|
|
120
|
+
setError(null)
|
|
121
|
+
setConflict(false)
|
|
122
|
+
}, [])
|
|
123
|
+
|
|
124
|
+
const submit = useCallback(
|
|
125
|
+
async (input: {
|
|
126
|
+
eventTypeSlug: string
|
|
127
|
+
username: string
|
|
128
|
+
start: string
|
|
129
|
+
timeZone: string
|
|
130
|
+
responses: BookingResponses
|
|
131
|
+
}): Promise<SubmitResult> => {
|
|
132
|
+
setSubmitting(true)
|
|
133
|
+
setError(null)
|
|
134
|
+
setConflict(false)
|
|
135
|
+
try {
|
|
136
|
+
const booking = await createBooking(api, input)
|
|
137
|
+
return { ok: true, booking }
|
|
138
|
+
} catch (err: unknown) {
|
|
139
|
+
const isConflict = err instanceof CalendarError && err.conflict
|
|
140
|
+
const message = errorText(err)
|
|
141
|
+
if (mounted.current) {
|
|
142
|
+
setError(message)
|
|
143
|
+
setConflict(isConflict)
|
|
144
|
+
}
|
|
145
|
+
return { ok: false, conflict: isConflict, message }
|
|
146
|
+
} finally {
|
|
147
|
+
if (mounted.current) setSubmitting(false)
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
[api]
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
return { submit, submitting, error, conflict, reset }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function useBooking(api: string, uid: string | undefined): AsyncState<Booking> {
|
|
157
|
+
const [state, setState] = useState<AsyncState<Booking>>({
|
|
158
|
+
data: null,
|
|
159
|
+
loading: !!uid,
|
|
160
|
+
error: null,
|
|
161
|
+
})
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (!uid) return
|
|
164
|
+
const ctrl = new AbortController()
|
|
165
|
+
setState({ data: null, loading: true, error: null })
|
|
166
|
+
fetchBooking(api, uid, ctrl.signal)
|
|
167
|
+
.then((data) => setState({ data, loading: false, error: null }))
|
|
168
|
+
.catch((err: unknown) => {
|
|
169
|
+
if (ctrl.signal.aborted) return
|
|
170
|
+
setState({ data: null, loading: false, error: errorText(err) })
|
|
171
|
+
})
|
|
172
|
+
return () => ctrl.abort()
|
|
173
|
+
}, [api, uid])
|
|
174
|
+
return state
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function errorText(err: unknown): string {
|
|
178
|
+
if (err instanceof Error) return err.message
|
|
179
|
+
return 'Something went wrong'
|
|
180
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// @hanzo/calendar — universal (web + native) booking UI on the Hanzo Calendar
|
|
2
|
+
// backend, built with hanzogui primitives.
|
|
3
|
+
|
|
4
|
+
export { Booker, type BookerProps } from './Booker'
|
|
5
|
+
export { CalendarEmbed, type CalendarEmbedProps } from './CalendarEmbed'
|
|
6
|
+
export { Confirmation, type ConfirmationProps } from './Confirmation'
|
|
7
|
+
|
|
8
|
+
// Composable sub-views (use these to build a custom flow).
|
|
9
|
+
export { EventHeader, type EventHeaderProps } from './EventHeader'
|
|
10
|
+
export { MonthCalendar, type MonthCalendarProps } from './MonthCalendar'
|
|
11
|
+
export { TimeSlots, type TimeSlotsProps } from './TimeSlots'
|
|
12
|
+
export { BookingForm, type BookingFormProps } from './BookingForm'
|
|
13
|
+
|
|
14
|
+
// Orchestration hook + data hooks.
|
|
15
|
+
export {
|
|
16
|
+
useBooker,
|
|
17
|
+
type UseBookerOptions,
|
|
18
|
+
type BookerModel,
|
|
19
|
+
type BookerPhase,
|
|
20
|
+
} from './useBooker'
|
|
21
|
+
export {
|
|
22
|
+
useEventType,
|
|
23
|
+
useAvailableSlots,
|
|
24
|
+
useCreateBooking,
|
|
25
|
+
useBooking,
|
|
26
|
+
type AsyncState,
|
|
27
|
+
type SlotsQuery,
|
|
28
|
+
type SlotsState,
|
|
29
|
+
type CreateBookingState,
|
|
30
|
+
type SubmitResult,
|
|
31
|
+
} from './hooks'
|
|
32
|
+
|
|
33
|
+
// Data plane.
|
|
34
|
+
export {
|
|
35
|
+
DEFAULT_API_URL,
|
|
36
|
+
CAL_API_VERSION,
|
|
37
|
+
CalendarError,
|
|
38
|
+
fetchEventType,
|
|
39
|
+
fetchSlots,
|
|
40
|
+
createBooking,
|
|
41
|
+
fetchBooking,
|
|
42
|
+
cancelBooking,
|
|
43
|
+
type EventType,
|
|
44
|
+
type Slot,
|
|
45
|
+
type SlotsByDay,
|
|
46
|
+
type Booking,
|
|
47
|
+
type Attendee,
|
|
48
|
+
type Location,
|
|
49
|
+
type Profile,
|
|
50
|
+
type BookingResponses,
|
|
51
|
+
type CreateBookingInput,
|
|
52
|
+
type SlotsInput,
|
|
53
|
+
} from './client'
|
|
54
|
+
|
|
55
|
+
// Time plane (Intl/Date helpers).
|
|
56
|
+
export {
|
|
57
|
+
visitorTimeZone,
|
|
58
|
+
ymd,
|
|
59
|
+
monthMatrix,
|
|
60
|
+
monthRange,
|
|
61
|
+
weekdayLabels,
|
|
62
|
+
formatMonthLabel,
|
|
63
|
+
formatDayLong,
|
|
64
|
+
formatSlotTime,
|
|
65
|
+
formatBookingWhen,
|
|
66
|
+
dateFromKey,
|
|
67
|
+
startOfMonth,
|
|
68
|
+
addMonths,
|
|
69
|
+
sameMonth,
|
|
70
|
+
isBeforeToday,
|
|
71
|
+
type MonthCell,
|
|
72
|
+
} from './time'
|
package/src/time.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Time plane — pure date/timezone helpers via Intl + Date only.
|
|
2
|
+
// No external date library. Web and React Native safe.
|
|
3
|
+
|
|
4
|
+
/** The visitor's IANA time zone, e.g. "America/New_York". */
|
|
5
|
+
export function visitorTimeZone(): string {
|
|
6
|
+
try {
|
|
7
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
|
|
8
|
+
} catch {
|
|
9
|
+
return 'UTC'
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Calendar date "YYYY-MM-DD" from a Date's local components. */
|
|
14
|
+
export function ymd(d: Date): string {
|
|
15
|
+
const y = d.getFullYear()
|
|
16
|
+
const m = String(d.getMonth() + 1).padStart(2, '0')
|
|
17
|
+
const day = String(d.getDate()).padStart(2, '0')
|
|
18
|
+
return `${y}-${m}-${day}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function startOfMonth(d: Date): Date {
|
|
22
|
+
return new Date(d.getFullYear(), d.getMonth(), 1)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function addMonths(d: Date, n: number): Date {
|
|
26
|
+
return new Date(d.getFullYear(), d.getMonth() + n, 1)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function sameMonth(a: Date, b: Date): boolean {
|
|
30
|
+
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** True if the day key is strictly before today (lexicographic works on YYYY-MM-DD). */
|
|
34
|
+
export function isBeforeToday(key: string): boolean {
|
|
35
|
+
return key < ymd(new Date())
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface MonthCell {
|
|
39
|
+
date: Date
|
|
40
|
+
key: string
|
|
41
|
+
inMonth: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A 6×7 grid of days covering the month of `viewDate`, including the leading
|
|
46
|
+
* and trailing days needed to fill whole weeks.
|
|
47
|
+
*/
|
|
48
|
+
export function monthMatrix(viewDate: Date, weekStartsOn = 0): MonthCell[] {
|
|
49
|
+
const year = viewDate.getFullYear()
|
|
50
|
+
const month = viewDate.getMonth()
|
|
51
|
+
const firstWeekday = new Date(year, month, 1).getDay()
|
|
52
|
+
const startOffset = (firstWeekday - weekStartsOn + 7) % 7
|
|
53
|
+
const cells: MonthCell[] = []
|
|
54
|
+
for (let i = 0; i < 42; i++) {
|
|
55
|
+
const date = new Date(year, month, 1 - startOffset + i)
|
|
56
|
+
cells.push({ date, key: ymd(date), inMonth: date.getMonth() === month })
|
|
57
|
+
}
|
|
58
|
+
return cells
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* ISO start/end covering the visible month, clamped so the start is never in
|
|
63
|
+
* the past (the backend rejects past ranges anyway).
|
|
64
|
+
*/
|
|
65
|
+
export function monthRange(viewDate: Date): { startTime: string; endTime: string } {
|
|
66
|
+
const year = viewDate.getFullYear()
|
|
67
|
+
const month = viewDate.getMonth()
|
|
68
|
+
const startOfView = new Date(year, month, 1, 0, 0, 0, 0)
|
|
69
|
+
const now = new Date()
|
|
70
|
+
const start = startOfView.getTime() > now.getTime() ? startOfView : now
|
|
71
|
+
const endOfView = new Date(year, month + 1, 0, 23, 59, 59, 999)
|
|
72
|
+
return { startTime: start.toISOString(), endTime: endOfView.toISOString() }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Short weekday headers ordered from `weekStartsOn` (0 = Sunday). */
|
|
76
|
+
export function weekdayLabels(weekStartsOn = 0): string[] {
|
|
77
|
+
const fmt = new Intl.DateTimeFormat(undefined, { weekday: 'short' })
|
|
78
|
+
const out: string[] = []
|
|
79
|
+
for (let i = 0; i < 7; i++) {
|
|
80
|
+
// 2023-01-01 is a Sunday; offset from there.
|
|
81
|
+
const d = new Date(2023, 0, 1 + ((i + weekStartsOn) % 7))
|
|
82
|
+
out.push(fmt.format(d))
|
|
83
|
+
}
|
|
84
|
+
return out
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function formatMonthLabel(d: Date): string {
|
|
88
|
+
return new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' }).format(d)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function formatDayLong(d: Date): string {
|
|
92
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
93
|
+
weekday: 'long',
|
|
94
|
+
month: 'long',
|
|
95
|
+
day: 'numeric',
|
|
96
|
+
}).format(d)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Slot time in the visitor's zone, e.g. "9:00 AM". */
|
|
100
|
+
export function formatSlotTime(iso: string, timeZone: string): string {
|
|
101
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
102
|
+
hour: 'numeric',
|
|
103
|
+
minute: '2-digit',
|
|
104
|
+
timeZone,
|
|
105
|
+
}).format(new Date(iso))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Full human "when" for a confirmation, e.g. "Monday, July 27, 2026 at 9:00 AM PDT". */
|
|
109
|
+
export function formatBookingWhen(iso: string, timeZone: string): string {
|
|
110
|
+
const at = new Date(iso)
|
|
111
|
+
const date = new Intl.DateTimeFormat(undefined, {
|
|
112
|
+
weekday: 'long',
|
|
113
|
+
month: 'long',
|
|
114
|
+
day: 'numeric',
|
|
115
|
+
year: 'numeric',
|
|
116
|
+
timeZone,
|
|
117
|
+
}).format(at)
|
|
118
|
+
const time = new Intl.DateTimeFormat(undefined, {
|
|
119
|
+
hour: 'numeric',
|
|
120
|
+
minute: '2-digit',
|
|
121
|
+
timeZoneName: 'short',
|
|
122
|
+
timeZone,
|
|
123
|
+
}).format(at)
|
|
124
|
+
return `${date} at ${time}`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A parsed local Date from a "YYYY-MM-DD" key (noon avoids DST edge slips). */
|
|
128
|
+
export function dateFromKey(key: string): Date {
|
|
129
|
+
const [y, m, d] = key.split('-').map(Number)
|
|
130
|
+
return new Date(y, (m ?? 1) - 1, d ?? 1, 12, 0, 0, 0)
|
|
131
|
+
}
|