@doany-ai/calendar 0.1.0-alpha.1 → 0.1.0-alpha.3
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/dist/components/booking-flow.js +41 -5
- package/dist/components/date-time-picker.js +3 -3
- package/dist/components/session-list.d.ts +6 -2
- package/dist/components/session-list.js +10 -5
- package/dist/format.d.ts +10 -0
- package/dist/format.js +15 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
|
@@ -7,19 +7,36 @@ import { Input } from "@doany-ai/app-core/ui/input";
|
|
|
7
7
|
import { Label } from "@doany-ai/app-core/ui/label";
|
|
8
8
|
import { Skeleton } from "@doany-ai/app-core/ui/skeleton";
|
|
9
9
|
import { CalendarCheck, CheckCircle2, Clock, Globe, MapPin, Tag, Users } from "lucide-react";
|
|
10
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
10
11
|
import { useState } from "react";
|
|
11
12
|
import { browserTimezone, defaultVariant, formatDateTime, formatTimezone, variantPriceLabel } from "../format.js";
|
|
12
|
-
import { useBook, useBookableService, useServiceDetails } from "../hooks.js";
|
|
13
|
+
import { calendarKeys, useBook, useBookableService, useServiceDetails } from "../hooks.js";
|
|
13
14
|
import { calendarLabels } from "../labels.js";
|
|
14
15
|
import { AttendeeForm } from "./attendee-form.js";
|
|
15
16
|
import { DateTimePicker } from "./date-time-picker.js";
|
|
17
|
+
import { SessionList } from "./session-list.js";
|
|
16
18
|
function messageOf(cause, labels, given) {
|
|
17
19
|
const { code, message } = (cause ?? {});
|
|
18
20
|
if (code === "SLOT_UNAVAILABLE")
|
|
19
|
-
return { text: labels.slotTaken, slotTaken: true };
|
|
21
|
+
return { text: labels.slotTaken, slotTaken: true, stale: false };
|
|
22
|
+
if (code === "VERSION_CONFLICT") {
|
|
23
|
+
// The slot's quote lasts five minutes; after that, or once availability
|
|
24
|
+
// changed, the platform says so and the visitor picks a time again. The
|
|
25
|
+
// same code also answers a service or price that changed under the
|
|
26
|
+
// visitor ("Service details changed; reload before booking"): then the
|
|
27
|
+
// service is read again and the visitor hears the platform's words.
|
|
28
|
+
const quote = /slot|availability/i.test(message ?? "");
|
|
29
|
+
return { text: message || labels.slotTaken, slotTaken: quote, stale: !quote };
|
|
30
|
+
}
|
|
20
31
|
if (code === "RATE_LIMITED")
|
|
21
|
-
return { text: labels.rateLimited, slotTaken: false };
|
|
22
|
-
|
|
32
|
+
return { text: labels.rateLimited, slotTaken: false, stale: false };
|
|
33
|
+
// A refusal the platform explains (it carries a code) is shown in its
|
|
34
|
+
// words: a site's generic line hid "Availability changed; choose a new
|
|
35
|
+
// slot" behind "Something went wrong" (2026-10-10). A failure with no code
|
|
36
|
+
// (the network, a timeout) keeps the site's line.
|
|
37
|
+
if (code && message)
|
|
38
|
+
return { text: message, slotTaken: false, stale: false };
|
|
39
|
+
return { text: given?.bookingFailed ?? labels.bookingFailed, slotTaken: false, stale: false };
|
|
23
40
|
}
|
|
24
41
|
function VariantChoice({ service, value, onChange, locale, labels }) {
|
|
25
42
|
if (service.variants.length < 2)
|
|
@@ -66,6 +83,7 @@ export function BookingFlow({ serviceSettingId, catalogItemVariantId, timezone:
|
|
|
66
83
|
const [step, setStep] = useState(order === "who-first" ? "who" : "when");
|
|
67
84
|
const [result, setResult] = useState(null);
|
|
68
85
|
const book = useBook();
|
|
86
|
+
const queryClient = useQueryClient();
|
|
69
87
|
const stacked = layout === "stacked";
|
|
70
88
|
if (isLoading) {
|
|
71
89
|
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" })] }));
|
|
@@ -73,6 +91,15 @@ export function BookingFlow({ serviceSettingId, catalogItemVariantId, timezone:
|
|
|
73
91
|
if (isError || !service) {
|
|
74
92
|
return (_jsx("p", { role: "alert", className: cn("py-10 text-center text-sm text-destructive", className), children: labels.notAvailable }));
|
|
75
93
|
}
|
|
94
|
+
if (service.booking_mode === "class") {
|
|
95
|
+
// A class has no free times to pick from: it has sessions, each with its
|
|
96
|
+
// seats. The flow is the class's upcoming sessions with Register, so a
|
|
97
|
+
// site's one booking page serves both modes without knowing which is which.
|
|
98
|
+
return (_jsxs("div", { className: cn("space-y-8", className), children: [_jsx("h2", { className: "font-heading text-2xl font-semibold tracking-tight", children: service.item_name }), _jsx(SessionList, { variant: "detailed", params: { service_setting_id: serviceSettingId }, timezone: given, catalogItemVariantId: catalogItemVariantId, funding: funding, renderPayment: renderPayment, onRegistered: (r) => {
|
|
99
|
+
setResult(r);
|
|
100
|
+
onBooked?.(r);
|
|
101
|
+
}, locale: locale, labels: givenLabels }), result ? children?.(result) : null] }));
|
|
102
|
+
}
|
|
76
103
|
const variant = service.variants.find((v) => v.id === variantId) ?? defaultVariant(service);
|
|
77
104
|
if (!variant) {
|
|
78
105
|
return (_jsx("p", { role: "alert", className: cn("py-10 text-center text-sm text-destructive", className), children: labels.notAvailable }));
|
|
@@ -97,7 +124,16 @@ export function BookingFlow({ serviceSettingId, catalogItemVariantId, timezone:
|
|
|
97
124
|
onBooked?.(booked);
|
|
98
125
|
},
|
|
99
126
|
onError: (cause) => {
|
|
100
|
-
|
|
127
|
+
const why = messageOf(cause, labels, givenLabels);
|
|
128
|
+
if (why.stale) {
|
|
129
|
+
// The service or its prices changed under the visitor: read it again.
|
|
130
|
+
void queryClient.invalidateQueries({ queryKey: calendarKeys.service(serviceSettingId) });
|
|
131
|
+
}
|
|
132
|
+
if (why.slotTaken) {
|
|
133
|
+
// The cached slots carry the quotes that just failed: fetch new ones.
|
|
134
|
+
void queryClient.invalidateQueries({
|
|
135
|
+
predicate: (query) => Array.isArray(query.queryKey) && query.queryKey.includes("slots") && query.queryKey.includes(serviceSettingId),
|
|
136
|
+
});
|
|
101
137
|
setSlot(null);
|
|
102
138
|
setStep("when");
|
|
103
139
|
}
|
|
@@ -5,7 +5,7 @@ import { Calendar } from "@doany-ai/app-core/ui/calendar";
|
|
|
5
5
|
import { Skeleton } from "@doany-ai/app-core/ui/skeleton";
|
|
6
6
|
import { Clock, Globe } from "lucide-react";
|
|
7
7
|
import { useMemo, useState } from "react";
|
|
8
|
-
import {
|
|
8
|
+
import { calendarDate, formatCalendarDay, formatTime, formatTimezone } from "../format.js";
|
|
9
9
|
import { useSlots } from "../hooks.js";
|
|
10
10
|
import { calendarLabels } from "../labels.js";
|
|
11
11
|
/** The timezones offered besides the visitor's own, when the runtime cannot list them all. */
|
|
@@ -64,7 +64,7 @@ export function DateTimePicker({ service, variant, timezone, onTimezoneChange, v
|
|
|
64
64
|
const today = useMemo(() => new Date(), []);
|
|
65
65
|
const [month, setMonth] = useState(() => new Date(today.getFullYear(), today.getMonth(), 1));
|
|
66
66
|
const [day, setDay] = useState(undefined);
|
|
67
|
-
const date = day ?
|
|
67
|
+
const date = day ? calendarDate(day) : null;
|
|
68
68
|
const { slots, isLoading, isError, refetch } = useSlots(service.id, { catalogItemVariantId: variant.id, date, timezone });
|
|
69
69
|
const horizon = service.booking_horizon_days
|
|
70
70
|
? new Date(today.getFullYear(), today.getMonth(), today.getDate() + service.booking_horizon_days)
|
|
@@ -72,7 +72,7 @@ export function DateTimePicker({ service, variant, timezone, onTimezoneChange, v
|
|
|
72
72
|
const zones = useMemo(() => timezoneChoices(timezone), [timezone]);
|
|
73
73
|
const wide = layout === "wide";
|
|
74
74
|
const times = cn("grid gap-2 grid-cols-[repeat(auto-fill,minmax(5.5rem,1fr))]", wide && "[@container(min-width:40rem)]:grid-cols-1");
|
|
75
|
-
return (_jsx("div", { className: cn("[container-type:inline-size]", className), children: _jsxs("div", { className: cn("grid gap-6", wide && "[@container(min-width:40rem)]:grid-cols-[auto_minmax(14rem,1fr)]"), children: [_jsxs("div", { className: "space-y-4", children: [_jsx("h3", { className: "font-heading text-base font-semibold", children: labels.selectDateTime }), _jsx(Calendar, { mode: "single", month: month, onMonthChange: setMonth, selected: day, onSelect: (picked) => setDay(picked ?? undefined), fromDate: today, toDate: horizon, disabled: { before: today }, className: "w-fit p-0", classNames: calendarClassNames }), _jsxs("label", { className: "block space-y-1.5 text-sm", children: [_jsx("span", { className: "font-medium text-foreground", children: labels.timezone }), _jsxs("span", { className: "relative block", children: [_jsx(Globe, { 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("select", { value: timezone, onChange: (e) => onTimezoneChange?.(e.target.value), className: "h-10 w-full rounded-lg border border-input bg-background pl-9 pr-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring", children: zones.map((zone) => (_jsx("option", { value: zone, children: formatTimezone(zone, locale) }, zone))) })] })] })] }), _jsxs("div", { className: cn("min-w-0 space-y-3", wide && "[@container(min-width:40rem)]:min-h-80"), children: [_jsxs("p", { className: "flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground", children: [_jsxs("span", { className: "inline-flex items-center gap-1.5 whitespace-nowrap", children: [_jsx(Clock, { className: "h-4 w-4", "aria-hidden": "true" }), variant.duration_minutes ? labels.duration(variant.duration_minutes) : variant.name] }), day ? _jsx("span", { className: "whitespace-nowrap font-medium text-foreground", children:
|
|
75
|
+
return (_jsx("div", { className: cn("[container-type:inline-size]", className), children: _jsxs("div", { className: cn("grid gap-6", wide && "[@container(min-width:40rem)]:grid-cols-[auto_minmax(14rem,1fr)]"), children: [_jsxs("div", { className: "space-y-4", children: [_jsx("h3", { className: "font-heading text-base font-semibold", children: labels.selectDateTime }), _jsx(Calendar, { mode: "single", month: month, onMonthChange: setMonth, selected: day, onSelect: (picked) => setDay(picked ?? undefined), fromDate: today, toDate: horizon, disabled: { before: today }, className: "w-fit p-0", classNames: calendarClassNames }), _jsxs("label", { className: "block space-y-1.5 text-sm", children: [_jsx("span", { className: "font-medium text-foreground", children: labels.timezone }), _jsxs("span", { className: "relative block", children: [_jsx(Globe, { 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("select", { value: timezone, onChange: (e) => onTimezoneChange?.(e.target.value), className: "h-10 w-full rounded-lg border border-input bg-background pl-9 pr-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring", children: zones.map((zone) => (_jsx("option", { value: zone, children: formatTimezone(zone, locale) }, zone))) })] })] })] }), _jsxs("div", { className: cn("min-w-0 space-y-3", wide && "[@container(min-width:40rem)]:min-h-80"), children: [_jsxs("p", { className: "flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground", children: [_jsxs("span", { className: "inline-flex items-center gap-1.5 whitespace-nowrap", children: [_jsx(Clock, { className: "h-4 w-4", "aria-hidden": "true" }), variant.duration_minutes ? labels.duration(variant.duration_minutes) : variant.name] }), day ? _jsx("span", { className: "whitespace-nowrap font-medium text-foreground", children: formatCalendarDay(day, locale) }) : null] }), !day ? null : isLoading ? (_jsx("div", { className: times, "aria-busy": "true", children: [0, 1, 2, 3, 4, 5].map((i) => (_jsx(Skeleton, { className: "h-11 rounded-lg" }, i))) })) : isError ? (_jsxs("div", { role: "alert", className: "space-y-2 text-sm text-destructive", children: [_jsx("p", { children: given?.loadError ?? labels.loadError }), _jsx(Button, { variant: "outline", size: "sm", onClick: () => refetch(), children: labels.tryAgain })] })) : !slots.length ? (_jsx("p", { className: "rounded-xl border border-dashed border-border py-10 text-center text-sm text-muted-foreground", children: labels.noSlots })) : (_jsx("div", { role: "listbox", "aria-label": labels.selectDateTime, className: cn(times, wide && "[@container(min-width:40rem)]:max-h-[26rem] [@container(min-width:40rem)]:overflow-y-auto [@container(min-width:40rem)]:pr-1"), children: slots.map((slot) => {
|
|
76
76
|
const chosen = value?.slot_token === slot.slot_token;
|
|
77
77
|
if (chosen && onConfirm) {
|
|
78
78
|
return (_jsxs("div", { className: cn("col-span-2 grid grid-cols-2 gap-2", wide && "[@container(min-width:40rem)]:col-span-1"), children: [_jsx("button", { type: "button", role: "option", "aria-selected": true, className: "h-11 rounded-lg bg-muted text-sm font-medium text-muted-foreground", onClick: () => onChange(slot), children: formatTime(slot.start_at, timezone, locale) }), _jsx(Button, { type: "button", className: "h-11 rounded-lg font-semibold", onClick: () => onConfirm(slot), children: confirmLabel ?? labels.selectTime })] }, slot.slot_token));
|
|
@@ -24,6 +24,8 @@ export interface SessionListProps {
|
|
|
24
24
|
renderPayment?: (result: BookingResult) => ReactNode;
|
|
25
25
|
/** Pay with a package the customer holds (its entitlement) instead of the ticket's default. */
|
|
26
26
|
funding?: BookingFunding;
|
|
27
|
+
/** Preselect this ticket in the register dialog; the session's own otherwise. */
|
|
28
|
+
catalogItemVariantId?: string;
|
|
27
29
|
onRegistered?: (result: BookingResult) => void;
|
|
28
30
|
/** Shown when there are no sessions. Default: the `noSessions` label. */
|
|
29
31
|
empty?: ReactNode;
|
|
@@ -40,11 +42,13 @@ export interface RegisterDialogProps {
|
|
|
40
42
|
onRegistered?: (result: BookingResult) => void;
|
|
41
43
|
/** Pay with a package the customer holds (its entitlement) instead of the ticket's default. */
|
|
42
44
|
funding?: BookingFunding;
|
|
45
|
+
/** Preselect this ticket (its variant id); the session's own otherwise. */
|
|
46
|
+
catalogItemVariantId?: string;
|
|
43
47
|
locale?: string;
|
|
44
48
|
labels?: Partial<CalendarLabels>;
|
|
45
49
|
onClose: () => void;
|
|
46
50
|
}
|
|
47
51
|
/** Take a seat in one session: the ticket, who it is for, then the confirmation or the payment. */
|
|
48
|
-
export declare function RegisterDialog({ session, timezone, renderPayment, onRegistered, funding, locale, labels: given, onClose }: RegisterDialogProps): import("react").JSX.Element;
|
|
52
|
+
export declare function RegisterDialog({ session, timezone, renderPayment, onRegistered, funding, catalogItemVariantId, locale, labels: given, onClose }: RegisterDialogProps): import("react").JSX.Element;
|
|
49
53
|
/** Upcoming class sessions by day, each with its seats and a Register button. */
|
|
50
|
-
export declare function SessionList({ params, timezone: given, variant, paging, maxWeeks, renderPayment, onRegistered, funding, empty, locale, labels: givenLabels, className, }: SessionListProps): import("react").JSX.Element;
|
|
54
|
+
export declare function SessionList({ params, timezone: given, variant, paging, maxWeeks, renderPayment, onRegistered, funding, catalogItemVariantId, empty, locale, labels: givenLabels, className, }: SessionListProps): import("react").JSX.Element;
|
|
@@ -16,7 +16,9 @@ function messageOf(cause, labels, given) {
|
|
|
16
16
|
const { code, message } = (cause ?? {});
|
|
17
17
|
if (code === "RATE_LIMITED")
|
|
18
18
|
return labels.rateLimited;
|
|
19
|
-
|
|
19
|
+
if (code && message)
|
|
20
|
+
return message;
|
|
21
|
+
return given?.bookingFailed ?? labels.bookingFailed;
|
|
20
22
|
}
|
|
21
23
|
/** The tickets a visitor can take; with a package, the ones open to a package holder too. */
|
|
22
24
|
function openVariants(session, funding) {
|
|
@@ -27,11 +29,14 @@ export function sessionIsFull(session, funding) {
|
|
|
27
29
|
return !openVariants(session, funding).length || (session.remaining_seats ?? 0) <= 0 || !session.allowed_actions.includes("create_registration");
|
|
28
30
|
}
|
|
29
31
|
/** Take a seat in one session: the ticket, who it is for, then the confirmation or the payment. */
|
|
30
|
-
export function RegisterDialog({ session, timezone, renderPayment, onRegistered, funding, locale, labels: given, onClose }) {
|
|
32
|
+
export function RegisterDialog({ session, timezone, renderPayment, onRegistered, funding, catalogItemVariantId, locale, labels: given, onClose }) {
|
|
31
33
|
const labels = calendarLabels(given);
|
|
32
34
|
const { user } = useAuth();
|
|
33
35
|
const tickets = openVariants(session, funding);
|
|
34
|
-
const [ticket, setTicket] = useState(tickets.find((t) => t.catalog_item_variant_id ===
|
|
36
|
+
const [ticket, setTicket] = useState(tickets.find((t) => t.catalog_item_variant_id === catalogItemVariantId) ??
|
|
37
|
+
tickets.find((t) => t.catalog_item_variant_id === session.catalog_item_variant_id) ??
|
|
38
|
+
tickets[0] ??
|
|
39
|
+
null);
|
|
35
40
|
const [result, setResult] = useState(null);
|
|
36
41
|
const register = useRegister();
|
|
37
42
|
const submit = (attendee) => {
|
|
@@ -71,7 +76,7 @@ function Seats({ session, labels, funding }) {
|
|
|
71
76
|
}
|
|
72
77
|
const minutesBetween = (a, b) => Math.round((new Date(b).getTime() - new Date(a).getTime()) / 60000);
|
|
73
78
|
/** Upcoming class sessions by day, each with its seats and a Register button. */
|
|
74
|
-
export function SessionList({ params, timezone: given, variant = "compact", paging = "all", maxWeeks = 8, renderPayment, onRegistered, funding, empty, locale, labels: givenLabels, className, }) {
|
|
79
|
+
export function SessionList({ params, timezone: given, variant = "compact", paging = "all", maxWeeks = 8, renderPayment, onRegistered, funding, catalogItemVariantId, empty, locale, labels: givenLabels, className, }) {
|
|
75
80
|
const labels = calendarLabels(givenLabels);
|
|
76
81
|
const timezone = given ?? browserTimezone();
|
|
77
82
|
const detailed = variant === "detailed";
|
|
@@ -135,5 +140,5 @@ export function SessionList({ params, timezone: given, variant = "compact", pagi
|
|
|
135
140
|
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));
|
|
136
141
|
}) })] }, day))) }));
|
|
137
142
|
}
|
|
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) })] }));
|
|
143
|
+
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, catalogItemVariantId: catalogItemVariantId, locale: locale, labels: givenLabels, onClose: () => setOpen(null) })] }));
|
|
139
144
|
}
|
package/dist/format.d.ts
CHANGED
|
@@ -3,6 +3,16 @@ import type { BookableService, BookableVariant } from "./types.js";
|
|
|
3
3
|
export declare function formatDuration(minutes: number): string;
|
|
4
4
|
/** The browser's IANA timezone, else UTC. */
|
|
5
5
|
export declare function browserTimezone(): string;
|
|
6
|
+
/**
|
|
7
|
+
* `YYYY-MM-DD` of a calendar day the visitor picked (a `Date` at local
|
|
8
|
+
* midnight), read from its own fields. Not `localDate`: that converts an
|
|
9
|
+
* instant into a timezone, and a day picked at midnight in Shanghai is the
|
|
10
|
+
* evening before in Johannesburg, so the slots came out one day early for
|
|
11
|
+
* every visitor east of the business (measured 2026-10-10).
|
|
12
|
+
*/
|
|
13
|
+
export declare function calendarDate(day: Date): string;
|
|
14
|
+
/** `Thu, Oct 15, 2026` of a picked calendar day, from its own fields. */
|
|
15
|
+
export declare function formatCalendarDay(day: Date, locale?: string): string;
|
|
6
16
|
/** `YYYY-MM-DD` of an instant, in a timezone. */
|
|
7
17
|
export declare function localDate(date: Date, timezone: string): string;
|
|
8
18
|
/** `3:30 PM` (or the locale's form) of an instant, in a timezone. */
|
package/dist/format.js
CHANGED
|
@@ -16,6 +16,21 @@ export function browserTimezone() {
|
|
|
16
16
|
return "UTC";
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* `YYYY-MM-DD` of a calendar day the visitor picked (a `Date` at local
|
|
21
|
+
* midnight), read from its own fields. Not `localDate`: that converts an
|
|
22
|
+
* instant into a timezone, and a day picked at midnight in Shanghai is the
|
|
23
|
+
* evening before in Johannesburg, so the slots came out one day early for
|
|
24
|
+
* every visitor east of the business (measured 2026-10-10).
|
|
25
|
+
*/
|
|
26
|
+
export function calendarDate(day) {
|
|
27
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
28
|
+
return `${day.getFullYear()}-${pad(day.getMonth() + 1)}-${pad(day.getDate())}`;
|
|
29
|
+
}
|
|
30
|
+
/** `Thu, Oct 15, 2026` of a picked calendar day, from its own fields. */
|
|
31
|
+
export function formatCalendarDay(day, locale) {
|
|
32
|
+
return new Intl.DateTimeFormat(locale, { weekday: "short", month: "short", day: "numeric", year: "numeric" }).format(day);
|
|
33
|
+
}
|
|
19
34
|
/** `YYYY-MM-DD` of an instant, in a timezone. */
|
|
20
35
|
export function localDate(date, timezone) {
|
|
21
36
|
const parts = new Intl.DateTimeFormat("en-CA", { timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit" }).formatToParts(date);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
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
|
-
export { browserTimezone, defaultVariant, formatDateTime, formatDay, formatDayParts, formatDuration, formatTime, formatTimezone, localDate, servicePriceLabel, variantPriceLabel, } from "./format.js";
|
|
3
|
+
export { browserTimezone, defaultVariant, formatDateTime, formatDay, formatDayParts, formatDuration, formatTime, formatTimezone, localDate, servicePriceLabel, variantPriceLabel, calendarDate, formatCalendarDay, } from "./format.js";
|
|
4
4
|
export { calendarLabels, defaultCalendarLabels, type CalendarLabels } from "./labels.js";
|
|
5
5
|
export { ServiceCard, ServiceList, type ServiceCardProps, type ServiceListProps } from "./components/service-list.js";
|
|
6
6
|
export { DateTimePicker, calendarClassNames, type DateTimePickerProps } from "./components/date-time-picker.js";
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// Tailwind build scans it (see @doany-ai/app-core/tailwind-preset).
|
|
8
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
|
-
export { browserTimezone, defaultVariant, formatDateTime, formatDay, formatDayParts, formatDuration, formatTime, formatTimezone, localDate, servicePriceLabel, variantPriceLabel, } from "./format.js";
|
|
10
|
+
export { browserTimezone, defaultVariant, formatDateTime, formatDay, formatDayParts, formatDuration, formatTime, formatTimezone, localDate, servicePriceLabel, variantPriceLabel, calendarDate, formatCalendarDay, } from "./format.js";
|
|
11
11
|
export { calendarLabels, defaultCalendarLabels } from "./labels.js";
|
|
12
12
|
export { ServiceCard, ServiceList } from "./components/service-list.js";
|
|
13
13
|
export { DateTimePicker, calendarClassNames } from "./components/date-time-picker.js";
|
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.3",
|
|
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,
|