@doany-ai/calendar 0.1.0-alpha.0 → 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 +1 -1
- package/dist/components/attendee-form.js +8 -2
- package/dist/components/booking-flow.d.ts +4 -2
- package/dist/components/booking-flow.js +21 -4
- package/dist/components/booking-status.js +14 -4
- package/dist/components/package-list.js +4 -3
- package/dist/components/service-list.js +6 -5
- package/dist/components/session-calendar.d.ts +4 -2
- package/dist/components/session-calendar.js +3 -3
- package/dist/components/session-list.d.ts +8 -4
- package/dist/components/session-list.js +16 -13
- package/dist/hooks.d.ts +11 -1
- package/dist/hooks.js +25 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/labels.d.ts +5 -0
- package/dist/labels.js +4 -0
- package/dist/phone.d.ts +4 -0
- package/dist/phone.js +7 -0
- package/dist/types.d.ts +35 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -108,7 +108,7 @@ A month of class sessions: the days with sessions are marked on the calendar; th
|
|
|
108
108
|
|
|
109
109
|
### `PackageList`, `PackageCard`
|
|
110
110
|
|
|
111
|
-
The packages of uses a visitor can buy (`doany.catalog.list({ type: "
|
|
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
112
|
|
|
113
113
|
```jsx
|
|
114
114
|
<PackageList
|
|
@@ -8,6 +8,7 @@ import { Textarea } from "@doany-ai/app-core/ui/textarea";
|
|
|
8
8
|
import { Loader2 } from "lucide-react";
|
|
9
9
|
import { useState } from "react";
|
|
10
10
|
import { calendarLabels } from "../labels.js";
|
|
11
|
+
import { isE164, normalizePhone } from "../phone.js";
|
|
11
12
|
function Question({ field, value, onChange }) {
|
|
12
13
|
const id = `doany-booking-${field.key}`;
|
|
13
14
|
if (field.type === "checkbox") {
|
|
@@ -41,9 +42,14 @@ export function AttendeeForm({ fields = [], phoneRequired = false, defaultValues
|
|
|
41
42
|
onSubmit({
|
|
42
43
|
name: name.trim(),
|
|
43
44
|
...(email.trim() ? { email: email.trim() } : {}),
|
|
44
|
-
...(phone.trim() ? { phone: phone
|
|
45
|
+
...(phone.trim() ? { phone: normalizePhone(phone) } : {}),
|
|
45
46
|
answers,
|
|
46
47
|
});
|
|
47
48
|
};
|
|
48
|
-
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) =>
|
|
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)] })] })] }));
|
|
49
55
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
2
|
import { type CalendarLabels } from "../labels.js";
|
|
3
|
-
import type { BookingResult } from "../types.js";
|
|
3
|
+
import type { BookingFunding, BookingResult } from "../types.js";
|
|
4
4
|
export interface BookingFlowProps {
|
|
5
5
|
/** The service's booking settings id (`BookableService.id`). */
|
|
6
6
|
serviceSettingId: string;
|
|
@@ -10,6 +10,8 @@ export interface BookingFlowProps {
|
|
|
10
10
|
timezone?: string;
|
|
11
11
|
/** For a service at the customer's address: where. */
|
|
12
12
|
address?: string;
|
|
13
|
+
/** Pay with a package the customer holds (its entitlement) instead of the service's default. */
|
|
14
|
+
funding?: BookingFunding;
|
|
13
15
|
/**
|
|
14
16
|
* `split` (default): the service's summary in a column beside the steps
|
|
15
17
|
* when the flow is at least 48rem wide, on top of them otherwise (its own
|
|
@@ -43,4 +45,4 @@ export interface BookingFlowProps {
|
|
|
43
45
|
* Book an appointment in one place: the service's variant, a day and a time,
|
|
44
46
|
* who it is for, then the confirmation (or the payment that confirms it).
|
|
45
47
|
*/
|
|
46
|
-
export declare function BookingFlow({ serviceSettingId, catalogItemVariantId, timezone: given, address, layout, order, image: givenImage, showDetails, renderPayment, onBooked, children, locale, labels: givenLabels, className, }: BookingFlowProps): import("react").JSX.Element;
|
|
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;
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Image } from "@doany-ai/app-core/media";
|
|
2
3
|
import { cn } from "@doany-ai/app-core";
|
|
3
4
|
import { useAuth } from "@doany-ai/app-core/auth";
|
|
4
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";
|
|
5
8
|
import { Skeleton } from "@doany-ai/app-core/ui/skeleton";
|
|
6
9
|
import { CalendarCheck, CheckCircle2, Clock, Globe, MapPin, Tag, Users } from "lucide-react";
|
|
7
10
|
import { useState } from "react";
|
|
@@ -41,13 +44,13 @@ function Summary({ service, variant, slot, timezone, image, description, locale,
|
|
|
41
44
|
// column. `stacked` is always the row; `split` is the row until the flow
|
|
42
45
|
// is 48rem wide. (The classes are spelled out: Tailwind finds them by
|
|
43
46
|
// scanning the built file for literal tokens.)
|
|
44
|
-
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(
|
|
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) })] }));
|
|
45
48
|
}
|
|
46
49
|
/**
|
|
47
50
|
* Book an appointment in one place: the service's variant, a day and a time,
|
|
48
51
|
* who it is for, then the confirmation (or the payment that confirms it).
|
|
49
52
|
*/
|
|
50
|
-
export function BookingFlow({ serviceSettingId, catalogItemVariantId, timezone: given, address, layout = "split", order = "when-first", image: givenImage, showDetails = true, renderPayment, onBooked, children, locale, labels: givenLabels, className, }) {
|
|
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, }) {
|
|
51
54
|
const labels = calendarLabels(givenLabels);
|
|
52
55
|
const { data: service, isLoading, isError } = useBookableService(serviceSettingId);
|
|
53
56
|
const details = useServiceDetails(showDetails);
|
|
@@ -56,6 +59,10 @@ export function BookingFlow({ serviceSettingId, catalogItemVariantId, timezone:
|
|
|
56
59
|
const [variantId, setVariantId] = useState(catalogItemVariantId ?? null);
|
|
57
60
|
const [slot, setSlot] = useState(null);
|
|
58
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);
|
|
59
66
|
const [step, setStep] = useState(order === "who-first" ? "who" : "when");
|
|
60
67
|
const [result, setResult] = useState(null);
|
|
61
68
|
const book = useBook();
|
|
@@ -73,13 +80,16 @@ export function BookingFlow({ serviceSettingId, catalogItemVariantId, timezone:
|
|
|
73
80
|
const item = details.byItem.get(service.catalog_item_id);
|
|
74
81
|
const image = givenImage === undefined ? item?.image_url : givenImage;
|
|
75
82
|
const description = showDetails ? item?.description : null;
|
|
83
|
+
const asksAddress = service.location_type === "customer_address" && !address;
|
|
84
|
+
const bookingAddress = address || (asksAddress ? customerAddress.trim() : "");
|
|
76
85
|
const place = (who, at) => book.mutate({
|
|
77
86
|
service_setting_id: service.id,
|
|
78
87
|
catalog_item_variant_id: variant.id,
|
|
79
88
|
start_at: at.start_at,
|
|
80
89
|
slot_token: at.slot_token,
|
|
81
90
|
attendee: { ...who, timezone },
|
|
82
|
-
...(
|
|
91
|
+
...(bookingAddress ? { address: bookingAddress } : {}),
|
|
92
|
+
...(funding ? { funding } : {}),
|
|
83
93
|
}, {
|
|
84
94
|
onSuccess: (booked) => {
|
|
85
95
|
setResult(booked);
|
|
@@ -100,7 +110,14 @@ export function BookingFlow({ serviceSettingId, catalogItemVariantId, timezone:
|
|
|
100
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)] }));
|
|
101
111
|
}
|
|
102
112
|
else if (step === "who") {
|
|
103
|
-
body = (_jsxs("div", { className: "space-y-5", children: [_jsx("h3", { className: "font-heading text-base font-semibold", children: labels.yourDetails }),
|
|
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
|
+
}
|
|
104
121
|
setAttendee(who);
|
|
105
122
|
if (order === "who-first")
|
|
106
123
|
setStep("when");
|
|
@@ -4,8 +4,9 @@ import { Button } from "@doany-ai/app-core/ui/button";
|
|
|
4
4
|
import { Skeleton } from "@doany-ai/app-core/ui/skeleton";
|
|
5
5
|
import { CalendarCheck, Clock, Loader2, MapPin, XCircle } from "lucide-react";
|
|
6
6
|
import { useState } from "react";
|
|
7
|
+
import { useBookingAccessToken } from "../booking-link.js";
|
|
7
8
|
import { browserTimezone, formatDateTime } from "../format.js";
|
|
8
|
-
import { useCancelRegistration, useRegistration } from "../hooks.js";
|
|
9
|
+
import { operationSettled, useCalendarOperation, useCancelRegistration, useRegistration } from "../hooks.js";
|
|
9
10
|
import { calendarLabels } from "../labels.js";
|
|
10
11
|
const statusLabel = (status, labels) => status === "confirmed" ? labels.statusConfirmed : status === "pending_payment" ? labels.statusPendingPayment : status === "canceled" ? labels.statusCanceled : labels.statusExpired;
|
|
11
12
|
function where(booking, labels) {
|
|
@@ -21,8 +22,13 @@ function where(booking, labels) {
|
|
|
21
22
|
export function BookingStatus({ registrationId, accessToken, timezone: given, allowCancel = true, children, locale, labels: givenLabels, className }) {
|
|
22
23
|
const labels = calendarLabels(givenLabels);
|
|
23
24
|
const timezone = given ?? browserTimezone();
|
|
24
|
-
|
|
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 });
|
|
25
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 });
|
|
26
32
|
const [confirming, setConfirming] = useState(false);
|
|
27
33
|
if (isLoading) {
|
|
28
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" })] }));
|
|
@@ -38,6 +44,10 @@ export function BookingStatus({ registrationId, accessToken, timezone: given, al
|
|
|
38
44
|
const Icon = status === "confirmed" ? CalendarCheck : status === "pending_payment" ? Clock : XCircle;
|
|
39
45
|
const tone = status === "confirmed" ? "bg-success/10 text-success" : status === "pending_payment" ? "bg-muted text-muted-foreground" : "bg-destructive/10 text-destructive";
|
|
40
46
|
const place = where(booking, labels);
|
|
41
|
-
const
|
|
42
|
-
|
|
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)] }));
|
|
43
53
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Image } from "@doany-ai/app-core/media";
|
|
2
3
|
import { cn } from "@doany-ai/app-core";
|
|
3
4
|
import { Button } from "@doany-ai/app-core/ui/button";
|
|
4
5
|
import { Skeleton } from "@doany-ai/app-core/ui/skeleton";
|
|
@@ -14,7 +15,7 @@ export function PackageCard({ item, renderBuy, onBuy, footer, highlighted = fals
|
|
|
14
15
|
const labels = calendarLabels(given);
|
|
15
16
|
const variant = packageVariant(item);
|
|
16
17
|
const price = variant ? variantPriceLabel(variant, locale, { free: labels.free }) : null;
|
|
17
|
-
return (_jsxs("article", { className: cn("flex flex-col gap-4 rounded-2xl border bg-card p-6 text-card-foreground shadow-soft transition-shadow hover:shadow-card", highlighted ? "border-primary ring-1 ring-primary" : "border-border", className), children: [item.image_url ? _jsx(
|
|
18
|
+
return (_jsxs("article", { className: cn("flex flex-col gap-4 rounded-2xl border bg-card p-6 text-card-foreground shadow-soft transition-shadow hover:shadow-card", highlighted ? "border-primary ring-1 ring-primary" : "border-border", className), children: [item.image_url ? _jsx(Image, { src: item.image_url, alt: "", className: "aspect-[3/2] w-full rounded-xl object-cover" }) : null, _jsxs("div", { className: "space-y-1", children: [_jsx("h3", { className: "font-heading text-lg font-semibold leading-tight", children: item.name }), variant && variant.name !== item.name ? _jsx("p", { className: "text-sm text-muted-foreground", children: variant.name }) : null] }), price ? _jsx("p", { className: "font-heading text-3xl font-semibold tracking-tight", children: price }) : null, item.description ? _jsx("p", { className: "text-sm text-muted-foreground", children: item.description }) : null, footer, _jsx("div", { className: "mt-auto pt-2", children: variant
|
|
18
19
|
? (renderBuy?.(item, variant) ?? (_jsx(Button, { className: "h-11 w-full rounded-lg font-semibold", onClick: () => onBuy?.(item, variant), children: labels.buy })))
|
|
19
20
|
: null })] }));
|
|
20
21
|
}
|
|
@@ -23,7 +24,7 @@ export function PackageList({ category, limit, highlight, footerFor, renderItem,
|
|
|
23
24
|
const labels = calendarLabels(card.labels);
|
|
24
25
|
const { packages, isLoading, isError, error, refetch } = usePackages({ category, limit });
|
|
25
26
|
if (isLoading) {
|
|
26
|
-
return (_jsx("div", { className: cn("grid gap-5
|
|
27
|
+
return (_jsx("div", { className: cn("grid gap-5 grid-cols-[repeat(auto-fill,minmax(15rem,1fr))]", className), "aria-busy": "true", children: [0, 1, 2].map((i) => (_jsx(Skeleton, { className: "h-64 rounded-2xl" }, i))) }));
|
|
27
28
|
}
|
|
28
29
|
if (isError) {
|
|
29
30
|
return (_jsxs("div", { role: "alert", className: "space-y-3 py-12 text-center text-sm text-destructive", children: [_jsx("p", { children: card.labels?.loadError ?? (error?.message || labels.loadError) }), _jsx(Button, { variant: "outline", size: "sm", onClick: () => refetch(), children: labels.tryAgain })] }));
|
|
@@ -31,5 +32,5 @@ export function PackageList({ category, limit, highlight, footerFor, renderItem,
|
|
|
31
32
|
if (!packages.length) {
|
|
32
33
|
return _jsx(_Fragment, { children: empty ?? _jsx("p", { className: "rounded-2xl border border-dashed border-border py-14 text-center text-sm text-muted-foreground", children: labels.noPackages }) });
|
|
33
34
|
}
|
|
34
|
-
return (_jsx("div", { className: cn("grid gap-5
|
|
35
|
+
return (_jsx("div", { className: cn("grid gap-5 grid-cols-[repeat(auto-fill,minmax(15rem,1fr))]", className), children: packages.map((item) => (renderItem ? renderItem(item) : _jsx(PackageCard, { item: item, highlighted: highlight?.(item), footer: footerFor?.(item), ...card }, item.id))) }));
|
|
35
36
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Image } from "@doany-ai/app-core/media";
|
|
2
3
|
import { cn } from "@doany-ai/app-core";
|
|
3
4
|
import { Button } from "@doany-ai/app-core/ui/button";
|
|
4
5
|
import { Skeleton } from "@doany-ai/app-core/ui/skeleton";
|
|
@@ -19,9 +20,9 @@ export function ServiceCard({ service, details, href, onSelect, renderLink, foot
|
|
|
19
20
|
const action = href ? (renderLink ? (renderLink({ href, className: "inline-flex", children: button })) : (_jsx("a", { href: href, className: "inline-flex", children: button }))) : (_jsx(Button, { variant: "secondary", className: "rounded-lg bg-primary/10 px-5 font-semibold text-primary hover:bg-primary/15", onClick: () => onSelect?.(service), children: cta }));
|
|
20
21
|
const image = details?.image_url;
|
|
21
22
|
if (shape === "row") {
|
|
22
|
-
return (_jsxs("article", { className: cn("flex gap-4 rounded-2xl border border-border bg-card p-4 text-card-foreground shadow-soft transition-shadow hover:shadow-card", className), children: [image ? _jsx(
|
|
23
|
+
return (_jsxs("article", { className: cn("flex gap-4 rounded-2xl border border-border bg-card p-4 text-card-foreground shadow-soft transition-shadow hover:shadow-card", className), children: [image ? _jsx(Image, { src: image, alt: "", className: "h-24 w-24 shrink-0 rounded-xl object-cover sm:h-28 sm:w-28" }) : null, _jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-2", children: [_jsx("h3", { className: "font-heading text-lg font-semibold leading-tight", children: service.item_name }), details?.description ? _jsx("p", { className: "line-clamp-2 text-sm text-muted-foreground", children: details.description }) : null, footer, _jsxs("div", { className: "mt-auto flex flex-wrap items-center justify-between gap-3 pt-1", children: [_jsx(Meta, { service: service, locale: locale, labels: labels }), action] })] })] }));
|
|
23
24
|
}
|
|
24
|
-
return (_jsxs("article", { className: cn("flex flex-col overflow-hidden rounded-2xl border border-border bg-card text-card-foreground shadow-soft transition-shadow hover:shadow-card", className), children: [image ? (_jsx("div", { className: "aspect-[4/3] overflow-hidden bg-muted", children: _jsx(
|
|
25
|
+
return (_jsxs("article", { className: cn("flex flex-col overflow-hidden rounded-2xl border border-border bg-card text-card-foreground shadow-soft transition-shadow hover:shadow-card", className), children: [image ? (_jsx("div", { className: "aspect-[4/3] overflow-hidden bg-muted", children: _jsx(Image, { src: image, alt: "", className: "h-full w-full object-cover" }) })) : null, _jsxs("div", { className: "flex flex-1 flex-col gap-3 p-5", children: [_jsx("h3", { className: "font-heading text-lg font-semibold leading-tight", children: service.item_name }), details?.description ? _jsx("p", { className: "line-clamp-3 text-sm text-muted-foreground", children: details.description }) : null, footer, _jsxs("div", { className: "mt-auto flex flex-wrap items-center justify-between gap-3 pt-2", children: [_jsx(Meta, { service: service, locale: locale, labels: labels }), action] })] })] }));
|
|
25
26
|
}
|
|
26
27
|
const UNCATEGORIZED = "";
|
|
27
28
|
/** Every service a visitor can book, as cards, with a Load more when there are more. */
|
|
@@ -46,7 +47,7 @@ export function ServiceList({ params, hrefFor, renderItem, empty, variant = "gri
|
|
|
46
47
|
return [...map.entries()].sort(([a], [b]) => (a === UNCATEGORIZED ? 1 : b === UNCATEGORIZED ? -1 : a.localeCompare(b)));
|
|
47
48
|
}, [variant, services, details.byItem, query]);
|
|
48
49
|
if (isLoading) {
|
|
49
|
-
return (_jsx("div", { className: cn(variant === "menu" ? "space-y-3" : "grid gap-5
|
|
50
|
+
return (_jsx("div", { className: cn(variant === "menu" ? "space-y-3" : "grid gap-5 grid-cols-[repeat(auto-fill,minmax(15rem,1fr))]", className), "aria-busy": "true", children: [0, 1, 2].map((i) => (_jsx(Skeleton, { className: variant === "menu" ? "h-32 rounded-2xl" : "h-48 rounded-2xl" }, i))) }));
|
|
50
51
|
}
|
|
51
52
|
if (isError) {
|
|
52
53
|
return (_jsxs("div", { role: "alert", className: "space-y-3 py-12 text-center text-sm text-destructive", children: [_jsx("p", { children: card.labels?.loadError ?? (error?.message || labels.loadError) }), _jsx(Button, { variant: "outline", size: "sm", onClick: () => refetch(), children: labels.tryAgain })] }));
|
|
@@ -62,7 +63,7 @@ export function ServiceList({ params, hrefFor, renderItem, empty, variant = "gri
|
|
|
62
63
|
return (_jsxs("div", { className: cn("space-y-6", className), children: [_jsxs("div", { className: "space-y-4", children: [_jsxs("label", { className: "relative block max-w-md", children: [_jsx(Search, { className: "pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground", "aria-hidden": "true" }), _jsx("input", { type: "search", value: query, onChange: (e) => setQuery(e.target.value), placeholder: labels.searchServices, "aria-label": labels.searchServices, className: "h-11 w-full rounded-xl border border-input bg-background pl-10 pr-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring" })] }), categories.length > 1 && (_jsx("div", { role: "tablist", className: "-mb-px flex gap-6 overflow-x-auto border-b border-border", children: [null, ...categories].map((name) => {
|
|
63
64
|
const on = name === category;
|
|
64
65
|
return (_jsx("button", { type: "button", role: "tab", "aria-selected": on, onClick: () => setCategory(name), className: cn("whitespace-nowrap border-b-2 px-1 pb-3 text-sm font-medium transition-colors", on ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"), children: name === null ? labels.allServices : name || labels.options }, name ?? "*"));
|
|
65
|
-
}) }))] }), !shown.length ? (_jsx("p", { className: "rounded-2xl border border-dashed border-border py-14 text-center text-sm text-muted-foreground", children: labels.noMatches })) : (shown.map(([name, list]) => (_jsxs("section", { className: "space-y-3", children: [name ? _jsx("h3", { className: "text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground", children: name }) : null, _jsx("div", { className: "grid gap-4
|
|
66
|
+
}) }))] }), !shown.length ? (_jsx("p", { className: "rounded-2xl border border-dashed border-border py-14 text-center text-sm text-muted-foreground", children: labels.noMatches })) : (shown.map(([name, list]) => (_jsxs("section", { className: "space-y-3", children: [name ? _jsx("h3", { className: "text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground", children: name }) : null, _jsx("div", { className: "grid gap-4 grid-cols-[repeat(auto-fill,minmax(22rem,1fr))]", children: list.map((service) => cardOf(service, "row")) })] }, name || "*")))), more] }));
|
|
66
67
|
}
|
|
67
|
-
return (_jsxs("div", { className: cn("space-y-6", className), children: [_jsx("div", { className: "grid gap-5
|
|
68
|
+
return (_jsxs("div", { className: cn("space-y-6", className), children: [_jsx("div", { className: "grid gap-5 grid-cols-[repeat(auto-fill,minmax(15rem,1fr))]", children: services.map((service) => cardOf(service, "tile")) }), more] }));
|
|
68
69
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
2
|
import { type CalendarLabels } from "../labels.js";
|
|
3
|
-
import type { BookingResult } from "../types.js";
|
|
3
|
+
import type { BookingResult, BookingFunding } from "../types.js";
|
|
4
4
|
export interface SessionCalendarProps {
|
|
5
5
|
/** Only this class's sessions. */
|
|
6
6
|
serviceSettingId?: string;
|
|
@@ -8,6 +8,8 @@ export interface SessionCalendarProps {
|
|
|
8
8
|
timezone?: string;
|
|
9
9
|
/** A session paid online: the payment, given the registration. */
|
|
10
10
|
renderPayment?: (result: BookingResult) => ReactNode;
|
|
11
|
+
/** Pay with a package the customer holds (its entitlement) instead of the ticket's default. */
|
|
12
|
+
funding?: BookingFunding;
|
|
11
13
|
onRegistered?: (result: BookingResult) => void;
|
|
12
14
|
locale?: string;
|
|
13
15
|
labels?: Partial<CalendarLabels>;
|
|
@@ -18,4 +20,4 @@ export interface SessionCalendarProps {
|
|
|
18
20
|
* calendar; the chosen day's sessions, with their seats, are listed beside
|
|
19
21
|
* it, each with Register.
|
|
20
22
|
*/
|
|
21
|
-
export declare function SessionCalendar({ serviceSettingId, timezone: given, renderPayment, onRegistered, locale, labels: givenLabels, className }: SessionCalendarProps): import("react").JSX.Element;
|
|
23
|
+
export declare function SessionCalendar({ serviceSettingId, timezone: given, renderPayment, onRegistered, funding, locale, labels: givenLabels, className }: SessionCalendarProps): import("react").JSX.Element;
|
|
@@ -17,7 +17,7 @@ import { RegisterDialog, sessionIsFull } from "./session-list.js";
|
|
|
17
17
|
* calendar; the chosen day's sessions, with their seats, are listed beside
|
|
18
18
|
* it, each with Register.
|
|
19
19
|
*/
|
|
20
|
-
export function SessionCalendar({ serviceSettingId, timezone: given, renderPayment, onRegistered, locale, labels: givenLabels, className }) {
|
|
20
|
+
export function SessionCalendar({ serviceSettingId, timezone: given, renderPayment, onRegistered, funding, locale, labels: givenLabels, className }) {
|
|
21
21
|
const labels = calendarLabels(givenLabels);
|
|
22
22
|
const timezone = given ?? browserTimezone();
|
|
23
23
|
const today = useMemo(() => new Date(), []);
|
|
@@ -46,7 +46,7 @@ export function SessionCalendar({ serviceSettingId, timezone: given, renderPayme
|
|
|
46
46
|
const chosen = day ? byDay.get(dayKey(day)) ?? [] : null;
|
|
47
47
|
return (_jsxs("div", { className: cn("grid gap-6 rounded-2xl border border-border bg-card p-6 text-card-foreground shadow-card lg:grid-cols-[auto_minmax(0,1fr)]", className), children: [_jsxs("div", { className: "space-y-3", children: [_jsx(Calendar, { mode: "single", month: month, onMonthChange: setMonth, selected: day, onSelect: (picked) => setDay(picked ?? undefined), disabled: { before: today }, modifiers: { hasSessions: marked }, modifiersClassNames: { hasSessions: "after:absolute after:bottom-1 after:left-1/2 after:h-1 after:w-1 after:-translate-x-1/2 after:rounded-full after:bg-primary aria-selected:after:bg-primary-foreground" }, className: "w-fit p-0", classNames: { ...calendarClassNames, day: `${calendarClassNames.day} relative` } }), isLoading ? _jsx(Skeleton, { className: "h-4 w-32" }) : null] }), _jsx("div", { className: "space-y-3 lg:min-h-72", children: isError ? (_jsxs("div", { role: "alert", className: "space-y-3 py-10 text-center text-sm text-destructive", children: [_jsx("p", { children: givenLabels?.loadError ?? (error?.message || labels.loadError) }), _jsx(Button, { variant: "outline", size: "sm", onClick: () => refetch(), children: labels.tryAgain })] })) : !chosen ? (_jsx("p", { className: "rounded-xl border border-dashed border-border py-10 text-center text-sm text-muted-foreground", children: labels.pickDay })) : (_jsxs(_Fragment, { children: [_jsx("h3", { className: "font-heading text-base font-semibold", children: formatDay(new Date(day.getFullYear(), day.getMonth(), day.getDate(), 12).toISOString(), "UTC", locale) }), !chosen.length ? (_jsx("p", { className: "rounded-xl border border-dashed border-border py-10 text-center text-sm text-muted-foreground", children: labels.noSessionsOnDay })) : (_jsx("ul", { className: "space-y-2", children: chosen.map((session) => {
|
|
48
48
|
const seats = session.remaining_seats ?? 0;
|
|
49
|
-
const full = sessionIsFull(session);
|
|
49
|
+
const full = sessionIsFull(session, funding);
|
|
50
50
|
return (_jsxs("li", { className: "flex flex-wrap items-center gap-x-4 gap-y-2 rounded-xl border border-border px-4 py-3", children: [_jsx("span", { className: "w-20 font-semibold tabular-nums", children: formatTime(session.start_at, timezone, locale) }), _jsxs("span", { className: "min-w-32 flex-1", children: [_jsx("span", { className: "block font-medium", children: session.item_name }), _jsx("span", { className: "block text-sm text-muted-foreground", children: session.variant_name })] }), _jsxs("span", { className: cn("inline-flex items-center gap-1.5 text-sm", full ? "text-muted-foreground" : "text-foreground"), children: [_jsx(Users, { className: "h-4 w-4", "aria-hidden": "true" }), full ? labels.full : labels.seatsLeft(seats)] }), _jsx(Button, { className: "rounded-lg", size: "sm", disabled: full, onClick: () => setOpen(session), children: labels.register })] }, session.id));
|
|
51
|
-
}) }))] })) }), open && _jsx(RegisterDialog, { session: open, timezone: timezone, renderPayment: renderPayment, onRegistered: onRegistered, locale: locale, labels: givenLabels, onClose: () => setOpen(null) })] }));
|
|
51
|
+
}) }))] })) }), open && _jsx(RegisterDialog, { session: open, timezone: timezone, renderPayment: renderPayment, funding: funding, onRegistered: onRegistered, locale: locale, labels: givenLabels, onClose: () => setOpen(null) })] }));
|
|
52
52
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
2
|
import { type SessionsParams } from "../hooks.js";
|
|
3
3
|
import { type CalendarLabels } from "../labels.js";
|
|
4
|
-
import type { Booking, BookingResult } from "../types.js";
|
|
4
|
+
import type { Booking, BookingFunding, BookingResult } from "../types.js";
|
|
5
5
|
export interface SessionListProps {
|
|
6
6
|
/** `service_setting_id` for one class, `from` / `to` for a window, page size (`limit`). */
|
|
7
7
|
params?: SessionsParams;
|
|
@@ -22,6 +22,8 @@ export interface SessionListProps {
|
|
|
22
22
|
maxWeeks?: number;
|
|
23
23
|
/** A seat paid online: what to render while the payment is due (see `BookingFlow`). */
|
|
24
24
|
renderPayment?: (result: BookingResult) => ReactNode;
|
|
25
|
+
/** Pay with a package the customer holds (its entitlement) instead of the ticket's default. */
|
|
26
|
+
funding?: BookingFunding;
|
|
25
27
|
onRegistered?: (result: BookingResult) => void;
|
|
26
28
|
/** Shown when there are no sessions. Default: the `noSessions` label. */
|
|
27
29
|
empty?: ReactNode;
|
|
@@ -30,17 +32,19 @@ export interface SessionListProps {
|
|
|
30
32
|
className?: string;
|
|
31
33
|
}
|
|
32
34
|
/** No seat can be taken: no open ticket, no seats left, or registration closed. */
|
|
33
|
-
export declare function sessionIsFull(session: Booking): boolean;
|
|
35
|
+
export declare function sessionIsFull(session: Booking, funding?: BookingFunding): boolean;
|
|
34
36
|
export interface RegisterDialogProps {
|
|
35
37
|
session: Booking;
|
|
36
38
|
timezone: string;
|
|
37
39
|
renderPayment?: (result: BookingResult) => ReactNode;
|
|
38
40
|
onRegistered?: (result: BookingResult) => void;
|
|
41
|
+
/** Pay with a package the customer holds (its entitlement) instead of the ticket's default. */
|
|
42
|
+
funding?: BookingFunding;
|
|
39
43
|
locale?: string;
|
|
40
44
|
labels?: Partial<CalendarLabels>;
|
|
41
45
|
onClose: () => void;
|
|
42
46
|
}
|
|
43
47
|
/** Take a seat in one session: the ticket, who it is for, then the confirmation or the payment. */
|
|
44
|
-
export declare function RegisterDialog({ session, timezone, renderPayment, onRegistered, locale, labels: given, onClose }: RegisterDialogProps): import("react").JSX.Element;
|
|
48
|
+
export declare function RegisterDialog({ session, timezone, renderPayment, onRegistered, funding, locale, labels: given, onClose }: RegisterDialogProps): import("react").JSX.Element;
|
|
45
49
|
/** Upcoming class sessions by day, each with its seats and a Register button. */
|
|
46
|
-
export declare function SessionList({ params, timezone: given, variant, paging, maxWeeks, renderPayment, onRegistered, empty, locale, labels: givenLabels, className, }: SessionListProps): import("react").JSX.Element;
|
|
50
|
+
export declare function SessionList({ params, timezone: given, variant, paging, maxWeeks, renderPayment, onRegistered, funding, empty, locale, labels: givenLabels, className, }: SessionListProps): import("react").JSX.Element;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Image } from "@doany-ai/app-core/media";
|
|
2
3
|
import { cn } from "@doany-ai/app-core";
|
|
3
4
|
import { useAuth } from "@doany-ai/app-core/auth";
|
|
4
5
|
import { Button } from "@doany-ai/app-core/ui/button";
|
|
@@ -17,18 +18,19 @@ function messageOf(cause, labels, given) {
|
|
|
17
18
|
return labels.rateLimited;
|
|
18
19
|
return given?.bookingFailed ?? (message || labels.bookingFailed);
|
|
19
20
|
}
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
/** The tickets a visitor can take; with a package, the ones open to a package holder too. */
|
|
22
|
+
function openVariants(session, funding) {
|
|
23
|
+
return (session.allowed_variants ?? []).filter((v) => v.is_active && (v.registration_status === "open" || (Boolean(funding) && v.package_registration_status === "open")));
|
|
22
24
|
}
|
|
23
25
|
/** No seat can be taken: no open ticket, no seats left, or registration closed. */
|
|
24
|
-
export function sessionIsFull(session) {
|
|
25
|
-
return !openVariants(session).length || (session.remaining_seats ?? 0) <= 0 || !session.allowed_actions.includes("create_registration");
|
|
26
|
+
export function sessionIsFull(session, funding) {
|
|
27
|
+
return !openVariants(session, funding).length || (session.remaining_seats ?? 0) <= 0 || !session.allowed_actions.includes("create_registration");
|
|
26
28
|
}
|
|
27
29
|
/** Take a seat in one session: the ticket, who it is for, then the confirmation or the payment. */
|
|
28
|
-
export function RegisterDialog({ session, timezone, renderPayment, onRegistered, locale, labels: given, onClose }) {
|
|
30
|
+
export function RegisterDialog({ session, timezone, renderPayment, onRegistered, funding, locale, labels: given, onClose }) {
|
|
29
31
|
const labels = calendarLabels(given);
|
|
30
32
|
const { user } = useAuth();
|
|
31
|
-
const tickets = openVariants(session);
|
|
33
|
+
const tickets = openVariants(session, funding);
|
|
32
34
|
const [ticket, setTicket] = useState(tickets.find((t) => t.catalog_item_variant_id === session.catalog_item_variant_id) ?? tickets[0] ?? null);
|
|
33
35
|
const [result, setResult] = useState(null);
|
|
34
36
|
const register = useRegister();
|
|
@@ -37,6 +39,7 @@ export function RegisterDialog({ session, timezone, renderPayment, onRegistered,
|
|
|
37
39
|
bookingId: session.id,
|
|
38
40
|
attendee: { ...attendee, timezone },
|
|
39
41
|
...(ticket ? { catalog_item_variant_id: ticket.catalog_item_variant_id } : {}),
|
|
42
|
+
...(funding ? { funding } : {}),
|
|
40
43
|
expected: {
|
|
41
44
|
booking_version: session.version,
|
|
42
45
|
payment_requirement: ticket?.payment_requirement ?? session.payment_requirement,
|
|
@@ -58,17 +61,17 @@ export function RegisterDialog({ session, timezone, renderPayment, onRegistered,
|
|
|
58
61
|
}) })), _jsx(AttendeeForm, { fields: session.booking_fields_snapshot, phoneRequired: session.phone_required, defaultValues: { name: user?.full_name ?? "", email: user?.email ?? "" }, onSubmit: submit, submitLabel: labels.register, busy: register.isPending, error: register.isError ? messageOf(register.error, labels, given) : null, labels: given })] }))] }) }));
|
|
59
62
|
}
|
|
60
63
|
/** How many of the seats are gone, as a bar, with the count beside it. */
|
|
61
|
-
function Seats({ session, labels }) {
|
|
64
|
+
function Seats({ session, labels, funding }) {
|
|
62
65
|
const capacity = session.capacity ?? 0;
|
|
63
66
|
const left = session.remaining_seats ?? 0;
|
|
64
|
-
const full = sessionIsFull(session);
|
|
67
|
+
const full = sessionIsFull(session, funding);
|
|
65
68
|
const taken = capacity ? Math.min(1, Math.max(0, (capacity - left) / capacity)) : 0;
|
|
66
69
|
const few = !full && left <= 3;
|
|
67
70
|
return (_jsxs("div", { className: "w-32 space-y-1.5", children: [_jsxs("p", { className: cn("flex items-center gap-1.5 text-xs", full ? "text-muted-foreground" : few ? "font-semibold text-destructive" : "text-muted-foreground"), children: [_jsx(Users, { className: "h-3.5 w-3.5", "aria-hidden": "true" }), full ? labels.full : few ? labels.onlyLeft(left) : capacity ? labels.spotsOf(left, capacity) : labels.seatsLeft(left)] }), _jsx("div", { className: "h-1.5 overflow-hidden rounded-full bg-muted", "aria-hidden": "true", children: _jsx("div", { className: cn("h-full rounded-full", full ? "bg-destructive/60" : "bg-primary"), style: { width: `${taken * 100}%` } }) })] }));
|
|
68
71
|
}
|
|
69
72
|
const minutesBetween = (a, b) => Math.round((new Date(b).getTime() - new Date(a).getTime()) / 60000);
|
|
70
73
|
/** Upcoming class sessions by day, each with its seats and a Register button. */
|
|
71
|
-
export function SessionList({ params, timezone: given, variant = "compact", paging = "all", maxWeeks = 8, renderPayment, onRegistered, empty, locale, labels: givenLabels, className, }) {
|
|
74
|
+
export function SessionList({ params, timezone: given, variant = "compact", paging = "all", maxWeeks = 8, renderPayment, onRegistered, funding, empty, locale, labels: givenLabels, className, }) {
|
|
72
75
|
const labels = calendarLabels(givenLabels);
|
|
73
76
|
const timezone = given ?? browserTimezone();
|
|
74
77
|
const detailed = variant === "detailed";
|
|
@@ -119,18 +122,18 @@ export function SessionList({ params, timezone: given, variant = "compact", pagi
|
|
|
119
122
|
const parts = formatDayParts(list[0].start_at, timezone, locale);
|
|
120
123
|
return (_jsxs("section", { className: "grid gap-4 md:grid-cols-[5.5rem_1fr] md:gap-8", "aria-label": `${parts.weekday} ${parts.day} ${parts.month}`, children: [_jsxs("div", { className: "flex items-baseline gap-2 md:sticky md:top-24 md:block md:self-start", children: [_jsx("p", { className: "text-xs font-semibold uppercase tracking-[0.2em] text-primary", children: parts.weekday }), _jsx("p", { className: "font-heading text-3xl font-semibold leading-none md:mt-1 md:text-5xl", children: parts.day }), _jsx("p", { className: "text-sm text-muted-foreground md:mt-1.5", children: parts.month })] }), _jsx("ul", { className: "divide-y divide-border overflow-hidden rounded-3xl border border-border bg-card shadow-soft", children: list.map((session) => {
|
|
121
124
|
const detail = detailOf(session);
|
|
122
|
-
const full = sessionIsFull(session);
|
|
125
|
+
const full = sessionIsFull(session, funding);
|
|
123
126
|
const price = variantPriceLabel({ price_amount: session.price_amount, currency: session.currency }, locale, { free: labels.free });
|
|
124
|
-
return (_jsxs("li", { className: "grid grid-cols-[5rem_1fr] items-center gap-x-4 gap-y-3 p-4 sm:grid-cols-[5.5rem_3.5rem_1fr_auto] sm:gap-x-5 sm:px-5", children: [_jsxs("div", { children: [_jsx("p", { className: "font-heading text-lg font-semibold leading-tight tabular-nums", children: formatTime(session.start_at, timezone, locale) }), _jsx("p", { className: "text-xs text-muted-foreground", children: formatDuration(minutesBetween(session.start_at, session.end_at)) })] }), detail?.image_url ? (_jsx(
|
|
127
|
+
return (_jsxs("li", { className: "grid grid-cols-[5rem_1fr] items-center gap-x-4 gap-y-3 p-4 sm:grid-cols-[5.5rem_3.5rem_1fr_auto] sm:gap-x-5 sm:px-5", children: [_jsxs("div", { children: [_jsx("p", { className: "font-heading text-lg font-semibold leading-tight tabular-nums", children: formatTime(session.start_at, timezone, locale) }), _jsx("p", { className: "text-xs text-muted-foreground", children: formatDuration(minutesBetween(session.start_at, session.end_at)) })] }), detail?.image_url ? (_jsx(Image, { src: detail.image_url, alt: "", className: "hidden h-14 w-14 rounded-2xl object-cover sm:block" })) : (_jsx("span", { className: "hidden h-14 w-14 rounded-2xl bg-muted sm:block", "aria-hidden": "true" })), _jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "font-semibold", children: session.item_name }), detail?.description ? _jsx("p", { className: "truncate text-sm text-muted-foreground", children: detail.description }) : null, _jsxs("p", { className: "mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground", children: [_jsxs("span", { className: "rounded-full bg-muted px-2.5 py-0.5 font-medium text-foreground", children: [session.variant_name, price ? ` · ${price}` : ""] }), session.address ? (_jsxs("span", { className: "inline-flex items-center gap-1", children: [_jsx(MapPin, { className: "h-3.5 w-3.5", "aria-hidden": "true" }), session.address] })) : null] })] }), _jsxs("div", { className: "col-span-2 flex items-center justify-between gap-4 border-t border-border pt-3 sm:col-span-1 sm:flex-col sm:items-end sm:border-0 sm:pt-0", children: [_jsx(Seats, { session: session, labels: labels, funding: funding }), _jsx(Button, { className: "h-10 rounded-full px-5 font-semibold", disabled: full, onClick: () => setOpen(session), children: full ? labels.full : labels.register })] })] }, session.id));
|
|
125
128
|
}) })] }, key));
|
|
126
129
|
}) }));
|
|
127
130
|
}
|
|
128
131
|
else {
|
|
129
132
|
body = (_jsx("div", { className: "space-y-8", children: days.map(([day, list]) => (_jsxs("section", { className: "space-y-3", children: [_jsx("h3", { className: "font-heading text-base font-semibold", children: day }), _jsx("ul", { className: "divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card shadow-soft", children: list.map((session) => {
|
|
130
133
|
const seats = session.remaining_seats ?? 0;
|
|
131
|
-
const full = sessionIsFull(session);
|
|
134
|
+
const full = sessionIsFull(session, funding);
|
|
132
135
|
return (_jsxs("li", { className: "flex flex-wrap items-center gap-x-4 gap-y-2 px-5 py-4", children: [_jsx("span", { className: "w-24 font-semibold tabular-nums", children: formatTime(session.start_at, timezone, locale) }), _jsxs("span", { className: "min-w-40 flex-1", children: [_jsx("span", { className: "block font-medium", children: session.item_name }), _jsx("span", { className: "block text-sm text-muted-foreground", children: session.variant_name })] }), _jsxs("span", { className: cn("inline-flex items-center gap-1.5 text-sm", full ? "text-muted-foreground" : "text-foreground"), children: [_jsx(Users, { className: "h-4 w-4", "aria-hidden": "true" }), full ? labels.full : labels.seatsLeft(seats)] }), _jsx(Button, { className: "rounded-lg", size: "sm", disabled: full, onClick: () => setOpen(session), children: labels.register })] }, session.id));
|
|
133
136
|
}) })] }, day))) }));
|
|
134
137
|
}
|
|
135
|
-
return (_jsxs("div", { className: cn("space-y-6", className), children: [weekBar, body, hasNextPage && !isLoading && !isError && (_jsx("div", { className: "flex justify-center", children: _jsx(Button, { variant: "outline", className: "min-w-32 rounded-lg", onClick: () => fetchNextPage(), disabled: isFetchingNextPage, children: isFetchingNextPage ? labels.loadingMore : labels.loadMore }) })), open && _jsx(RegisterDialog, { session: open, timezone: timezone, renderPayment: renderPayment, onRegistered: onRegistered, locale: locale, labels: givenLabels, onClose: () => setOpen(null) })] }));
|
|
138
|
+
return (_jsxs("div", { className: cn("space-y-6", className), children: [weekBar, body, hasNextPage && !isLoading && !isError && (_jsx("div", { className: "flex justify-center", children: _jsx(Button, { variant: "outline", className: "min-w-32 rounded-lg", onClick: () => fetchNextPage(), disabled: isFetchingNextPage, children: isFetchingNextPage ? labels.loadingMore : labels.loadMore }) })), open && _jsx(RegisterDialog, { session: open, timezone: timezone, renderPayment: renderPayment, onRegistered: onRegistered, funding: funding, locale: locale, labels: givenLabels, onClose: () => setOpen(null) })] }));
|
|
136
139
|
}
|
package/dist/hooks.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CatalogItemSummary, BookableService, Booking, BookingMode, BookingResult, BookParams, CancelRegistrationParams, OperationReceipt, Page, RegisterSeatParams, RegistrationAccess, RegistrationDetail, Slot } from "./types.js";
|
|
1
|
+
import type { CatalogItemSummary, BookableService, Booking, BookingMode, BookingResult, BookParams, CancelRegistrationParams, Operation, OperationReceipt, Page, RegisterSeatParams, RegistrationAccess, RegistrationDetail, Slot } from "./types.js";
|
|
2
2
|
/** The query keys this package reads under, for invalidating from elsewhere. */
|
|
3
3
|
export declare const calendarKeys: {
|
|
4
4
|
details: () => readonly ["doany", "calendar", "details"];
|
|
@@ -699,6 +699,16 @@ export declare function useRegister(): import("@tanstack/react-query").UseMutati
|
|
|
699
699
|
* token, the given one, or the one in the booking link's `#t=`).
|
|
700
700
|
*/
|
|
701
701
|
export declare function useRegistration(registrationId: string | null | undefined, access?: RegistrationAccess): import("@tanstack/react-query").UseQueryResult<RegistrationDetail, Error>;
|
|
702
|
+
/** Has the operation settled? The backend says when to read again; `null` once it is done. */
|
|
703
|
+
export declare const operationSettled: (operation: Operation | undefined) => boolean;
|
|
704
|
+
/**
|
|
705
|
+
* A cancel that waits on a payment answers with an operation: read it with
|
|
706
|
+
* the registration's token until it settles, then the registration is read
|
|
707
|
+
* again. `registrationId` names the registration to refresh.
|
|
708
|
+
*/
|
|
709
|
+
export declare function useCalendarOperation(operationId: string | null | undefined, { registrationId, accessToken }?: {
|
|
710
|
+
registrationId?: string;
|
|
711
|
+
} & RegistrationAccess): import("@tanstack/react-query").UseQueryResult<Operation, Error>;
|
|
702
712
|
/** Cancels the visitor's registration, within the service's policy. */
|
|
703
713
|
export declare function useCancelRegistration(): import("@tanstack/react-query").UseMutationResult<OperationReceipt | RegistrationDetail, unknown, {
|
|
704
714
|
registrationId: string;
|
package/dist/hooks.js
CHANGED
|
@@ -133,6 +133,31 @@ export function useRegistration(registrationId, access = {}) {
|
|
|
133
133
|
refetchInterval: (query) => (query.state.data?.registration.status === "pending_payment" ? 3000 : false),
|
|
134
134
|
});
|
|
135
135
|
}
|
|
136
|
+
/** Has the operation settled? The backend says when to read again; `null` once it is done. */
|
|
137
|
+
export const operationSettled = (operation) => Boolean(operation) && operation.next_poll_at === null;
|
|
138
|
+
/**
|
|
139
|
+
* A cancel that waits on a payment answers with an operation: read it with
|
|
140
|
+
* the registration's token until it settles, then the registration is read
|
|
141
|
+
* again. `registrationId` names the registration to refresh.
|
|
142
|
+
*/
|
|
143
|
+
export function useCalendarOperation(operationId, { registrationId, accessToken } = {}) {
|
|
144
|
+
const doany = useDoany();
|
|
145
|
+
const queryClient = useQueryClient();
|
|
146
|
+
return useQuery({
|
|
147
|
+
queryKey: [...calendarKeys.all, "operation", operationId ?? "", accessToken ?? null],
|
|
148
|
+
queryFn: async () => {
|
|
149
|
+
const operation = await doany.calendar.operation(operationId, { accessToken, registrationId });
|
|
150
|
+
if (operationSettled(operation) && registrationId) {
|
|
151
|
+
void queryClient.invalidateQueries({ queryKey: calendarKeys.anyRegistration(registrationId) });
|
|
152
|
+
void queryClient.invalidateQueries({ queryKey: [...calendarKeys.all, "slots"] });
|
|
153
|
+
void queryClient.invalidateQueries({ queryKey: [...calendarKeys.all, "sessions"] });
|
|
154
|
+
}
|
|
155
|
+
return operation;
|
|
156
|
+
},
|
|
157
|
+
enabled: Boolean(operationId),
|
|
158
|
+
refetchInterval: (query) => (operationSettled(query.state.data) ? false : 2000),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
136
161
|
/** Cancels the visitor's registration, within the service's policy. */
|
|
137
162
|
export function useCancelRegistration() {
|
|
138
163
|
const doany = useDoany();
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { calendarKeys, usePackages, useServiceDetails, useBook, useBookableService, useBookableServices, useCancelRegistration, useRegister, useRegistration, useSession, useSessions, useSlots, type BookableServicesParams, type SessionsParams, type UseSlotsParams, } from "./hooks.js";
|
|
1
|
+
export { calendarKeys, usePackages, useServiceDetails, useBook, useBookableService, useBookableServices, useCancelRegistration, useRegister, useRegistration, useCalendarOperation, operationSettled, useSession, useSessions, useSlots, type BookableServicesParams, type SessionsParams, type UseSlotsParams, } from "./hooks.js";
|
|
2
2
|
export { readBookingLinkToken, useBookingAccessToken } from "./booking-link.js";
|
|
3
3
|
export { browserTimezone, defaultVariant, formatDateTime, formatDay, formatDayParts, formatDuration, formatTime, formatTimezone, localDate, servicePriceLabel, variantPriceLabel, } from "./format.js";
|
|
4
4
|
export { calendarLabels, defaultCalendarLabels, type CalendarLabels } from "./labels.js";
|
|
@@ -10,4 +10,4 @@ export { SessionList, RegisterDialog, sessionIsFull, type SessionListProps, type
|
|
|
10
10
|
export { SessionCalendar, type SessionCalendarProps } from "./components/session-calendar.js";
|
|
11
11
|
export { PackageCard, PackageList, packageVariant, type PackageCardProps, type PackageListProps } from "./components/package-list.js";
|
|
12
12
|
export { BookingStatus, type BookingStatusProps } from "./components/booking-status.js";
|
|
13
|
-
export type { Attendee, BookableService, BookableVariant, Booking, BookingField, BookingMode, BookingResult, BookParams, CalendarClient, CancelRegistrationParams, CatalogItemSummary, LocationType, OperationReceipt, Page, PaymentRequirement, Registration, RegistrationAccess, RegistrationDetail, RegistrationStatus, RegisterSeatParams, SessionVariant, Slot, } from "./types.js";
|
|
13
|
+
export type { Attendee, BookableService, BookableVariant, Booking, BookingField, BookingMode, BookingResult, BookingFunding, BookParams, CalendarClient, CancelRegistrationParams, CatalogItemSummary, LocationType, Operation, OperationReceipt, Page, PaymentRequirement, Registration, RegistrationAccess, RegistrationDetail, RegistrationStatus, RegisterSeatParams, SessionVariant, Slot, } from "./types.js";
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// theme variables, adjust through props (their words through `labels`), or
|
|
6
6
|
// replace while keeping the hooks. The package ships no CSS: the site's
|
|
7
7
|
// Tailwind build scans it (see @doany-ai/app-core/tailwind-preset).
|
|
8
|
-
export { calendarKeys, usePackages, useServiceDetails, useBook, useBookableService, useBookableServices, useCancelRegistration, useRegister, useRegistration, useSession, useSessions, useSlots, } from "./hooks.js";
|
|
8
|
+
export { calendarKeys, usePackages, useServiceDetails, useBook, useBookableService, useBookableServices, useCancelRegistration, useRegister, useRegistration, useCalendarOperation, operationSettled, useSession, useSessions, useSlots, } from "./hooks.js";
|
|
9
9
|
export { readBookingLinkToken, useBookingAccessToken } from "./booking-link.js";
|
|
10
10
|
export { browserTimezone, defaultVariant, formatDateTime, formatDay, formatDayParts, formatDuration, formatTime, formatTimezone, localDate, servicePriceLabel, variantPriceLabel, } from "./format.js";
|
|
11
11
|
export { calendarLabels, defaultCalendarLabels } from "./labels.js";
|
package/dist/labels.d.ts
CHANGED
|
@@ -36,6 +36,8 @@ export interface CalendarLabels {
|
|
|
36
36
|
name: string;
|
|
37
37
|
email: string;
|
|
38
38
|
phone: string;
|
|
39
|
+
/** Shown while a phone number is not in the international format. */
|
|
40
|
+
phoneFormat: string;
|
|
39
41
|
/** Under the details form, with the appointment restated. */
|
|
40
42
|
confirmBooking: string;
|
|
41
43
|
booking: string;
|
|
@@ -94,6 +96,9 @@ export interface CalendarLabels {
|
|
|
94
96
|
location: string;
|
|
95
97
|
online: string;
|
|
96
98
|
atYourAddress: string;
|
|
99
|
+
joinMeeting: string;
|
|
100
|
+
yourAddress: string;
|
|
101
|
+
addressRequired: string;
|
|
97
102
|
}
|
|
98
103
|
export declare const defaultCalendarLabels: CalendarLabels;
|
|
99
104
|
/** The defaults with a site's changes on top. */
|
package/dist/labels.js
CHANGED
|
@@ -21,6 +21,7 @@ export const defaultCalendarLabels = {
|
|
|
21
21
|
name: "Name",
|
|
22
22
|
email: "Email",
|
|
23
23
|
phone: "Phone",
|
|
24
|
+
phoneFormat: "Use the international format, like +1 415 555 0132.",
|
|
24
25
|
confirmBooking: "Confirm booking",
|
|
25
26
|
booking: "Booking…",
|
|
26
27
|
back: "Back",
|
|
@@ -68,6 +69,9 @@ export const defaultCalendarLabels = {
|
|
|
68
69
|
location: "Location",
|
|
69
70
|
online: "Online",
|
|
70
71
|
atYourAddress: "At your address",
|
|
72
|
+
joinMeeting: "Join online",
|
|
73
|
+
yourAddress: "Your address",
|
|
74
|
+
addressRequired: "Tell us the address we should come to.",
|
|
71
75
|
};
|
|
72
76
|
/** The defaults with a site's changes on top. */
|
|
73
77
|
export const calendarLabels = (labels) => labels ? { ...defaultCalendarLabels, ...labels } : defaultCalendarLabels;
|
package/dist/phone.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** The platform takes phone numbers in E.164 (`+14155550132`): the visitor's
|
|
2
|
+
* spaces, dots, dashes and brackets are dropped and a `00` prefix becomes `+`. */
|
|
3
|
+
export declare function normalizePhone(raw: string): string;
|
|
4
|
+
export declare const isE164: (value: string) => boolean;
|
package/dist/phone.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** The platform takes phone numbers in E.164 (`+14155550132`): the visitor's
|
|
2
|
+
* spaces, dots, dashes and brackets are dropped and a `00` prefix becomes `+`. */
|
|
3
|
+
export function normalizePhone(raw) {
|
|
4
|
+
const trimmed = raw.trim().replace(/[\s().-]/g, "");
|
|
5
|
+
return trimmed.startsWith("00") ? `+${trimmed.slice(2)}` : trimmed;
|
|
6
|
+
}
|
|
7
|
+
export const isE164 = (value) => /^\+[1-9][0-9]{1,14}$/.test(value);
|
package/dist/types.d.ts
CHANGED
|
@@ -76,6 +76,8 @@ export interface SessionVariant {
|
|
|
76
76
|
is_active: boolean;
|
|
77
77
|
registration_status: string;
|
|
78
78
|
registration_closed_reason: string | null;
|
|
79
|
+
/** The ticket's status for a package holder: `open` while cash registration is closed (the prepayment window). */
|
|
80
|
+
package_registration_status?: string | null;
|
|
79
81
|
}
|
|
80
82
|
/** An appointment, or a class session with seats. */
|
|
81
83
|
export interface Booking {
|
|
@@ -95,6 +97,10 @@ export interface Booking {
|
|
|
95
97
|
payment_requirement: PaymentRequirement;
|
|
96
98
|
location_type: LocationType | null;
|
|
97
99
|
address?: string | null;
|
|
100
|
+
/** An online booking's link, on the registration's own read (never public). */
|
|
101
|
+
meeting?: {
|
|
102
|
+
meeting_url: string | null;
|
|
103
|
+
} | null;
|
|
98
104
|
phone_required: boolean;
|
|
99
105
|
booking_fields_snapshot: BookingField[];
|
|
100
106
|
allowed_actions: string[];
|
|
@@ -128,6 +134,11 @@ export interface Registration {
|
|
|
128
134
|
hold_expires_at: string | null;
|
|
129
135
|
allowed_actions: string[];
|
|
130
136
|
}
|
|
137
|
+
/** How a booking is paid for when not the service's default: a package the customer holds. */
|
|
138
|
+
export interface BookingFunding {
|
|
139
|
+
kind: "package";
|
|
140
|
+
entitlement_id: string;
|
|
141
|
+
}
|
|
131
142
|
export interface BookParams {
|
|
132
143
|
service_setting_id: string;
|
|
133
144
|
catalog_item_variant_id: string;
|
|
@@ -135,9 +146,13 @@ export interface BookParams {
|
|
|
135
146
|
slot_token: string;
|
|
136
147
|
attendee: Attendee;
|
|
137
148
|
address?: string;
|
|
149
|
+
/** Pay with a package the customer holds (its entitlement), instead of the default. */
|
|
150
|
+
funding?: BookingFunding;
|
|
138
151
|
}
|
|
139
152
|
export interface RegisterSeatParams {
|
|
140
153
|
attendee: Attendee;
|
|
154
|
+
/** Pay with a package the customer holds (its entitlement), instead of the default. */
|
|
155
|
+
funding?: BookingFunding;
|
|
141
156
|
catalog_item_variant_id?: string;
|
|
142
157
|
expected: {
|
|
143
158
|
booking_version: number;
|
|
@@ -160,6 +175,21 @@ export interface CancelRegistrationParams {
|
|
|
160
175
|
booking_version: number;
|
|
161
176
|
reason?: string;
|
|
162
177
|
}
|
|
178
|
+
/** A cancel that waits on a payment: what `calendar.operation` reads until it settles. */
|
|
179
|
+
export interface Operation {
|
|
180
|
+
id: string;
|
|
181
|
+
status: string;
|
|
182
|
+
phase: string;
|
|
183
|
+
updated_at: string;
|
|
184
|
+
/** When to read again; `null` once the operation has settled. */
|
|
185
|
+
next_poll_at: string | null;
|
|
186
|
+
error_code: string | null;
|
|
187
|
+
result: {
|
|
188
|
+
booking_id: string;
|
|
189
|
+
registration_id: string | null;
|
|
190
|
+
outcome: "canceled" | "payment_settled" | "expired";
|
|
191
|
+
} | null;
|
|
192
|
+
}
|
|
163
193
|
export interface OperationReceipt {
|
|
164
194
|
operation_id: string;
|
|
165
195
|
}
|
|
@@ -238,5 +268,10 @@ export interface CalendarClient extends DoanyClientLike {
|
|
|
238
268
|
idempotencyKey?: string;
|
|
239
269
|
}): Promise<RegistrationDetail | OperationReceipt>;
|
|
240
270
|
};
|
|
271
|
+
/** One operation, read with the registration's token until it settles. */
|
|
272
|
+
operation(operationId: string, options?: {
|
|
273
|
+
accessToken?: string;
|
|
274
|
+
registrationId?: string;
|
|
275
|
+
}): Promise<Operation>;
|
|
241
276
|
};
|
|
242
277
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@doany-ai/calendar",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.1",
|
|
4
4
|
"description": "Booking the business's services, on a Doany site: calendar hooks and default components (service list, date and time picker, booking flow, class sessions, booking status)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|