@doany-ai/calendar 0.0.0-stage → 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +197 -2
- package/dist/booking-link.d.ts +8 -0
- package/dist/booking-link.js +60 -0
- package/dist/components/attendee-form.d.ts +28 -0
- package/dist/components/attendee-form.js +55 -0
- package/dist/components/booking-flow.d.ts +48 -0
- package/dist/components/booking-flow.js +138 -0
- package/dist/components/booking-status.d.ts +23 -0
- package/dist/components/booking-status.js +53 -0
- package/dist/components/date-time-picker.d.ts +54 -0
- package/dist/components/date-time-picker.js +82 -0
- package/dist/components/package-list.d.ts +42 -0
- package/dist/components/package-list.js +36 -0
- package/dist/components/service-list.d.ts +47 -0
- package/dist/components/service-list.js +69 -0
- package/dist/components/session-calendar.d.ts +23 -0
- package/dist/components/session-calendar.js +52 -0
- package/dist/components/session-list.d.ts +50 -0
- package/dist/components/session-list.js +139 -0
- package/dist/format.d.ts +37 -0
- package/dist/format.js +90 -0
- package/dist/hooks.d.ts +1050 -0
- package/dist/hooks.js +209 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +19 -0
- package/dist/labels.d.ts +105 -0
- package/dist/labels.js +77 -0
- package/dist/phone.d.ts +4 -0
- package/dist/phone.js +7 -0
- package/dist/types.d.ts +277 -0
- package/dist/types.js +1 -0
- package/package.json +52 -4
package/README.md
CHANGED
|
@@ -1,3 +1,198 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @doany-ai/calendar
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Booking the business's services on a Doany site: the services a visitor can book, a date and time picker, the booking flow, class sessions with seats, and the page a booking's link opens. Hooks read and book through the SDK client the site gives `<DoanyProvider>`; the components are defaults the site restyles through the theme variables, adjusts through props (every word through `labels`), or replaces while keeping the hooks.
|
|
4
|
+
|
|
5
|
+
The business sets its services up in its Catalog (type, options with duration and price, how clients book, cancellation rules, questions). The site only reads them and books. Nothing here creates services.
|
|
6
|
+
|
|
7
|
+
```jsx
|
|
8
|
+
import { ServiceList, BookingFlow, SessionList, BookingStatus } from "@doany-ai/calendar";
|
|
9
|
+
import { Link, useParams } from "react-router-dom";
|
|
10
|
+
|
|
11
|
+
// /book — what can be booked; each card leads to its booking page
|
|
12
|
+
<ServiceList hrefFor={(s) => `/book/${s.id}`} renderLink={({ href, className, children }) => <Link to={href} className={className}>{children}</Link>} />
|
|
13
|
+
|
|
14
|
+
// /book/:serviceSettingId — pick a time, say who, done
|
|
15
|
+
const { serviceSettingId } = useParams();
|
|
16
|
+
<BookingFlow serviceSettingId={serviceSettingId} />
|
|
17
|
+
|
|
18
|
+
// /classes — upcoming sessions with seats; Register opens the form in a dialog
|
|
19
|
+
<SessionList />
|
|
20
|
+
|
|
21
|
+
// /bookings/:registrationId — the page a booking's link opens; cancel within the policy
|
|
22
|
+
const { registrationId } = useParams();
|
|
23
|
+
<BookingStatus registrationId={registrationId} />
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Keep `AuthProvider` in `src/App.jsx`: `BookingFlow` and `SessionList` read the signed-in account to prefill the form. List the pages in `doany/features.jsonc` under `booking`.
|
|
27
|
+
|
|
28
|
+
## Records
|
|
29
|
+
|
|
30
|
+
What the hooks return, as the platform gives them (the fields this package reads; `src/types.ts` has them all).
|
|
31
|
+
|
|
32
|
+
- **BookableService** (`services.list` / `useBookableServices`): `id` (what `slots` and `book` take), `item_name`, `booking_mode` (`appointment` | `class`), `is_listed` (false: the business quotes first — not in the list, readable by id), `variants[]` (`id`, `name`, `duration_minutes`, `price_amount`, `currency`, `is_default`), `payment_requirement` (`none` | `full_online`), `location_type` (`business_address` | `customer_address` | `online`) + `address`, `phone_required`, `booking_fields[]` (the questions: `key`, `type`, `label`, `required`, `options`), `allow_customer_cancel`, `cancellation_cutoff_minutes`, `cancellation_policy_text`, `booking_horizon_days`, `seat_capacity`.
|
|
33
|
+
- **Slot** (`useSlots`): `start_at`, `end_at`, `slot_token` (good for five minutes; holds nothing), `token_expires_at`.
|
|
34
|
+
- **Booking** (`useSessions`): an appointment or a class session: `id`, `version`, `kind`, `status`, `item_name`, `variant_name`, `start_at`, `end_at`, `timezone`, `price_amount`, `currency`, `payment_requirement`, `location_type`, `address`, `booking_fields_snapshot`, `allowed_actions` (`create_registration` while seats are open); a session adds `capacity`, `remaining_seats`, `allowed_variants[]` (the tickets).
|
|
35
|
+
- **Registration** (`useRegistration`, in every booking result): one person's place: `id`, `booking_id`, `version`, `status` (`pending_payment` | `confirmed` | `canceled` | `expired`), `order_id` (when paid online), `hold_expires_at`, `allowed_actions` (`cancel` when the policy allows).
|
|
36
|
+
- **BookingResult** (`useBook`, `useRegister`): `{ booking, registration, access_token, order_access_token }`. The SDK keeps the tokens for the browser session, so the calls that follow need none.
|
|
37
|
+
|
|
38
|
+
Times are ISO instants; the components show them in the visitor's timezone (`timezone` prop, default the browser's). Prices are in the currency's smallest unit (`12000` = $120.00).
|
|
39
|
+
|
|
40
|
+
## Components
|
|
41
|
+
|
|
42
|
+
### `ServiceList`
|
|
43
|
+
|
|
44
|
+
The bookable services as cards (name, price, length, a Book button), with "Load more" while there are more.
|
|
45
|
+
|
|
46
|
+
| prop | |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| `params` | `{ booking_mode?: "appointment" \| "class", limit? }` |
|
|
49
|
+
| `hrefFor` | `(service) => string`: each card's link |
|
|
50
|
+
| `renderLink` | `({ href, className, children }) => node`: render links with the site's router |
|
|
51
|
+
| `onSelect` | `(service) => void`: instead of a link |
|
|
52
|
+
| `renderItem` | `(service) => node`: replaces the card |
|
|
53
|
+
| `empty` | node shown when nothing can be booked |
|
|
54
|
+
| `footer` | node inside each card, under its text |
|
|
55
|
+
| `variant` | `"grid"` (default): tiles. `"menu"`: the services grouped by their catalog category, with a category bar and a search box, each a row — a service menu |
|
|
56
|
+
| `showDetails` | read each service's description, category and image from the catalog (`doany.catalog.list({ type: "service" })`); default `true`. A catalog that cannot be read leaves the cards without them |
|
|
57
|
+
| `locale`, `labels`, `className` | |
|
|
58
|
+
|
|
59
|
+
### `ServiceCard`
|
|
60
|
+
|
|
61
|
+
One service: `service`, `href` / `onSelect`, `renderLink`, `footer`, `locale`, `labels`, `className`.
|
|
62
|
+
|
|
63
|
+
### `BookingFlow`
|
|
64
|
+
|
|
65
|
+
Book an appointment: the variant (when the service has several), a day and a time, who it is for, then the confirmation.
|
|
66
|
+
|
|
67
|
+
| prop | |
|
|
68
|
+
| --- | --- |
|
|
69
|
+
| `serviceSettingId` | required: the service (`BookableService.id`) |
|
|
70
|
+
| `catalogItemVariantId` | skip the variant choice |
|
|
71
|
+
| `timezone` | the timezone times are shown in; default the browser's |
|
|
72
|
+
| `address` | for a service at the customer's address |
|
|
73
|
+
| `layout` | `"split"` (default): the service's summary (image, description, length, price, place, chosen time, timezone) in a column beside the steps. `"stacked"`: one column, summary on top |
|
|
74
|
+
| `order` | `"when-first"` (default): day and time, then the details. `"who-first"`: the details first, then the time; the chosen time books at once |
|
|
75
|
+
| `image`, `showDetails` | the summary's image (the catalog item's by default) and whether to read the catalog's description and image at all (default `true`) |
|
|
76
|
+
| `renderPayment` | `(result) => node`: for a service paid online, what to render while the payment is due — the site's checkout for `result.registration.order_id` (`@doany-ai/commerce`'s `OrderCheckout`) |
|
|
77
|
+
| `onBooked` | `(result) => void` |
|
|
78
|
+
| `children` | `(result) => node`: under the confirmation, links onward |
|
|
79
|
+
| `locale`, `labels`, `className` | |
|
|
80
|
+
|
|
81
|
+
A slot taken by someone else meanwhile (`SLOT_UNAVAILABLE`) sends the visitor back to pick another; the times are re-read.
|
|
82
|
+
|
|
83
|
+
### `DateTimePicker`
|
|
84
|
+
|
|
85
|
+
The calendar and the day's times, with the timezone under it. For a site that draws its own flow around it.
|
|
86
|
+
|
|
87
|
+
| prop | |
|
|
88
|
+
| --- | --- |
|
|
89
|
+
| `service`, `variant` | required |
|
|
90
|
+
| `timezone`, `onTimezoneChange` | |
|
|
91
|
+
| `value`, `onChange` | the chosen `Slot` |
|
|
92
|
+
| `onConfirm`, `confirmLabel` | the button that appears beside the chosen slot (`selectTime` label by default) |
|
|
93
|
+
| `layout` | `"wide"` (default): the times beside the calendar. `"narrow"`: under it |
|
|
94
|
+
| `locale`, `labels`, `className` | |
|
|
95
|
+
|
|
96
|
+
### `AttendeeForm`
|
|
97
|
+
|
|
98
|
+
Name, email, phone and the service's questions. `fields` (the service's `booking_fields`), `phoneRequired`, `defaultValues`, `onSubmit(attendee)`, `submitLabel`, `busy`, `error`, `secondaryAction`, `labels`, `className`. The flow adds the timezone before booking.
|
|
99
|
+
|
|
100
|
+
### `SessionCalendar`
|
|
101
|
+
|
|
102
|
+
A month of class sessions: the days with sessions are marked on the calendar; the chosen day's sessions, with their seats, are listed beside it, each with Register (the same dialog as `SessionList`).
|
|
103
|
+
|
|
104
|
+
| prop | |
|
|
105
|
+
| --- | --- |
|
|
106
|
+
| `serviceSettingId` | only this class |
|
|
107
|
+
| `timezone`, `renderPayment`, `onRegistered`, `locale`, `labels`, `className` | as in `SessionList` |
|
|
108
|
+
|
|
109
|
+
### `PackageList`, `PackageCard`
|
|
110
|
+
|
|
111
|
+
The packages of uses a visitor can buy (`doany.catalog.list({ type: "plan", purchasable: true })`), as pricing cards: name, price, description, and the way to buy — which the site provides, because buying is `@doany-ai/commerce`'s job:
|
|
112
|
+
|
|
113
|
+
```jsx
|
|
114
|
+
<PackageList
|
|
115
|
+
highlight={(item) => item.name === "10-Class Pack"}
|
|
116
|
+
renderBuy={(item, variant) => <BuyButton items={[{ catalog_item_variant_id: variant.id, quantity: 1 }]} successPath={(id) => `/orders/${id}`} />}
|
|
117
|
+
/>
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
| prop | |
|
|
121
|
+
| --- | --- |
|
|
122
|
+
| `renderBuy` | `(item, variant) => node`: the buying control. Left out, a plain Buy button calls `onBuy` |
|
|
123
|
+
| `onBuy` | `(item, variant) => void` |
|
|
124
|
+
| `highlight` | `(item) => boolean`: the card to mark out |
|
|
125
|
+
| `footerFor` | `(item) => node`: under each description |
|
|
126
|
+
| `category`, `limit`, `renderItem`, `empty`, `locale`, `labels`, `className` | |
|
|
127
|
+
|
|
128
|
+
### `SessionList`
|
|
129
|
+
|
|
130
|
+
Upcoming class sessions grouped by day, each with its seats and a Register button that opens the form in a dialog.
|
|
131
|
+
|
|
132
|
+
| prop | |
|
|
133
|
+
| --- | --- |
|
|
134
|
+
| `params` | `{ service_setting_id?, from?, to?, limit? }` |
|
|
135
|
+
| `timezone` | default the browser's |
|
|
136
|
+
| `renderPayment` | as in `BookingFlow` |
|
|
137
|
+
| `onRegistered` | `(result) => void` |
|
|
138
|
+
| `empty` | node when there are no sessions |
|
|
139
|
+
| `locale`, `labels`, `className` | |
|
|
140
|
+
|
|
141
|
+
### `BookingStatus`
|
|
142
|
+
|
|
143
|
+
The page a booking's link opens (`/bookings/<registration id>#t=<token>`): status, what, when, where, and Cancel within the policy.
|
|
144
|
+
|
|
145
|
+
| prop | |
|
|
146
|
+
| --- | --- |
|
|
147
|
+
| `registrationId` | required |
|
|
148
|
+
| `accessToken` | default: the link's `#t=`, else this browser session's |
|
|
149
|
+
| `timezone` | default the browser's |
|
|
150
|
+
| `allowCancel` | default `true` |
|
|
151
|
+
| `children` | `(status, detail) => node` |
|
|
152
|
+
| `locale`, `labels`, `className` | |
|
|
153
|
+
|
|
154
|
+
## Hooks
|
|
155
|
+
|
|
156
|
+
| hook | returns |
|
|
157
|
+
| `useServiceDetails()` | query of the catalog's service items, plus `byItem`: a `Map` from catalog item id to `{ description, category, image_url, … }` |
|
|
158
|
+
| `usePackages({ limit?, category? })` | query of the packages on sale, plus `packages` |
|
|
159
|
+
| --- | --- |
|
|
160
|
+
| `useBookableServices({ booking_mode?, limit? })` | react-query infinite query + `services` (every page so far) + `timezone` (the business's) |
|
|
161
|
+
| `useBookableService(id)` | query of one `BookableService` |
|
|
162
|
+
| `useSlots(serviceSettingId, { catalogItemVariantId, date: "YYYY-MM-DD", timezone })` | infinite query + `slots` |
|
|
163
|
+
| `useBook()` | mutation: `mutate({ service_setting_id, catalog_item_variant_id, start_at, slot_token, attendee: { name, timezone, email?, phone?, answers? }, address? })` → `BookingResult` |
|
|
164
|
+
| `useSessions({ service_setting_id?, from?, to?, limit? })` | infinite query + `sessions` + `timezone` |
|
|
165
|
+
| `useSession(bookingId)` | query of one `Booking` |
|
|
166
|
+
| `useRegister()` | mutation: `mutate({ bookingId, attendee, catalog_item_variant_id?, expected: { booking_version, payment_requirement, item_version?, variant_version? } })` → `BookingResult` |
|
|
167
|
+
| `useRegistration(registrationId, { accessToken? })` | query of `{ registration, booking }`; polls while `pending_payment` |
|
|
168
|
+
| `useCancelRegistration()` | mutation: `mutate({ registrationId, registration_version, booking_version, reason?, accessToken? })` |
|
|
169
|
+
| `readBookingLinkToken(registrationId)` | the token in the page's link, for a page that calls the SDK itself |
|
|
170
|
+
|
|
171
|
+
Helpers: `formatDuration(minutes)`, `formatTime(iso, timezone, locale)`, `formatDay`, `formatDateTime`, `formatTimezone`, `localDate(date, timezone)`, `browserTimezone()`, `servicePriceLabel(service, locale)`, `variantPriceLabel(variant, locale)`, `defaultVariant(service)`.
|
|
172
|
+
|
|
173
|
+
## Labels
|
|
174
|
+
|
|
175
|
+
Every word the components show is in `labels` (`Partial<CalendarLabels>`; the rest keep their defaults). A site in another language passes its words once to the outer component; it passes them on.
|
|
176
|
+
|
|
177
|
+
`free`, `fromPrice(price)`, `duration(minutes)`, `book`, `askForQuote`, `noServices`, `loadError`, `tryAgain`, `loadMore`, `loadingMore`, `notAvailable`, `selectDateTime`, `timezone`, `noSlots`, `previousMonth`, `nextMonth`, `options`, `yourDetails`, `name`, `email`, `phone`, `confirmBooking`, `booking`, `back`, `slotTaken`, `bookingFailed`, `rateLimited`, `bookedTitle`, `bookedBody(when)`, `payToConfirmTitle`, `payToConfirmBody(until)`, `payNow`, `seatsLeft(n)`, `full`, `register`, `registered`, `noSessions`, `statusConfirmed`, `statusPendingPayment`, `statusCanceled`, `statusExpired`, `cancelBooking`, `cancelConfirm`, `keepBooking`, `canceling`, `cancelFailed`, `cancelProcessing`, `bookingNotFound`, `bookingCheckFailed`, `cancellationPolicy`, `location`, `online`, `atYourAddress`.
|
|
178
|
+
|
|
179
|
+
Failure wording: the site's label (`loadError`, `bookingFailed`, `cancelFailed`) when set, else the platform's own message, else the default.
|
|
180
|
+
|
|
181
|
+
## Customizing
|
|
182
|
+
|
|
183
|
+
Stop at the first level that does what was asked:
|
|
184
|
+
|
|
185
|
+
1. **Theme**: the variables in the site's `src/index.css` (`--primary`, `--radius`, `--font-heading`, …) style every component here.
|
|
186
|
+
2. **Props**: `className`, `labels`, `renderLink`, `renderItem`, `empty`, `renderPayment`, `children`.
|
|
187
|
+
3. **Hooks**: build the part yourself on `useBookableServices`, `useSlots`, `useBook`, `useSessions`, `useRegister`, `useRegistration`, `useCancelRegistration`.
|
|
188
|
+
|
|
189
|
+
The package ships no CSS: the site's Tailwind build scans `node_modules/@doany-ai/calendar/dist` (the general and service scaffolds do).
|
|
190
|
+
|
|
191
|
+
## Develop
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
npm install
|
|
195
|
+
npm run typecheck # includes tests/sdk-contract.types.ts: the real SDK client satisfies CalendarClient
|
|
196
|
+
npm test
|
|
197
|
+
npm run build
|
|
198
|
+
```
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The access token of the registration this page was opened for by a link,
|
|
3
|
+
* or the one this tab kept from such a link. Only taken for the registration
|
|
4
|
+
* whose id is a segment of the page's path.
|
|
5
|
+
*/
|
|
6
|
+
export declare function readBookingLinkToken(registrationId: string | null | undefined): string | undefined;
|
|
7
|
+
/** The given token, else the booking link's. */
|
|
8
|
+
export declare function useBookingAccessToken(registrationId: string | null | undefined, given?: string): string | undefined;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
// A booking link carries the registration's access token after the `#`:
|
|
3
|
+
// `/bookings/<registration id>#t=<token>`, the way an order link does
|
|
4
|
+
// (docs/specs/basic-modules-redesign/api/conventions.md). The hash never
|
|
5
|
+
// reaches a server. The token is kept for the tab, per registration, so a
|
|
6
|
+
// payment that leaves the site and comes back (no hash) still finds it.
|
|
7
|
+
const storageKey = (registrationId) => `doany_booking_link_token_${registrationId}`;
|
|
8
|
+
function pathSegments(pathname) {
|
|
9
|
+
return pathname.split("/").map((segment) => {
|
|
10
|
+
try {
|
|
11
|
+
return decodeURIComponent(segment);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return segment;
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
function session() {
|
|
19
|
+
try {
|
|
20
|
+
return typeof window !== "undefined" ? window.sessionStorage : null;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The access token of the registration this page was opened for by a link,
|
|
28
|
+
* or the one this tab kept from such a link. Only taken for the registration
|
|
29
|
+
* whose id is a segment of the page's path.
|
|
30
|
+
*/
|
|
31
|
+
export function readBookingLinkToken(registrationId) {
|
|
32
|
+
if (!registrationId || typeof window === "undefined")
|
|
33
|
+
return undefined;
|
|
34
|
+
const { hash, pathname } = window.location;
|
|
35
|
+
let token;
|
|
36
|
+
if (hash && pathSegments(pathname).includes(registrationId)) {
|
|
37
|
+
token = new URLSearchParams(hash.slice(1)).get("t") || undefined;
|
|
38
|
+
if (token) {
|
|
39
|
+
try {
|
|
40
|
+
session()?.setItem(storageKey(registrationId), token);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
/* best effort */
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (!token) {
|
|
48
|
+
try {
|
|
49
|
+
token = session()?.getItem(storageKey(registrationId)) || undefined;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
token = undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return token;
|
|
56
|
+
}
|
|
57
|
+
/** The given token, else the booking link's. */
|
|
58
|
+
export function useBookingAccessToken(registrationId, given) {
|
|
59
|
+
return useMemo(() => given ?? readBookingLinkToken(registrationId), [registrationId, given]);
|
|
60
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type CalendarLabels } from "../labels.js";
|
|
2
|
+
import type { BookingField } from "../types.js";
|
|
3
|
+
/** What the form collects; the flow adds the timezone. */
|
|
4
|
+
export interface AttendeeInput {
|
|
5
|
+
name: string;
|
|
6
|
+
email?: string;
|
|
7
|
+
phone?: string;
|
|
8
|
+
answers: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
export interface AttendeeFormProps {
|
|
11
|
+
/** The service's questions, asked after name and contact. */
|
|
12
|
+
fields?: BookingField[];
|
|
13
|
+
/** The service requires a phone; otherwise an email or a phone, one of them. */
|
|
14
|
+
phoneRequired?: boolean;
|
|
15
|
+
defaultValues?: Partial<AttendeeInput>;
|
|
16
|
+
onSubmit: (attendee: AttendeeInput) => void;
|
|
17
|
+
/** Wins over the `confirmBooking` label. */
|
|
18
|
+
submitLabel?: string;
|
|
19
|
+
busy?: boolean;
|
|
20
|
+
/** Shown above the button: why the booking could not be made. */
|
|
21
|
+
error?: string | null;
|
|
22
|
+
/** Rendered beside the submit button, e.g. a Back button. */
|
|
23
|
+
secondaryAction?: React.ReactNode;
|
|
24
|
+
labels?: Partial<CalendarLabels>;
|
|
25
|
+
className?: string;
|
|
26
|
+
}
|
|
27
|
+
/** Who the booking is for: name, a way to reach them, and the service's questions. */
|
|
28
|
+
export declare function AttendeeForm({ fields, phoneRequired, defaultValues, onSubmit, submitLabel, busy, error, secondaryAction, labels: given, className, }: AttendeeFormProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from "@doany-ai/app-core";
|
|
3
|
+
import { Button } from "@doany-ai/app-core/ui/button";
|
|
4
|
+
import { Checkbox } from "@doany-ai/app-core/ui/checkbox";
|
|
5
|
+
import { Input } from "@doany-ai/app-core/ui/input";
|
|
6
|
+
import { Label } from "@doany-ai/app-core/ui/label";
|
|
7
|
+
import { Textarea } from "@doany-ai/app-core/ui/textarea";
|
|
8
|
+
import { Loader2 } from "lucide-react";
|
|
9
|
+
import { useState } from "react";
|
|
10
|
+
import { calendarLabels } from "../labels.js";
|
|
11
|
+
import { isE164, normalizePhone } from "../phone.js";
|
|
12
|
+
function Question({ field, value, onChange }) {
|
|
13
|
+
const id = `doany-booking-${field.key}`;
|
|
14
|
+
if (field.type === "checkbox") {
|
|
15
|
+
return (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Checkbox, { id: id, checked: Boolean(value), onCheckedChange: (checked) => onChange(checked === true), required: field.required }), _jsx(Label, { htmlFor: id, children: field.label })] }));
|
|
16
|
+
}
|
|
17
|
+
if (field.type === "select") {
|
|
18
|
+
return (_jsxs("div", { className: "space-y-2", children: [_jsx(Label, { htmlFor: id, children: field.label }), _jsxs("select", { id: id, value: typeof value === "string" ? value : "", onChange: (e) => onChange(e.target.value), required: field.required, className: "h-10 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring", children: [_jsx("option", { value: "", children: "\u2014" }), (field.options ?? []).map((option) => (_jsx("option", { value: option, children: option }, option)))] })] }));
|
|
19
|
+
}
|
|
20
|
+
if (field.type === "multiselect") {
|
|
21
|
+
const chosen = Array.isArray(value) ? value : [];
|
|
22
|
+
return (_jsxs("fieldset", { className: "space-y-2", children: [_jsx("legend", { className: "text-sm font-medium", children: field.label }), (field.options ?? []).map((option) => {
|
|
23
|
+
const optionId = `${id}-${option}`;
|
|
24
|
+
const on = chosen.includes(option);
|
|
25
|
+
return (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Checkbox, { id: optionId, checked: on, onCheckedChange: (checked) => onChange(checked === true ? [...chosen, option] : chosen.filter((o) => o !== option)) }), _jsx(Label, { htmlFor: optionId, children: option })] }, option));
|
|
26
|
+
})] }));
|
|
27
|
+
}
|
|
28
|
+
if (field.type === "textarea") {
|
|
29
|
+
return (_jsxs("div", { className: "space-y-2", children: [_jsx(Label, { htmlFor: id, children: field.label }), _jsx(Textarea, { id: id, value: typeof value === "string" ? value : "", onChange: (e) => onChange(e.target.value), required: field.required })] }));
|
|
30
|
+
}
|
|
31
|
+
return (_jsxs("div", { className: "space-y-2", children: [_jsx(Label, { htmlFor: id, children: field.label }), _jsx(Input, { id: id, value: typeof value === "string" ? value : "", onChange: (e) => onChange(e.target.value), required: field.required })] }));
|
|
32
|
+
}
|
|
33
|
+
/** Who the booking is for: name, a way to reach them, and the service's questions. */
|
|
34
|
+
export function AttendeeForm({ fields = [], phoneRequired = false, defaultValues, onSubmit, submitLabel, busy = false, error, secondaryAction, labels: given, className, }) {
|
|
35
|
+
const labels = calendarLabels(given);
|
|
36
|
+
const [name, setName] = useState(defaultValues?.name ?? "");
|
|
37
|
+
const [email, setEmail] = useState(defaultValues?.email ?? "");
|
|
38
|
+
const [phone, setPhone] = useState(defaultValues?.phone ?? "");
|
|
39
|
+
const [answers, setAnswers] = useState(defaultValues?.answers ?? {});
|
|
40
|
+
const submit = (event) => {
|
|
41
|
+
event.preventDefault();
|
|
42
|
+
onSubmit({
|
|
43
|
+
name: name.trim(),
|
|
44
|
+
...(email.trim() ? { email: email.trim() } : {}),
|
|
45
|
+
...(phone.trim() ? { phone: normalizePhone(phone) } : {}),
|
|
46
|
+
answers,
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
return (_jsxs("form", { onSubmit: submit, className: cn("space-y-4", className), children: [_jsxs("div", { className: "space-y-2", children: [_jsx(Label, { htmlFor: "doany-attendee-name", children: labels.name }), _jsx(Input, { id: "doany-attendee-name", autoComplete: "name", value: name, onChange: (e) => setName(e.target.value), required: true })] }), _jsxs("div", { className: "space-y-2", children: [_jsx(Label, { htmlFor: "doany-attendee-email", children: labels.email }), _jsx(Input, { id: "doany-attendee-email", type: "email", autoComplete: "email", value: email, onChange: (e) => setEmail(e.target.value), required: !phoneRequired && !phone.trim() })] }), _jsxs("div", { className: "space-y-2", children: [_jsx(Label, { htmlFor: "doany-attendee-phone", children: labels.phone }), _jsx(Input, { id: "doany-attendee-phone", type: "tel", autoComplete: "tel", value: phone, onChange: (e) => {
|
|
50
|
+
// The platform takes E.164 only: say so before the submit does.
|
|
51
|
+
const normalized = normalizePhone(e.target.value);
|
|
52
|
+
e.target.setCustomValidity(!normalized || isE164(normalized) ? "" : labels.phoneFormat);
|
|
53
|
+
setPhone(e.target.value);
|
|
54
|
+
}, required: phoneRequired })] }), fields.map((field) => (_jsx(Question, { field: field, value: answers[field.key], onChange: (v) => setAnswers((a) => ({ ...a, [field.key]: v })) }, field.key))), error && (_jsx("p", { role: "alert", className: "text-sm text-destructive", children: error })), _jsxs("div", { className: "flex items-center gap-3 pt-1", children: [secondaryAction, _jsxs(Button, { type: "submit", className: "h-10 flex-1 rounded-full", disabled: busy, children: [busy && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), busy ? labels.booking : (submitLabel ?? labels.confirmBooking)] })] })] }));
|
|
55
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { type CalendarLabels } from "../labels.js";
|
|
3
|
+
import type { BookingFunding, BookingResult } from "../types.js";
|
|
4
|
+
export interface BookingFlowProps {
|
|
5
|
+
/** The service's booking settings id (`BookableService.id`). */
|
|
6
|
+
serviceSettingId: string;
|
|
7
|
+
/** Preselect a variant; the service's default otherwise. */
|
|
8
|
+
catalogItemVariantId?: string;
|
|
9
|
+
/** The timezone to start in; the visitor's own otherwise. They can change it. */
|
|
10
|
+
timezone?: string;
|
|
11
|
+
/** For a service at the customer's address: where. */
|
|
12
|
+
address?: string;
|
|
13
|
+
/** Pay with a package the customer holds (its entitlement) instead of the service's default. */
|
|
14
|
+
funding?: BookingFunding;
|
|
15
|
+
/**
|
|
16
|
+
* `split` (default): the service's summary in a column beside the steps
|
|
17
|
+
* when the flow is at least 48rem wide, on top of them otherwise (its own
|
|
18
|
+
* width, not the screen's). `stacked`: always on top, in one row.
|
|
19
|
+
*/
|
|
20
|
+
layout?: "split" | "stacked";
|
|
21
|
+
/**
|
|
22
|
+
* `when-first` (default): day and time, then who it is for. `who-first`:
|
|
23
|
+
* the details first, then the time; the chosen time books at once.
|
|
24
|
+
*/
|
|
25
|
+
order?: "when-first" | "who-first";
|
|
26
|
+
/** An image for the summary; the catalog item's own when left out. */
|
|
27
|
+
image?: string | null;
|
|
28
|
+
/** Show the catalog's description and image in the summary. Default `true`. */
|
|
29
|
+
showDetails?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* A service paid online: the payment, given the booking (its
|
|
32
|
+
* `registration.order_id`); e.g. `<OrderCheckout orderId=…/>` from
|
|
33
|
+
* `@doany-ai/commerce`. Left out, the flow shows that payment is due.
|
|
34
|
+
*/
|
|
35
|
+
renderPayment?: (result: BookingResult) => ReactNode;
|
|
36
|
+
/** The booking was made (confirmed, or awaiting its payment). */
|
|
37
|
+
onBooked?: (result: BookingResult) => void;
|
|
38
|
+
/** Under the confirmation: links onward. */
|
|
39
|
+
children?: (result: BookingResult) => ReactNode;
|
|
40
|
+
locale?: string;
|
|
41
|
+
labels?: Partial<CalendarLabels>;
|
|
42
|
+
className?: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Book an appointment in one place: the service's variant, a day and a time,
|
|
46
|
+
* who it is for, then the confirmation (or the payment that confirms it).
|
|
47
|
+
*/
|
|
48
|
+
export declare function BookingFlow({ serviceSettingId, catalogItemVariantId, timezone: given, address, funding, layout, order, image: givenImage, showDetails, renderPayment, onBooked, children, locale, labels: givenLabels, className, }: BookingFlowProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Image } from "@doany-ai/app-core/media";
|
|
3
|
+
import { cn } from "@doany-ai/app-core";
|
|
4
|
+
import { useAuth } from "@doany-ai/app-core/auth";
|
|
5
|
+
import { Button } from "@doany-ai/app-core/ui/button";
|
|
6
|
+
import { Input } from "@doany-ai/app-core/ui/input";
|
|
7
|
+
import { Label } from "@doany-ai/app-core/ui/label";
|
|
8
|
+
import { Skeleton } from "@doany-ai/app-core/ui/skeleton";
|
|
9
|
+
import { CalendarCheck, CheckCircle2, Clock, Globe, MapPin, Tag, Users } from "lucide-react";
|
|
10
|
+
import { useState } from "react";
|
|
11
|
+
import { browserTimezone, defaultVariant, formatDateTime, formatTimezone, variantPriceLabel } from "../format.js";
|
|
12
|
+
import { useBook, useBookableService, useServiceDetails } from "../hooks.js";
|
|
13
|
+
import { calendarLabels } from "../labels.js";
|
|
14
|
+
import { AttendeeForm } from "./attendee-form.js";
|
|
15
|
+
import { DateTimePicker } from "./date-time-picker.js";
|
|
16
|
+
function messageOf(cause, labels, given) {
|
|
17
|
+
const { code, message } = (cause ?? {});
|
|
18
|
+
if (code === "SLOT_UNAVAILABLE")
|
|
19
|
+
return { text: labels.slotTaken, slotTaken: true };
|
|
20
|
+
if (code === "RATE_LIMITED")
|
|
21
|
+
return { text: labels.rateLimited, slotTaken: false };
|
|
22
|
+
return { text: given?.bookingFailed ?? (message || labels.bookingFailed), slotTaken: false };
|
|
23
|
+
}
|
|
24
|
+
function VariantChoice({ service, value, onChange, locale, labels }) {
|
|
25
|
+
if (service.variants.length < 2)
|
|
26
|
+
return null;
|
|
27
|
+
return (_jsx("div", { role: "radiogroup", "aria-label": labels.options, className: "flex flex-wrap gap-2", children: service.variants.map((variant) => {
|
|
28
|
+
const on = variant.id === value.id;
|
|
29
|
+
const price = variantPriceLabel(variant, locale, { free: labels.free });
|
|
30
|
+
return (_jsxs("button", { type: "button", role: "radio", "aria-checked": on, onClick: () => onChange(variant), className: cn("rounded-lg border px-4 py-2 text-sm font-medium transition-colors", on ? "border-primary bg-primary text-primary-foreground" : "border-border bg-background text-foreground hover:border-primary/60"), children: [variant.name, variant.duration_minutes ? ` · ${labels.duration(variant.duration_minutes)}` : "", price ? ` · ${price}` : ""] }, variant.id));
|
|
31
|
+
}) }));
|
|
32
|
+
}
|
|
33
|
+
function Summary({ service, variant, slot, timezone, image, description, locale, labels, stacked, }) {
|
|
34
|
+
const price = variantPriceLabel(variant, locale, { free: labels.free });
|
|
35
|
+
const place = service.location_type === "online" ? labels.online : service.location_type === "customer_address" ? labels.atYourAddress : service.address;
|
|
36
|
+
const rows = [
|
|
37
|
+
[Clock, variant.duration_minutes ? labels.duration(variant.duration_minutes) : null],
|
|
38
|
+
[Tag, price],
|
|
39
|
+
[service.booking_mode === "class" ? Users : MapPin, service.booking_mode === "class" && service.seat_capacity ? labels.classSeats(service.seat_capacity) : place],
|
|
40
|
+
[CalendarCheck, slot ? formatDateTime(slot.start_at, timezone, locale) : null],
|
|
41
|
+
[Globe, formatTimezone(timezone, locale)],
|
|
42
|
+
];
|
|
43
|
+
// On top of the steps the summary is one row that wraps; beside them, a
|
|
44
|
+
// column. `stacked` is always the row; `split` is the row until the flow
|
|
45
|
+
// is 48rem wide. (The classes are spelled out: Tailwind finds them by
|
|
46
|
+
// scanning the built file for literal tokens.)
|
|
47
|
+
return (_jsxs("aside", { className: cn("flex flex-wrap items-start gap-x-8 gap-y-3", !stacked && "[@container(min-width:48rem)]:block [@container(min-width:48rem)]:space-y-4"), children: [_jsxs("div", { className: "flex items-start gap-4", children: [image ? _jsx(Image, { src: image, alt: "", className: "h-16 w-16 shrink-0 rounded-xl object-cover" }) : null, _jsxs("div", { className: "space-y-1", children: [_jsx("h2", { className: "font-heading text-xl font-semibold leading-tight tracking-tight", children: service.item_name }), description ? _jsx("p", { className: cn("line-clamp-2 text-sm text-muted-foreground", !stacked && "[@container(min-width:48rem)]:line-clamp-4"), children: description }) : null] })] }), _jsx("ul", { className: cn("flex flex-wrap gap-x-5 gap-y-1.5 text-sm text-muted-foreground", !stacked && "[@container(min-width:48rem)]:block [@container(min-width:48rem)]:space-y-2.5"), children: rows.map(([Icon, text], i) => text ? (_jsxs("li", { className: cn("flex items-center gap-2", i === 3 && "font-medium text-foreground"), children: [_jsx(Icon, { className: "h-4 w-4 shrink-0", "aria-hidden": "true" }), _jsx("span", { children: text })] }, i)) : null) })] }));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Book an appointment in one place: the service's variant, a day and a time,
|
|
51
|
+
* who it is for, then the confirmation (or the payment that confirms it).
|
|
52
|
+
*/
|
|
53
|
+
export function BookingFlow({ serviceSettingId, catalogItemVariantId, timezone: given, address, funding, layout = "split", order = "when-first", image: givenImage, showDetails = true, renderPayment, onBooked, children, locale, labels: givenLabels, className, }) {
|
|
54
|
+
const labels = calendarLabels(givenLabels);
|
|
55
|
+
const { data: service, isLoading, isError } = useBookableService(serviceSettingId);
|
|
56
|
+
const details = useServiceDetails(showDetails);
|
|
57
|
+
const { user } = useAuth();
|
|
58
|
+
const [timezone, setTimezone] = useState(given ?? browserTimezone());
|
|
59
|
+
const [variantId, setVariantId] = useState(catalogItemVariantId ?? null);
|
|
60
|
+
const [slot, setSlot] = useState(null);
|
|
61
|
+
const [attendee, setAttendee] = useState(null);
|
|
62
|
+
// A service at the customer's address needs one: the site's `address` prop,
|
|
63
|
+
// else the one the visitor types with their details.
|
|
64
|
+
const [customerAddress, setCustomerAddress] = useState("");
|
|
65
|
+
const [addressMissing, setAddressMissing] = useState(false);
|
|
66
|
+
const [step, setStep] = useState(order === "who-first" ? "who" : "when");
|
|
67
|
+
const [result, setResult] = useState(null);
|
|
68
|
+
const book = useBook();
|
|
69
|
+
const stacked = layout === "stacked";
|
|
70
|
+
if (isLoading) {
|
|
71
|
+
return (_jsxs("div", { className: cn("space-y-4", className), "aria-busy": "true", children: [_jsx(Skeleton, { className: "h-6 w-1/3" }), _jsx(Skeleton, { className: "h-72 w-full rounded-2xl" })] }));
|
|
72
|
+
}
|
|
73
|
+
if (isError || !service) {
|
|
74
|
+
return (_jsx("p", { role: "alert", className: cn("py-10 text-center text-sm text-destructive", className), children: labels.notAvailable }));
|
|
75
|
+
}
|
|
76
|
+
const variant = service.variants.find((v) => v.id === variantId) ?? defaultVariant(service);
|
|
77
|
+
if (!variant) {
|
|
78
|
+
return (_jsx("p", { role: "alert", className: cn("py-10 text-center text-sm text-destructive", className), children: labels.notAvailable }));
|
|
79
|
+
}
|
|
80
|
+
const item = details.byItem.get(service.catalog_item_id);
|
|
81
|
+
const image = givenImage === undefined ? item?.image_url : givenImage;
|
|
82
|
+
const description = showDetails ? item?.description : null;
|
|
83
|
+
const asksAddress = service.location_type === "customer_address" && !address;
|
|
84
|
+
const bookingAddress = address || (asksAddress ? customerAddress.trim() : "");
|
|
85
|
+
const place = (who, at) => book.mutate({
|
|
86
|
+
service_setting_id: service.id,
|
|
87
|
+
catalog_item_variant_id: variant.id,
|
|
88
|
+
start_at: at.start_at,
|
|
89
|
+
slot_token: at.slot_token,
|
|
90
|
+
attendee: { ...who, timezone },
|
|
91
|
+
...(bookingAddress ? { address: bookingAddress } : {}),
|
|
92
|
+
...(funding ? { funding } : {}),
|
|
93
|
+
}, {
|
|
94
|
+
onSuccess: (booked) => {
|
|
95
|
+
setResult(booked);
|
|
96
|
+
setStep("done");
|
|
97
|
+
onBooked?.(booked);
|
|
98
|
+
},
|
|
99
|
+
onError: (cause) => {
|
|
100
|
+
if (messageOf(cause, labels, givenLabels).slotTaken) {
|
|
101
|
+
setSlot(null);
|
|
102
|
+
setStep("when");
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
const error = book.isError ? messageOf(book.error, labels, givenLabels).text : null;
|
|
107
|
+
let body;
|
|
108
|
+
if (step === "done" && result) {
|
|
109
|
+
const pending = result.registration?.status === "pending_payment";
|
|
110
|
+
body = (_jsxs("div", { className: "space-y-6", children: [pending ? (_jsxs("div", { className: "space-y-4", children: [_jsxs("div", { className: "rounded-xl border border-border bg-muted/40 p-5", children: [_jsx("h3", { className: "font-heading text-lg font-semibold", children: labels.payToConfirmTitle }), _jsx("p", { className: "mt-1 text-sm text-muted-foreground", children: labels.payToConfirmBody(result.registration?.hold_expires_at ? formatDateTime(result.registration.hold_expires_at, timezone, locale) : "") })] }), renderPayment?.(result)] })) : (_jsxs("div", { className: "flex items-start gap-3 rounded-xl bg-success/10 p-5 text-success", children: [_jsx(CheckCircle2, { className: "mt-0.5 h-5 w-5 shrink-0", "aria-hidden": "true" }), _jsxs("div", { children: [_jsx("h3", { className: "font-heading text-lg font-semibold", children: labels.bookedTitle }), _jsx("p", { className: "mt-1 text-sm", children: labels.bookedBody(slot ? formatDateTime(slot.start_at, timezone, locale) : "") })] })] })), children?.(result)] }));
|
|
111
|
+
}
|
|
112
|
+
else if (step === "who") {
|
|
113
|
+
body = (_jsxs("div", { className: "space-y-5", children: [_jsx("h3", { className: "font-heading text-base font-semibold", children: labels.yourDetails }), asksAddress ? (_jsxs("div", { className: "space-y-2", children: [_jsx(Label, { htmlFor: "doany-booking-address", children: labels.yourAddress }), _jsx(Input, { id: "doany-booking-address", autoComplete: "street-address", value: customerAddress, onChange: (e) => {
|
|
114
|
+
setCustomerAddress(e.target.value);
|
|
115
|
+
setAddressMissing(false);
|
|
116
|
+
}, "aria-invalid": addressMissing || undefined, required: true }), addressMissing ? (_jsx("p", { role: "alert", className: "text-sm text-destructive", children: labels.addressRequired })) : null] })) : null, _jsx(AttendeeForm, { fields: service.booking_fields, phoneRequired: service.phone_required, defaultValues: { name: user?.full_name ?? "", email: user?.email ?? "", ...(attendee ?? {}) }, onSubmit: (who) => {
|
|
117
|
+
if (asksAddress && !customerAddress.trim()) {
|
|
118
|
+
setAddressMissing(true);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
setAttendee(who);
|
|
122
|
+
if (order === "who-first")
|
|
123
|
+
setStep("when");
|
|
124
|
+
else if (slot)
|
|
125
|
+
place(who, slot);
|
|
126
|
+
}, busy: book.isPending, error: error, submitLabel: order === "who-first" ? labels.selectTime : undefined, labels: givenLabels, secondaryAction: order === "when-first" ? (_jsx(Button, { type: "button", variant: "outline", className: "h-10 rounded-lg px-5", onClick: () => setStep("when"), disabled: book.isPending, children: labels.back })) : null })] }));
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
body = (_jsxs("div", { className: "space-y-5", children: [_jsx(VariantChoice, { service: service, value: variant, onChange: (v) => { setVariantId(v.id); setSlot(null); }, locale: locale, labels: labels }), error && (_jsx("p", { role: "alert", className: "text-sm text-destructive", children: error })), _jsx(DateTimePicker, { service: service, variant: variant, timezone: timezone, onTimezoneChange: setTimezone, value: slot, onChange: setSlot, onConfirm: (at) => {
|
|
130
|
+
setSlot(at);
|
|
131
|
+
if (order === "who-first" && attendee)
|
|
132
|
+
place(attendee, at);
|
|
133
|
+
else
|
|
134
|
+
setStep("who");
|
|
135
|
+
}, confirmLabel: order === "who-first" ? labels.confirmBooking : undefined, layout: stacked ? "narrow" : "wide", locale: locale, labels: givenLabels }), order === "who-first" && book.isPending ? _jsx("p", { className: "text-sm text-muted-foreground", children: labels.booking }) : null] }));
|
|
136
|
+
}
|
|
137
|
+
return (_jsx("section", { className: cn("overflow-hidden rounded-2xl border border-border bg-card text-card-foreground shadow-card [container-type:inline-size]", className), children: _jsxs("div", { className: cn(!stacked && "grid [@container(min-width:48rem)]:grid-cols-[minmax(200px,250px)_minmax(0,1fr)]"), children: [_jsx("div", { className: cn("border-b border-border p-6", !stacked && "[@container(min-width:48rem)]:border-b-0 [@container(min-width:48rem)]:border-r"), children: _jsx(Summary, { service: service, variant: variant, slot: slot, timezone: timezone, image: image, description: description, locale: locale, labels: labels, stacked: stacked }) }), _jsx("div", { className: "p-6", children: body })] }) }));
|
|
138
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { type CalendarLabels } from "../labels.js";
|
|
3
|
+
import type { Booking, Registration, RegistrationStatus } from "../types.js";
|
|
4
|
+
export interface BookingStatusProps {
|
|
5
|
+
/** The registration, from the booking link `/bookings/<id>` or a `BookingFlow` result. */
|
|
6
|
+
registrationId: string;
|
|
7
|
+
/** Its access token. Default: the one in the link's `#t=`, else this browser session's. */
|
|
8
|
+
accessToken?: string;
|
|
9
|
+
/** The timezone to show times in. Default: the visitor's. */
|
|
10
|
+
timezone?: string;
|
|
11
|
+
/** Let the visitor cancel, within the service's policy. Default `true`. */
|
|
12
|
+
allowCancel?: boolean;
|
|
13
|
+
/** Under everything: links onward. Receives the status. */
|
|
14
|
+
children?: (status: RegistrationStatus | undefined, detail: {
|
|
15
|
+
registration: Registration;
|
|
16
|
+
booking: Booking;
|
|
17
|
+
} | undefined) => ReactNode;
|
|
18
|
+
locale?: string;
|
|
19
|
+
labels?: Partial<CalendarLabels>;
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
/** The page a booking's link opens: what was booked, when, where, and the way to cancel it. */
|
|
23
|
+
export declare function BookingStatus({ registrationId, accessToken, timezone: given, allowCancel, children, locale, labels: givenLabels, className }: BookingStatusProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from "@doany-ai/app-core";
|
|
3
|
+
import { Button } from "@doany-ai/app-core/ui/button";
|
|
4
|
+
import { Skeleton } from "@doany-ai/app-core/ui/skeleton";
|
|
5
|
+
import { CalendarCheck, Clock, Loader2, MapPin, XCircle } from "lucide-react";
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
import { useBookingAccessToken } from "../booking-link.js";
|
|
8
|
+
import { browserTimezone, formatDateTime } from "../format.js";
|
|
9
|
+
import { operationSettled, useCalendarOperation, useCancelRegistration, useRegistration } from "../hooks.js";
|
|
10
|
+
import { calendarLabels } from "../labels.js";
|
|
11
|
+
const statusLabel = (status, labels) => status === "confirmed" ? labels.statusConfirmed : status === "pending_payment" ? labels.statusPendingPayment : status === "canceled" ? labels.statusCanceled : labels.statusExpired;
|
|
12
|
+
function where(booking, labels) {
|
|
13
|
+
if (booking.location_type === "online")
|
|
14
|
+
return labels.online;
|
|
15
|
+
if (booking.location_type === "customer_address")
|
|
16
|
+
return labels.atYourAddress;
|
|
17
|
+
if (booking.location_type === "business_address")
|
|
18
|
+
return booking.address ?? null;
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
/** The page a booking's link opens: what was booked, when, where, and the way to cancel it. */
|
|
22
|
+
export function BookingStatus({ registrationId, accessToken, timezone: given, allowCancel = true, children, locale, labels: givenLabels, className }) {
|
|
23
|
+
const labels = calendarLabels(givenLabels);
|
|
24
|
+
const timezone = given ?? browserTimezone();
|
|
25
|
+
// The token the page opened with: the given one, the link's `#t=`, or this
|
|
26
|
+
// browser session's. The detail, the cancel and its operation all use it.
|
|
27
|
+
const token = useBookingAccessToken(registrationId, accessToken);
|
|
28
|
+
const { data, isLoading, isError, error, refetch } = useRegistration(registrationId, { accessToken: token });
|
|
29
|
+
const cancel = useCancelRegistration();
|
|
30
|
+
const operationId = cancel.data && "operation_id" in cancel.data ? cancel.data.operation_id : null;
|
|
31
|
+
const operation = useCalendarOperation(operationId, { registrationId, accessToken: token });
|
|
32
|
+
const [confirming, setConfirming] = useState(false);
|
|
33
|
+
if (isLoading) {
|
|
34
|
+
return (_jsxs("div", { className: cn("space-y-3", className), "aria-busy": "true", children: [_jsx(Skeleton, { className: "h-6 w-1/3" }), _jsx(Skeleton, { className: "h-24 w-full rounded-2xl" })] }));
|
|
35
|
+
}
|
|
36
|
+
if (isError || !data) {
|
|
37
|
+
const missing = error?.status === 404;
|
|
38
|
+
return (_jsxs("div", { role: "alert", className: cn("space-y-3 py-10 text-center text-sm", className), children: [_jsx("p", { className: "text-destructive", children: missing ? labels.bookingNotFound : labels.bookingCheckFailed }), !missing && (_jsx(Button, { variant: "outline", size: "sm", onClick: () => void refetch(), children: labels.tryAgain }))] }));
|
|
39
|
+
}
|
|
40
|
+
const { registration, booking } = data;
|
|
41
|
+
const status = registration.status;
|
|
42
|
+
const live = status === "confirmed" || status === "pending_payment";
|
|
43
|
+
const canCancel = allowCancel && live && registration.allowed_actions.includes("cancel");
|
|
44
|
+
const Icon = status === "confirmed" ? CalendarCheck : status === "pending_payment" ? Clock : XCircle;
|
|
45
|
+
const tone = status === "confirmed" ? "bg-success/10 text-success" : status === "pending_payment" ? "bg-muted text-muted-foreground" : "bg-destructive/10 text-destructive";
|
|
46
|
+
const place = where(booking, labels);
|
|
47
|
+
const meetingUrl = booking.location_type === "online" ? (booking.meeting?.meeting_url ?? null) : null;
|
|
48
|
+
// A cancel that waits on a payment: processing until its operation settles.
|
|
49
|
+
const settled = operationSettled(operation.data);
|
|
50
|
+
const processing = Boolean(operationId) && !settled;
|
|
51
|
+
const operationFailed = settled && operation.data?.status !== "succeeded";
|
|
52
|
+
return (_jsxs("section", { className: cn("space-y-5", className), children: [_jsxs("div", { className: cn("flex items-center gap-3 rounded-2xl p-4", tone), children: [_jsx(Icon, { className: "h-5 w-5 shrink-0", "aria-hidden": "true" }), _jsx("p", { className: "font-semibold", children: statusLabel(status, labels) })] }), _jsxs("dl", { className: "space-y-3 rounded-2xl border border-border bg-card p-5 text-sm", children: [_jsxs("div", { children: [_jsx("dt", { className: "text-muted-foreground", children: booking.item_name }), _jsx("dd", { className: "font-heading text-lg font-semibold", children: formatDateTime(booking.start_at, timezone, locale) })] }), _jsx("div", { className: "text-muted-foreground", children: booking.variant_name }), place && (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(MapPin, { className: "h-4 w-4 shrink-0 text-muted-foreground", "aria-hidden": "true" }), _jsx("span", { children: place })] })), meetingUrl && live && (_jsx("div", { children: _jsx("a", { href: meetingUrl, target: "_blank", rel: "noreferrer", className: "font-medium underline underline-offset-4", children: labels.joinMeeting }) }))] }), canCancel && (_jsxs("div", { className: "space-y-3", children: [processing ? (_jsxs("p", { className: "flex items-center gap-2 text-sm text-muted-foreground", children: [_jsx(Loader2, { className: "h-4 w-4 animate-spin", "aria-hidden": "true" }), labels.cancelProcessing] })) : confirming ? (_jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [_jsx("span", { className: "text-sm", children: labels.cancelConfirm }), _jsx(Button, { variant: "destructive", size: "sm", className: "rounded-full", disabled: cancel.isPending, onClick: () => cancel.mutate({ registrationId, accessToken: token, registration_version: registration.version, booking_version: booking.version }, { onSettled: () => setConfirming(false) }), children: cancel.isPending ? labels.canceling : labels.cancelBooking }), _jsx(Button, { variant: "outline", size: "sm", className: "rounded-full", onClick: () => setConfirming(false), disabled: cancel.isPending, children: labels.keepBooking })] })) : (_jsx(Button, { variant: "outline", size: "sm", className: "rounded-full", onClick: () => setConfirming(true), children: labels.cancelBooking })), (cancel.isError || operationFailed || operation.isError) && (_jsx("p", { role: "alert", className: "text-sm text-destructive", children: givenLabels?.cancelFailed ?? (cancel.error?.message || labels.cancelFailed) }))] })), children?.(status, data)] }));
|
|
53
|
+
}
|