From 598034332b85fc1d024ea45443865e46b606ce25 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:26:05 +0000 Subject: [PATCH 01/24] Revert "improve transition" This reverts commit 527060a9559df46f7c814abd3554089b35914b70. --- apps/ui/registry/default/atoms/booker-1.tsx | 2 +- .../ui/registry/default/ui/size-transition.tsx | 18 +++++------------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/apps/ui/registry/default/atoms/booker-1.tsx b/apps/ui/registry/default/atoms/booker-1.tsx index 9a7fc16db..7baae65f2 100644 --- a/apps/ui/registry/default/atoms/booker-1.tsx +++ b/apps/ui/registry/default/atoms/booker-1.tsx @@ -147,7 +147,7 @@ export function Booker({ initialData, target, timezone, labels }: BookerProps) { +
} > {booker.step === "select" ? ( diff --git a/apps/ui/registry/default/ui/size-transition.tsx b/apps/ui/registry/default/ui/size-transition.tsx index 0432e194c..524e99537 100644 --- a/apps/ui/registry/default/ui/size-transition.tsx +++ b/apps/ui/registry/default/ui/size-transition.tsx @@ -128,9 +128,8 @@ export function SizeTransition({ React.useState(null); const [previousSize, setPreviousSize] = React.useState(null); const [startingStyle, setStartingStyle] = React.useState(false); - const [sizeTransitioning, setSizeTransitioning] = React.useState(false); - const hasPreviousLayer = previousContent !== null; + const isTransitioning = previousContent !== null; useIsoLayoutEffect(() => { const element = rootRef.current; @@ -153,8 +152,6 @@ export function SizeTransition({ const outgoingContent = snapshotRef.current; const previousSizeValue = committedSizeRef.current; - setSizeTransitioning(true); - if (outgoingContent) { setPreviousContent(outgoingContent); setStartingStyle(true); @@ -194,7 +191,6 @@ export function SizeTransition({ () => { releaseSize(element); currentRef.current?.style.removeProperty("width"); - setSizeTransitioning(false); }, sizeAbort.signal, ); @@ -241,15 +237,11 @@ export function SizeTransition({ }); const contextValue = React.useMemo( - () => ({ - currentRef, - startingStyle, - transitioning: hasPreviousLayer || sizeTransitioning, - }), - [hasPreviousLayer, sizeTransitioning, startingStyle], + () => ({ currentRef, startingStyle, transitioning: isTransitioning }), + [isTransitioning, startingStyle], ); - const content = hasPreviousLayer ? ( + const content = isTransitioning ? ( {children} {/* After `children` so inserting this layer never remounts the panel. */} @@ -283,7 +275,7 @@ export function SizeTransition({ props: mergeProps<"div">(defaultProps, props, { children: content }), render, ref: rootRef, - state: { transitioning: sizeTransitioning }, + state: { transitioning: isTransitioning }, stateAttributesMapping: { transitioning: (value) => (value ? { "data-transitioning": "" } : null), }, From 48feaf4aad5920e0c49731621da8f2dcf5081a27 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:26:05 +0000 Subject: [PATCH 02/24] Revert "size transition component" This reverts commit f22fbde7ea00e3080534a0ff3bb41fd96b83b16f. --- apps/ui/public/r/booker-1.json | 10 +- apps/ui/public/r/registry.json | 16 - apps/ui/public/r/size-transition.json | 15 - apps/ui/public/r/ui.json | 1 - apps/ui/registry.json | 16 - apps/ui/registry/__index__.tsx | 20 +- apps/ui/registry/default/atoms/booker-1.tsx | 27 +- .../registry/default/ui/size-transition.tsx | 318 ------------------ apps/ui/registry/registry-atoms.ts | 1 - apps/ui/registry/registry-ui.ts | 12 - 10 files changed, 11 insertions(+), 425 deletions(-) delete mode 100644 apps/ui/public/r/size-transition.json delete mode 100644 apps/ui/registry/default/ui/size-transition.tsx diff --git a/apps/ui/public/r/booker-1.json b/apps/ui/public/r/booker-1.json index bb29c4871..0b6431ef9 100644 --- a/apps/ui/public/r/booker-1.json +++ b/apps/ui/public/r/booker-1.json @@ -14,12 +14,10 @@ "@coss/card", "@coss/combobox", "@coss/empty", - "@coss/input", "@coss/label", "@coss/popover", "@coss/scroll-area", "@coss/select", - "@coss/size-transition", "@coss/skeleton", "@coss/switch", "@coss/tooltip" @@ -27,13 +25,13 @@ "files": [ { "path": "registry/default/atoms/booker-1.tsx", - "content": "\"use client\";\n\nimport { ArrowLeftIcon, Clock3Icon } from \"lucide-react\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport { Card } from \"@/registry/default/ui/card\";\nimport { Input } from \"@/registry/default/ui/input\";\nimport { SizeTransition } from \"@/registry/default/ui/size-transition\";\nimport { Skeleton } from \"@/registry/default/ui/skeleton\";\nimport { BookerAvatars } from \"./booker/booker-avatars\";\nimport { BookerCalendar } from \"./booker/booker-calendar\";\nimport { BookerErrorState } from \"./booker/booker-error-state\";\nimport { type BookerLabels, getBookerLabels } from \"./booker/booker-labels\";\nimport { DurationPicker } from \"./booker/duration-picker\";\nimport { EventDescription } from \"./booker/event-description\";\nimport { HeaderBanner } from \"./booker/header-banner\";\nimport { Location } from \"./booker/location\";\nimport { TimePicker } from \"./booker/time-picker\";\nimport { TimezonePicker } from \"./booker/timezone-picker\";\nimport type { BookerTarget } from \"@/lib/booker/target\";\nimport { type BookerInitialData, useBooker } from \"@/lib/booker/use-booker\";\n\ntype BookerProps = {\n target: BookerTarget;\n timezone?: string;\n initialData?: BookerInitialData;\n defaultFormValues?: Record;\n onCreateBookingSuccess?: (data: unknown) => void;\n labels?: Partial;\n};\n\nexport function Booker({ initialData, target, timezone, labels }: BookerProps) {\n const t = getBookerLabels(labels);\n const booker = useBooker({ initialData, target, timezone });\n\n if (booker.error) {\n return (\n \n );\n }\n\n const displayMeta = booker.meta;\n const headerImageAlt = displayMeta\n ? t.headerImageAlt(displayMeta.hostName, displayMeta.eventTypeTitle)\n : \"Booker header\";\n const metaContentClassName = [\n displayMeta?.eventTypeImageUrl ? \"relative -mt-11 @5xl:-mt-13\" : \"\",\n \"flex flex-col gap-6 @5xl:p-6 p-4\",\n ]\n .filter(Boolean)\n .join(\" \");\n\n return (\n \n \n {/* Meta */}\n
\n \n
\n
\n
\n \n {displayMeta ? (\n

\n {displayMeta.hostName}\n

\n ) : (\n \n )}\n
\n
\n {displayMeta ? (\n <>\n

\n {displayMeta.eventTypeTitle}\n

\n \n \n ) : (\n <>\n \n \n \n \n )}\n
\n
\n\n {displayMeta ? (\n
\n {displayMeta.eventTypeDurationOptions ? (\n \n ) : (\n
\n \n \n {displayMeta.eventTypeDurationMinutes\n ? t.durationMinutes(\n displayMeta.eventTypeDurationMinutes,\n )\n : t.durationUnknown}\n \n
\n )}\n \n \n
\n ) : (\n
\n \n \n \n
\n )}\n
\n
\n \n }\n >\n {booker.step === \"select\" ? (\n
\n {/* Calendar */}\n
\n \n
\n {/* Time picker */}\n \n
\n ) : (\n
\n \n \n
\n )}\n \n
\n \n Cal.com\n \n
\n );\n}\n\nexport default Booker;\n", + "content": "\"use client\";\n\nimport { Clock3Icon } from \"lucide-react\";\nimport { Card } from \"@/registry/default/ui/card\";\nimport { Skeleton } from \"@/registry/default/ui/skeleton\";\nimport { BookerAvatars } from \"./booker/booker-avatars\";\nimport { BookerCalendar } from \"./booker/booker-calendar\";\nimport { BookerErrorState } from \"./booker/booker-error-state\";\nimport { type BookerLabels, getBookerLabels } from \"./booker/booker-labels\";\nimport { DurationPicker } from \"./booker/duration-picker\";\nimport { EventDescription } from \"./booker/event-description\";\nimport { HeaderBanner } from \"./booker/header-banner\";\nimport { Location } from \"./booker/location\";\nimport { TimePicker } from \"./booker/time-picker\";\nimport { TimezonePicker } from \"./booker/timezone-picker\";\nimport type { BookerTarget } from \"@/lib/booker/target\";\nimport { type BookerInitialData, useBooker } from \"@/lib/booker/use-booker\";\n\ntype BookerProps = {\n target: BookerTarget;\n timezone?: string;\n initialData?: BookerInitialData;\n defaultFormValues?: Record;\n onCreateBookingSuccess?: (data: unknown) => void;\n labels?: Partial;\n};\n\nexport function Booker({ initialData, target, timezone, labels }: BookerProps) {\n const t = getBookerLabels(labels);\n const booker = useBooker({ initialData, target, timezone });\n\n if (booker.error) {\n return (\n \n );\n }\n\n const displayMeta = booker.meta;\n const headerImageAlt = displayMeta\n ? t.headerImageAlt(displayMeta.hostName, displayMeta.eventTypeTitle)\n : \"Booker header\";\n const metaContentClassName = [\n displayMeta?.eventTypeImageUrl ? \"relative -mt-11 @5xl:-mt-13\" : \"\",\n \"flex flex-col gap-6 @5xl:p-6 p-4\",\n ]\n .filter(Boolean)\n .join(\" \");\n\n return (\n \n \n {/* Meta */}\n
\n \n
\n
\n
\n \n {displayMeta ? (\n

\n {displayMeta.hostName}\n

\n ) : (\n \n )}\n
\n
\n {displayMeta ? (\n <>\n

\n {displayMeta.eventTypeTitle}\n

\n \n \n ) : (\n <>\n \n \n \n \n )}\n
\n
\n\n {displayMeta ? (\n
\n {displayMeta.eventTypeDurationOptions ? (\n \n ) : (\n
\n \n \n {displayMeta.eventTypeDurationMinutes\n ? t.durationMinutes(\n displayMeta.eventTypeDurationMinutes,\n )\n : t.durationUnknown}\n \n
\n )}\n \n \n
\n ) : (\n
\n \n \n \n
\n )}\n
\n
\n {/* Calendar */}\n
\n \n
\n {/* Time picker */}\n \n
\n \n Cal.com\n \n \n );\n}\n\nexport default Booker;\n", "type": "registry:block", "target": "components/atoms/booker-1.tsx" }, { "path": "registry/default/atoms/booker/booker-calendar.tsx", - "content": "\"use client\";\n\nimport { ChevronLeftIcon, ChevronRightIcon } from \"lucide-react\";\nimport {\n type ComponentProps,\n type KeyboardEvent,\n type MouseEvent,\n type ReactElement,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport { cn } from \"@/registry/default/lib/utils\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport { Skeleton } from \"@/registry/default/ui/skeleton\";\nimport {\n Tooltip,\n TooltipCreateHandle,\n TooltipPopup,\n TooltipProvider,\n TooltipTrigger,\n} from \"@/registry/default/ui/tooltip\";\n\n// A single shared tooltip reused across every day cell (via the handle/payload\n// pattern). Reusing one instance lets it gracefully move/scale between days\n// instead of mounting a separate tooltip per cell.\nconst monthTooltipHandle = TooltipCreateHandle();\n\n// Falls back to English (US) when no `locale` prop is provided.\nconst DEFAULT_LOCALE = \"en-US\";\n\ntype CalendarLabels = {\n /** ARIA label for the previous-month navigation button. */\n previousMonth: string;\n /** ARIA label for the next-month navigation button. */\n nextMonth: string;\n /** ARIA label for the navigation toolbar. */\n nav: string;\n /** Prefix for today's day button label, e.g. \"Today, Monday, …\". */\n today: string;\n /** Suffix for the selected day button label, e.g. \"…, selected\". */\n selected: string;\n};\n\n// English defaults. Localize by passing translated strings via the `labels`\n// prop (wire it up to your app's own i18n layer); date/weekday/month names are\n// still localized automatically from `locale` via `Intl`.\nconst DEFAULT_LABELS: CalendarLabels = {\n previousMonth: \"Go to the Previous Month\",\n nextMonth: \"Go to the Next Month\",\n nav: \"Navigation bar\",\n today: \"Today\",\n selected: \"selected\",\n};\n\n// Resolves the text direction for the locale (e.g. \"rtl\" for Arabic/Hebrew).\nfunction getTextDirection(locale: string): \"ltr\" | \"rtl\" {\n try {\n const localeObj = new Intl.Locale(locale) as Intl.Locale & {\n textInfo?: { direction?: string };\n getTextInfo?: () => { direction?: string };\n };\n const info =\n typeof localeObj.getTextInfo === \"function\"\n ? localeObj.getTextInfo()\n : localeObj.textInfo;\n if (info?.direction === \"rtl\") {\n return \"rtl\";\n }\n } catch {\n // Fall through to the ltr default below.\n }\n return \"ltr\";\n}\n\n// Locale-aware formatting/ordering, derived from a BCP-47 locale string.\ntype Localization = {\n weekStartsOn: number;\n monthYearLabel: (date: Date) => string;\n fullDateLabel: (date: Date) => string;\n monthShortLabel: (date: Date) => string;\n monthLongLabel: (date: Date) => string;\n weekdayLong: (date: Date) => string;\n weekdayShort: (date: Date) => string;\n dayNumber: (date: Date) => string;\n captionFormatter: Intl.DateTimeFormat;\n};\n\n// 1 = Monday … 7 = Sunday (ISO) → JS getDay() index (0 = Sunday … 6 = Saturday).\nfunction getWeekStartsOn(locale: string): number {\n try {\n const localeObj = new Intl.Locale(locale) as Intl.Locale & {\n weekInfo?: { firstDay?: number };\n getWeekInfo?: () => { firstDay?: number };\n };\n const info =\n typeof localeObj.getWeekInfo === \"function\"\n ? localeObj.getWeekInfo()\n : localeObj.weekInfo;\n const firstDay = info?.firstDay;\n if (typeof firstDay === \"number\") {\n return firstDay % 7;\n }\n } catch {\n // Fall through to the Sunday default below.\n }\n return 0;\n}\n\nfunction buildLocalization(\n locale: string,\n weekStartsOnOverride?: number,\n): Localization {\n const longDateFormatter = new Intl.DateTimeFormat(locale, {\n weekday: \"long\",\n year: \"numeric\",\n month: \"long\",\n day: \"numeric\",\n });\n const monthYearFormatter = new Intl.DateTimeFormat(locale, {\n month: \"long\",\n year: \"numeric\",\n });\n const monthShortFormatter = new Intl.DateTimeFormat(locale, {\n month: \"short\",\n });\n const monthLongFormatter = new Intl.DateTimeFormat(locale, {\n month: \"long\",\n });\n const weekdayLongFormatter = new Intl.DateTimeFormat(locale, {\n weekday: \"long\",\n });\n const weekdayShortFormatter = new Intl.DateTimeFormat(locale, {\n weekday: \"short\",\n });\n const dayFormatter = new Intl.DateTimeFormat(locale, { day: \"numeric\" });\n\n return {\n weekStartsOn: weekStartsOnOverride ?? getWeekStartsOn(locale),\n monthYearLabel: (date) => monthYearFormatter.format(date),\n fullDateLabel: (date) => longDateFormatter.format(date),\n monthShortLabel: (date) => monthShortFormatter.format(date),\n monthLongLabel: (date) => monthLongFormatter.format(date),\n weekdayLong: (date) => weekdayLongFormatter.format(date),\n weekdayShort: (date) => weekdayShortFormatter.format(date),\n dayNumber: (date) => dayFormatter.format(date),\n captionFormatter: monthYearFormatter,\n };\n}\n\nfunction pad2(value: number): string {\n return value < 10 ? `0${value}` : String(value);\n}\n\nfunction isoDate(date: Date): string {\n return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;\n}\n\nfunction isoMonth(date: Date): string {\n return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}`;\n}\n\nfunction startOfDay(date: Date): Date {\n return new Date(date.getFullYear(), date.getMonth(), date.getDate());\n}\n\nfunction startOfMonth(date: Date): Date {\n return new Date(date.getFullYear(), date.getMonth(), 1);\n}\n\nfunction endOfMonth(date: Date): Date {\n return new Date(date.getFullYear(), date.getMonth() + 1, 0);\n}\n\nfunction addDays(date: Date, amount: number): Date {\n return new Date(date.getFullYear(), date.getMonth(), date.getDate() + amount);\n}\n\nfunction addMonths(date: Date, amount: number): Date {\n return new Date(date.getFullYear(), date.getMonth() + amount, date.getDate());\n}\n\nfunction startOfWeek(date: Date, weekStartsOn = 0): Date {\n const day = startOfDay(date);\n const diff = (day.getDay() - weekStartsOn + 7) % 7;\n return addDays(day, -diff);\n}\n\nfunction endOfWeek(date: Date, weekStartsOn = 0): Date {\n return addDays(startOfWeek(date, weekStartsOn), 6);\n}\n\nfunction isSameDay(a: Date, b: Date): boolean {\n return (\n a.getFullYear() === b.getFullYear() &&\n a.getMonth() === b.getMonth() &&\n a.getDate() === b.getDate()\n );\n}\n\nfunction isSameMonth(a: Date, b: Date): boolean {\n return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();\n}\n\nfunction differenceInCalendarMonths(a: Date, b: Date): number {\n return (\n (a.getFullYear() - b.getFullYear()) * 12 + (a.getMonth() - b.getMonth())\n );\n}\n\ntype CalendarDay = {\n date: Date;\n outside: boolean;\n isoDate: string;\n dateMonthId: string;\n};\n\ntype DayModifiers = {\n focused: boolean;\n disabled: boolean;\n hidden: boolean;\n outside: boolean;\n today: boolean;\n selected: boolean;\n};\n\n// Builds the month grid as full weeks covering the month.\n//\n// `shiftRows` slides the window forward by that many weeks, dropping the\n// month's earliest week(s). When `fillToSix` is set the grid is then padded to\n// a full 6 rows with days from the next month. Together they keep today's week\n// plus the next two visible without exceeding 6 rows; with both at their\n// defaults the month spans only the weeks it naturally needs.\nfunction buildCalendarDays(\n month: Date,\n weekStartsOn: number,\n shiftRows: number,\n fillToSix: boolean,\n): CalendarDay[] {\n const monthStart = startOfMonth(month);\n const offset = shiftRows * 7;\n const gridStart = addDays(startOfWeek(monthStart, weekStartsOn), offset);\n const gridEnd = addDays(endOfWeek(endOfMonth(month), weekStartsOn), offset);\n\n const dates: Date[] = [];\n for (let cursor = gridStart; cursor <= gridEnd; cursor = addDays(cursor, 1)) {\n dates.push(cursor);\n }\n\n if (fillToSix) {\n while (dates.length < 42) {\n const lastDate = dates.at(-1);\n if (!lastDate) {\n break;\n }\n dates.push(addDays(lastDate, 1));\n }\n }\n\n return dates.map((date) => ({\n date,\n outside: !isSameMonth(date, month),\n isoDate: isoDate(date),\n dateMonthId: isoMonth(date),\n }));\n}\n\nfunction chunkWeeks(days: CalendarDay[]): CalendarDay[][] {\n const weeks: CalendarDay[][] = [];\n for (let i = 0; i < days.length; i += 7) {\n weeks.push(days.slice(i, i + 7));\n }\n return weeks;\n}\n\nfunction isFocusableDay(modifiers: DayModifiers): boolean {\n return !modifiers.disabled && !modifiers.hidden && !modifiers.outside;\n}\n\n// A single day cell's button. Focuses itself when it becomes the roving-tabindex\n// target so keyboard navigation moves the actual DOM focus.\nfunction DayButton({\n focused,\n nextMonthLabel,\n tooltip,\n children,\n ...buttonProps\n}: ComponentProps<\"button\"> & {\n focused: boolean;\n nextMonthLabel?: string;\n tooltip?: string;\n}): ReactElement {\n const ref = useRef(null);\n\n useEffect(() => {\n if (focused) ref.current?.focus();\n }, [focused]);\n\n const button = (\n \n );\n\n if (!tooltip) return button;\n\n return (\n \n );\n}\n\n// Month + year heading; the year is muted via formatToParts so it stays\n// locale-correct (some locales render \"2026 June\").\nfunction MonthCaption({\n date,\n formatter,\n className,\n}: {\n date: Date;\n formatter: Intl.DateTimeFormat;\n className?: string;\n}): ReactElement {\n return (\n \n {formatter.formatToParts(date).map((part) =>\n part.type === \"year\" ? (\n \n {part.value}\n \n ) : (\n part.value\n ),\n )}\n \n );\n}\n\ntype MoveBy = \"day\" | \"week\" | \"month\" | \"year\" | \"startOfWeek\" | \"endOfWeek\";\ntype MoveDir = \"before\" | \"after\";\n\nexport type BookerCalendarProps = {\n className?: string;\n /**\n * For the current month, once today reaches the 4th row, keep its week plus\n * the next two visible within the 6-row grid: extend into next-month days,\n * and slide the window down (dropping early weeks) only when today sits too\n * low for those two weeks to fit. Off renders a plain month grid. On by\n * default.\n */\n shiftWeeks?: boolean;\n availabilityLoading?: boolean;\n initialLoading?: boolean;\n month?: Date;\n defaultMonth?: Date;\n startMonth?: Date;\n endMonth?: Date;\n selected?: Date;\n onSelect?: (date: Date | undefined, triggerDate: Date) => void;\n onMonthChange?: (month: Date) => void;\n disabled?: (date: Date) => boolean;\n /** BCP-47 locale tag used to localize day/month/weekday names and ARIA labels. */\n locale?: string;\n /** Override the locale's first day of the week (0 = Sunday … 6 = Saturday). */\n weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;\n /** Text direction; defaults to the locale's direction (\"rtl\" for ar/he/…). */\n dir?: \"ltr\" | \"rtl\";\n /** Translatable strings for the navigation buttons and day button states. */\n labels?: Partial;\n /** Reference \"today\" for highlighting and week shifting; defaults to now. */\n today?: Date;\n};\n\nexport function BookerCalendar({\n className,\n shiftWeeks = true,\n availabilityLoading = false,\n initialLoading = false,\n month,\n defaultMonth,\n startMonth,\n endMonth,\n selected,\n onSelect,\n onMonthChange,\n disabled,\n locale = DEFAULT_LOCALE,\n weekStartsOn,\n dir,\n labels,\n today: todayProp,\n}: BookerCalendarProps): ReactElement {\n const dayCellsLoading = initialLoading || availabilityLoading;\n const localization = useMemo(\n () => buildLocalization(locale, weekStartsOn),\n [locale, weekStartsOn],\n );\n const resolvedLabels = { ...DEFAULT_LABELS, ...labels };\n const resolvedDir = dir ?? getTextDirection(locale);\n const weekStart = localization.weekStartsOn;\n\n const today = todayProp ?? new Date();\n\n // The month is controlled by the parent (via `month`/`onMonthChange`), but we\n // keep an internal fallback so the calendar works uncontrolled too.\n const [internalMonth, setInternalMonth] = useState(() =>\n startOfMonth(month ?? defaultMonth ?? today),\n );\n const monthTime = month ? startOfMonth(month).getTime() : undefined;\n useEffect(() => {\n if (monthTime !== undefined) {\n setInternalMonth(new Date(monthTime));\n }\n }, [monthTime]);\n\n const firstMonth = month ? startOfMonth(month) : internalMonth;\n\n const [focusedDate, setFocusedDate] = useState(undefined);\n const [lastFocusedDate, setLastFocusedDate] = useState(\n undefined,\n );\n\n const navStart = startMonth ? startOfMonth(startMonth) : undefined;\n const navEnd = endMonth ? startOfMonth(endMonth) : undefined;\n\n const previousMonth =\n navStart && differenceInCalendarMonths(firstMonth, navStart) <= 0\n ? undefined\n : addMonths(firstMonth, -1);\n const nextMonth =\n navEnd && differenceInCalendarMonths(navEnd, firstMonth) <= 0\n ? undefined\n : addMonths(firstMonth, 1);\n\n const goToMonth = useCallback(\n (date: Date) => {\n let newMonth = startOfMonth(date);\n if (navStart && newMonth < navStart) {\n newMonth = navStart;\n }\n if (navEnd && newMonth > navEnd) {\n newMonth = navEnd;\n }\n setInternalMonth(newMonth);\n onMonthChange?.(newMonth);\n },\n [navEnd, navStart, onMonthChange],\n );\n\n // For the live current month, once today reaches the 4th row, guarantee its\n // week plus the next two are visible. todayRow is today's 0-based row in the\n // natural (unshifted) grid.\n const naturalGridStart = startOfWeek(startOfMonth(firstMonth), weekStart);\n const todayRow = Math.floor(\n Math.round(\n (startOfDay(today).getTime() - naturalGridStart.getTime()) / 86_400_000,\n ) / 7,\n );\n const expand = shiftWeeks && isSameMonth(firstMonth, today) && todayRow >= 3;\n // Extend downward first; only drop early weeks (shift) when today sits so low\n // that the two trailing weeks wouldn't fit within the 6-row cap.\n const shiftRows = expand ? Math.max(0, todayRow - 3) : 0;\n\n const days = buildCalendarDays(firstMonth, weekStart, shiftRows, expand);\n const weeks = chunkWeeks(days);\n\n // Reserve any missing rows up to 6 (the most any month needs) with empty\n // placeholders, so the grid keeps a constant height across navigation. This\n // is 0 when expanding, since those months already fill all 6 rows with days.\n const placeholderRows = Math.max(0, 6 - weeks.length);\n\n const isSelected = (date: Date): boolean =>\n selected ? isSameDay(selected, date) : false;\n\n const getModifiers = (day: CalendarDay): DayModifiers => {\n const { date, outside } = day;\n const isBeforeNavStart = Boolean(navStart && date < navStart);\n const isAfterNavEnd = Boolean(navEnd && date > endOfMonth(navEnd));\n const isDisabled = disabled ? disabled(date) : false;\n // Leading previous-month days are never shown (past days aren't relevant).\n // Next-month days only belong in the grid while expanding; otherwise the\n // empty placeholder rows reserve their space instead of spilling over.\n const isPrevMonthDay = date < startOfMonth(firstMonth);\n const isNextMonthDay = date > endOfMonth(firstMonth);\n const isHidden =\n isBeforeNavStart ||\n isAfterNavEnd ||\n isPrevMonthDay ||\n (!expand && isNextMonthDay);\n const isToday = isSameDay(date, today);\n const isFocused =\n !isHidden &&\n !outside &&\n focusedDate !== undefined &&\n isSameDay(date, focusedDate);\n\n return {\n focused: isFocused,\n disabled: isDisabled,\n hidden: isHidden,\n outside,\n today: isToday,\n selected: isSelected(date),\n };\n };\n\n // Roving tabindex target. Priority: focused > last focused > selected > today\n // > first focusable day.\n const focusTarget = ((): CalendarDay | undefined => {\n let target: CalendarDay | undefined;\n let priority = -1;\n for (const day of days) {\n const modifiers = getModifiers(day);\n if (!isFocusableDay(modifiers)) continue;\n if (modifiers.focused && priority < 3) {\n target = day;\n priority = 3;\n } else if (\n lastFocusedDate &&\n isSameDay(day.date, lastFocusedDate) &&\n priority < 2\n ) {\n target = day;\n priority = 2;\n } else if (isSelected(day.date) && priority < 1) {\n target = day;\n priority = 1;\n } else if (modifiers.today && priority < 0) {\n target = day;\n priority = 0;\n }\n }\n if (!target) {\n target = days.find((day) => isFocusableDay(getModifiers(day)));\n }\n return target;\n })();\n\n // While expanding, mark the 1st of the next month (the first visible day past\n // the current month's end) with the month label.\n const monthEnd = endOfMonth(firstMonth);\n const nextMonthLabelIso = expand\n ? days.find((day) => {\n const modifiers = getModifiers(day);\n return day.date > monthEnd && !modifiers.hidden;\n })?.isoDate\n : undefined;\n\n const getFocusableDate = (\n moveBy: MoveBy,\n moveDir: MoveDir,\n refDate: Date,\n ): Date => {\n const delta = moveDir === \"after\" ? 1 : -1;\n let result: Date;\n switch (moveBy) {\n case \"day\":\n result = addDays(refDate, delta);\n break;\n case \"week\":\n result = addDays(refDate, delta * 7);\n break;\n case \"month\":\n result = addMonths(refDate, delta);\n break;\n case \"year\":\n result = addMonths(refDate, delta * 12);\n break;\n case \"startOfWeek\":\n result = startOfWeek(refDate, weekStart);\n break;\n case \"endOfWeek\":\n result = endOfWeek(refDate, weekStart);\n break;\n }\n if (moveDir === \"before\" && navStart && result < navStart) {\n result = navStart;\n }\n if (moveDir === \"after\" && navEnd && result > endOfMonth(navEnd)) {\n result = endOfMonth(navEnd);\n }\n return result;\n };\n\n const getNextFocus = (\n moveBy: MoveBy,\n moveDir: MoveDir,\n refDate: Date,\n attempt = 0,\n ): Date | undefined => {\n if (attempt > 365) return undefined;\n const next = getFocusableDate(moveBy, moveDir, refDate);\n const isDisabled = disabled ? disabled(next) : false;\n if (!isDisabled) return next;\n return getNextFocus(moveBy, moveDir, next, attempt + 1);\n };\n\n const moveFocus = (moveBy: MoveBy, moveDir: MoveDir) => {\n if (!focusedDate) return;\n const next = getNextFocus(moveBy, moveDir, focusedDate);\n if (!next) return;\n if (!isSameMonth(next, firstMonth)) {\n goToMonth(next);\n }\n setFocusedDate(next);\n };\n\n const handleDayClick =\n (day: CalendarDay, modifiers: DayModifiers) => (event: MouseEvent) => {\n event.preventDefault();\n event.stopPropagation();\n setFocusedDate(day.date);\n if (modifiers.disabled) return;\n const newDate =\n selected && isSameDay(day.date, selected) ? undefined : day.date;\n onSelect?.(newDate, day.date);\n };\n\n const handleDayFocus = (day: CalendarDay) => () => {\n setFocusedDate(day.date);\n };\n\n const handleDayBlur = () => {\n setLastFocusedDate(focusedDate);\n setFocusedDate(undefined);\n };\n\n const handleDayKeyDown = (event: KeyboardEvent) => {\n const keyMap: Record = {\n ArrowLeft: [event.shiftKey ? \"month\" : \"day\", \"before\"],\n ArrowRight: [event.shiftKey ? \"month\" : \"day\", \"after\"],\n ArrowDown: [event.shiftKey ? \"year\" : \"week\", \"after\"],\n ArrowUp: [event.shiftKey ? \"year\" : \"week\", \"before\"],\n PageUp: [event.shiftKey ? \"year\" : \"month\", \"before\"],\n PageDown: [event.shiftKey ? \"year\" : \"month\", \"after\"],\n Home: [\"startOfWeek\", \"before\"],\n End: [\"endOfWeek\", \"after\"],\n };\n const move = keyMap[event.key];\n if (move) {\n event.preventDefault();\n event.stopPropagation();\n const [moveBy, moveDir] = move;\n moveFocus(moveBy, moveDir);\n }\n };\n\n const weekdayHeaderStart = startOfWeek(today, weekStart);\n const weekdays = Array.from({ length: 7 }, (_, index) => {\n const date = addDays(weekdayHeaderStart, index);\n return {\n long: localization.weekdayLong(date),\n short: localization.weekdayShort(date),\n };\n });\n\n return (\n \n
\n
\n {localization.monthYearLabel(firstMonth)}\n
\n
\n {initialLoading ? (\n \n ) : (\n <>\n \n \n {\n if (previousMonth) goToMonth(previousMonth);\n }}\n >\n \n \n {\n if (nextMonth) goToMonth(nextMonth);\n }}\n >\n \n \n \n \n )}\n
\n \n \n \n \n {weekdays.map((weekday) => (\n \n {initialLoading ? (\n \n ) : (\n weekday.short\n )}\n \n ))}\n \n \n \n {weeks.map((week) => (\n \n {week.map((day) => {\n const modifiers = getModifiers(day);\n\n // The day button's own styling is driven by the `data-*`\n // attributes below; these cell classes only cover the\n // layout and the outside/hidden/today states.\n const cellClassName = cn(\n \"@max-3xl:max-h-9 flex-1 p-0\",\n modifiers.hidden && \"invisible\",\n modifiers.outside && \"text-muted-foreground/50\",\n modifiers.today &&\n !dayCellsLoading &&\n \"*:before:pointer-events-none *:before:absolute *:before:start-1/2 *:before:bottom-1/7 *:before:z-1 *:before:size-1 *:before:-translate-x-1/2 *:before:rounded-full *:before:bg-primary *:before:transition-colors [&[data-selected]>*]:before:bg-primary-foreground\",\n );\n\n // Label (e.g. \"Aug\") sits on the 1st of the next month.\n const nextMonthLabel =\n day.isoDate === nextMonthLabelIso\n ? localization.monthShortLabel(day.date)\n : undefined;\n\n // Next-month days only show in the shifted current-month\n // view; a month-name tooltip keeps them from being mistaken\n // for the current month.\n const monthTooltip =\n day.outside && !modifiers.disabled && !modifiers.hidden\n ? localization.monthLongLabel(day.date)\n : undefined;\n\n return (\n \n {modifiers.hidden ? null : dayCellsLoading ? (\n \n \n \n ) : (\n {\n let label = localization.fullDateLabel(day.date);\n if (modifiers.today) {\n label = `${resolvedLabels.today}, ${label}`;\n }\n if (modifiers.selected) {\n label = `${label}, ${resolvedLabels.selected}`;\n }\n return label;\n })()}\n onClick={handleDayClick(day, modifiers)}\n onBlur={handleDayBlur}\n onFocus={handleDayFocus(day)}\n onKeyDown={handleDayKeyDown}\n >\n {localization.dayNumber(day.date)}\n \n )}\n \n );\n })}\n \n ))}\n {Array.from(\n { length: placeholderRows },\n (_, rowIndex) => `placeholder-row-${rowIndex}`,\n ).map((rowKey) => (\n \n {weekdays.map((weekday) => (\n \n
\n \n ))}\n \n ))}\n \n \n \n {({ payload }) => (\n \n {payload}\n \n )}\n \n \n
\n
\n );\n}\n", + "content": "\"use client\";\n\nimport { ChevronLeftIcon, ChevronRightIcon } from \"lucide-react\";\nimport {\n type ComponentProps,\n type KeyboardEvent,\n type MouseEvent,\n type ReactElement,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport { cn } from \"@/registry/default/lib/utils\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport { Skeleton } from \"@/registry/default/ui/skeleton\";\nimport {\n Tooltip,\n TooltipCreateHandle,\n TooltipPopup,\n TooltipProvider,\n TooltipTrigger,\n} from \"@/registry/default/ui/tooltip\";\n\n// A single shared tooltip reused across every day cell (via the handle/payload\n// pattern). Reusing one instance lets it gracefully move/scale between days\n// instead of mounting a separate tooltip per cell.\nconst monthTooltipHandle = TooltipCreateHandle();\n\n// Falls back to English (US) when no `locale` prop is provided.\nconst DEFAULT_LOCALE = \"en-US\";\n\ntype CalendarLabels = {\n /** ARIA label for the previous-month navigation button. */\n previousMonth: string;\n /** ARIA label for the next-month navigation button. */\n nextMonth: string;\n /** ARIA label for the navigation toolbar. */\n nav: string;\n /** Prefix for today's day button label, e.g. \"Today, Monday, …\". */\n today: string;\n /** Suffix for the selected day button label, e.g. \"…, selected\". */\n selected: string;\n};\n\n// English defaults. Localize by passing translated strings via the `labels`\n// prop (wire it up to your app's own i18n layer); date/weekday/month names are\n// still localized automatically from `locale` via `Intl`.\nconst DEFAULT_LABELS: CalendarLabels = {\n previousMonth: \"Go to the Previous Month\",\n nextMonth: \"Go to the Next Month\",\n nav: \"Navigation bar\",\n today: \"Today\",\n selected: \"selected\",\n};\n\n// Resolves the text direction for the locale (e.g. \"rtl\" for Arabic/Hebrew).\nfunction getTextDirection(locale: string): \"ltr\" | \"rtl\" {\n try {\n const localeObj = new Intl.Locale(locale) as Intl.Locale & {\n textInfo?: { direction?: string };\n getTextInfo?: () => { direction?: string };\n };\n const info =\n typeof localeObj.getTextInfo === \"function\"\n ? localeObj.getTextInfo()\n : localeObj.textInfo;\n if (info?.direction === \"rtl\") {\n return \"rtl\";\n }\n } catch {\n // Fall through to the ltr default below.\n }\n return \"ltr\";\n}\n\n// Locale-aware formatting/ordering, derived from a BCP-47 locale string.\ntype Localization = {\n weekStartsOn: number;\n monthYearLabel: (date: Date) => string;\n fullDateLabel: (date: Date) => string;\n monthShortLabel: (date: Date) => string;\n monthLongLabel: (date: Date) => string;\n weekdayLong: (date: Date) => string;\n weekdayShort: (date: Date) => string;\n dayNumber: (date: Date) => string;\n captionFormatter: Intl.DateTimeFormat;\n};\n\n// 1 = Monday … 7 = Sunday (ISO) → JS getDay() index (0 = Sunday … 6 = Saturday).\nfunction getWeekStartsOn(locale: string): number {\n try {\n const localeObj = new Intl.Locale(locale) as Intl.Locale & {\n weekInfo?: { firstDay?: number };\n getWeekInfo?: () => { firstDay?: number };\n };\n const info =\n typeof localeObj.getWeekInfo === \"function\"\n ? localeObj.getWeekInfo()\n : localeObj.weekInfo;\n const firstDay = info?.firstDay;\n if (typeof firstDay === \"number\") {\n return firstDay % 7;\n }\n } catch {\n // Fall through to the Sunday default below.\n }\n return 0;\n}\n\nfunction buildLocalization(\n locale: string,\n weekStartsOnOverride?: number,\n): Localization {\n const longDateFormatter = new Intl.DateTimeFormat(locale, {\n weekday: \"long\",\n year: \"numeric\",\n month: \"long\",\n day: \"numeric\",\n });\n const monthYearFormatter = new Intl.DateTimeFormat(locale, {\n month: \"long\",\n year: \"numeric\",\n });\n const monthShortFormatter = new Intl.DateTimeFormat(locale, {\n month: \"short\",\n });\n const monthLongFormatter = new Intl.DateTimeFormat(locale, {\n month: \"long\",\n });\n const weekdayLongFormatter = new Intl.DateTimeFormat(locale, {\n weekday: \"long\",\n });\n const weekdayShortFormatter = new Intl.DateTimeFormat(locale, {\n weekday: \"short\",\n });\n const dayFormatter = new Intl.DateTimeFormat(locale, { day: \"numeric\" });\n\n return {\n weekStartsOn: weekStartsOnOverride ?? getWeekStartsOn(locale),\n monthYearLabel: (date) => monthYearFormatter.format(date),\n fullDateLabel: (date) => longDateFormatter.format(date),\n monthShortLabel: (date) => monthShortFormatter.format(date),\n monthLongLabel: (date) => monthLongFormatter.format(date),\n weekdayLong: (date) => weekdayLongFormatter.format(date),\n weekdayShort: (date) => weekdayShortFormatter.format(date),\n dayNumber: (date) => dayFormatter.format(date),\n captionFormatter: monthYearFormatter,\n };\n}\n\nfunction pad2(value: number): string {\n return value < 10 ? `0${value}` : String(value);\n}\n\nfunction isoDate(date: Date): string {\n return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;\n}\n\nfunction isoMonth(date: Date): string {\n return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}`;\n}\n\nfunction startOfDay(date: Date): Date {\n return new Date(date.getFullYear(), date.getMonth(), date.getDate());\n}\n\nfunction startOfMonth(date: Date): Date {\n return new Date(date.getFullYear(), date.getMonth(), 1);\n}\n\nfunction endOfMonth(date: Date): Date {\n return new Date(date.getFullYear(), date.getMonth() + 1, 0);\n}\n\nfunction addDays(date: Date, amount: number): Date {\n return new Date(date.getFullYear(), date.getMonth(), date.getDate() + amount);\n}\n\nfunction addMonths(date: Date, amount: number): Date {\n return new Date(date.getFullYear(), date.getMonth() + amount, date.getDate());\n}\n\nfunction startOfWeek(date: Date, weekStartsOn = 0): Date {\n const day = startOfDay(date);\n const diff = (day.getDay() - weekStartsOn + 7) % 7;\n return addDays(day, -diff);\n}\n\nfunction endOfWeek(date: Date, weekStartsOn = 0): Date {\n return addDays(startOfWeek(date, weekStartsOn), 6);\n}\n\nfunction isSameDay(a: Date, b: Date): boolean {\n return (\n a.getFullYear() === b.getFullYear() &&\n a.getMonth() === b.getMonth() &&\n a.getDate() === b.getDate()\n );\n}\n\nfunction isSameMonth(a: Date, b: Date): boolean {\n return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();\n}\n\nfunction differenceInCalendarMonths(a: Date, b: Date): number {\n return (\n (a.getFullYear() - b.getFullYear()) * 12 + (a.getMonth() - b.getMonth())\n );\n}\n\ntype CalendarDay = {\n date: Date;\n outside: boolean;\n isoDate: string;\n dateMonthId: string;\n};\n\ntype DayModifiers = {\n focused: boolean;\n disabled: boolean;\n hidden: boolean;\n outside: boolean;\n today: boolean;\n selected: boolean;\n};\n\n// Builds the month grid as full weeks covering the month.\n//\n// `shiftRows` slides the window forward by that many weeks, dropping the\n// month's earliest week(s). When `fillToSix` is set the grid is then padded to\n// a full 6 rows with days from the next month. Together they keep today's week\n// plus the next two visible without exceeding 6 rows; with both at their\n// defaults the month spans only the weeks it naturally needs.\nfunction buildCalendarDays(\n month: Date,\n weekStartsOn: number,\n shiftRows: number,\n fillToSix: boolean,\n): CalendarDay[] {\n const monthStart = startOfMonth(month);\n const offset = shiftRows * 7;\n const gridStart = addDays(startOfWeek(monthStart, weekStartsOn), offset);\n const gridEnd = addDays(endOfWeek(endOfMonth(month), weekStartsOn), offset);\n\n const dates: Date[] = [];\n for (let cursor = gridStart; cursor <= gridEnd; cursor = addDays(cursor, 1)) {\n dates.push(cursor);\n }\n\n if (fillToSix) {\n while (dates.length < 42) {\n const lastDate = dates.at(-1);\n if (!lastDate) {\n break;\n }\n dates.push(addDays(lastDate, 1));\n }\n }\n\n return dates.map((date) => ({\n date,\n outside: !isSameMonth(date, month),\n isoDate: isoDate(date),\n dateMonthId: isoMonth(date),\n }));\n}\n\nfunction chunkWeeks(days: CalendarDay[]): CalendarDay[][] {\n const weeks: CalendarDay[][] = [];\n for (let i = 0; i < days.length; i += 7) {\n weeks.push(days.slice(i, i + 7));\n }\n return weeks;\n}\n\nfunction isFocusableDay(modifiers: DayModifiers): boolean {\n return !modifiers.disabled && !modifiers.hidden && !modifiers.outside;\n}\n\n// A single day cell's button. Focuses itself when it becomes the roving-tabindex\n// target so keyboard navigation moves the actual DOM focus.\nfunction DayButton({\n focused,\n nextMonthLabel,\n tooltip,\n children,\n ...buttonProps\n}: ComponentProps<\"button\"> & {\n focused: boolean;\n nextMonthLabel?: string;\n tooltip?: string;\n}): ReactElement {\n const ref = useRef(null);\n\n useEffect(() => {\n if (focused) ref.current?.focus();\n }, [focused]);\n\n const button = (\n \n );\n\n if (!tooltip) return button;\n\n return (\n \n );\n}\n\n// Month + year heading; the year is muted via formatToParts so it stays\n// locale-correct (some locales render \"2026 June\").\nfunction MonthCaption({\n date,\n formatter,\n className,\n}: {\n date: Date;\n formatter: Intl.DateTimeFormat;\n className?: string;\n}): ReactElement {\n return (\n
\n \n {formatter.formatToParts(date).map((part) =>\n part.type === \"year\" ? (\n \n {part.value}\n \n ) : (\n part.value\n ),\n )}\n \n
\n );\n}\n\ntype MoveBy = \"day\" | \"week\" | \"month\" | \"year\" | \"startOfWeek\" | \"endOfWeek\";\ntype MoveDir = \"before\" | \"after\";\n\nexport type BookerCalendarProps = {\n className?: string;\n /**\n * For the current month, once today reaches the 4th row, keep its week plus\n * the next two visible within the 6-row grid: extend into next-month days,\n * and slide the window down (dropping early weeks) only when today sits too\n * low for those two weeks to fit. Off renders a plain month grid. On by\n * default.\n */\n shiftWeeks?: boolean;\n availabilityLoading?: boolean;\n initialLoading?: boolean;\n month?: Date;\n defaultMonth?: Date;\n startMonth?: Date;\n endMonth?: Date;\n selected?: Date;\n onSelect?: (date: Date | undefined, triggerDate: Date) => void;\n onMonthChange?: (month: Date) => void;\n disabled?: (date: Date) => boolean;\n /** BCP-47 locale tag used to localize day/month/weekday names and ARIA labels. */\n locale?: string;\n /** Override the locale's first day of the week (0 = Sunday … 6 = Saturday). */\n weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;\n /** Text direction; defaults to the locale's direction (\"rtl\" for ar/he/…). */\n dir?: \"ltr\" | \"rtl\";\n /** Translatable strings for the navigation buttons and day button states. */\n labels?: Partial;\n /** Reference \"today\" for highlighting and week shifting; defaults to now. */\n today?: Date;\n};\n\nexport function BookerCalendar({\n className,\n shiftWeeks = true,\n availabilityLoading = false,\n initialLoading = false,\n month,\n defaultMonth,\n startMonth,\n endMonth,\n selected,\n onSelect,\n onMonthChange,\n disabled,\n locale = DEFAULT_LOCALE,\n weekStartsOn,\n dir,\n labels,\n today: todayProp,\n}: BookerCalendarProps): ReactElement {\n const dayCellsLoading = initialLoading || availabilityLoading;\n const localization = useMemo(\n () => buildLocalization(locale, weekStartsOn),\n [locale, weekStartsOn],\n );\n const resolvedLabels = { ...DEFAULT_LABELS, ...labels };\n const resolvedDir = dir ?? getTextDirection(locale);\n const weekStart = localization.weekStartsOn;\n\n const today = todayProp ?? new Date();\n\n // The month is controlled by the parent (via `month`/`onMonthChange`), but we\n // keep an internal fallback so the calendar works uncontrolled too.\n const [internalMonth, setInternalMonth] = useState(() =>\n startOfMonth(month ?? defaultMonth ?? today),\n );\n const monthTime = month ? startOfMonth(month).getTime() : undefined;\n useEffect(() => {\n if (monthTime !== undefined) {\n setInternalMonth(new Date(monthTime));\n }\n }, [monthTime]);\n\n const firstMonth = month ? startOfMonth(month) : internalMonth;\n\n const [focusedDate, setFocusedDate] = useState(undefined);\n const [lastFocusedDate, setLastFocusedDate] = useState(\n undefined,\n );\n\n const navStart = startMonth ? startOfMonth(startMonth) : undefined;\n const navEnd = endMonth ? startOfMonth(endMonth) : undefined;\n\n const previousMonth =\n navStart && differenceInCalendarMonths(firstMonth, navStart) <= 0\n ? undefined\n : addMonths(firstMonth, -1);\n const nextMonth =\n navEnd && differenceInCalendarMonths(navEnd, firstMonth) <= 0\n ? undefined\n : addMonths(firstMonth, 1);\n\n const goToMonth = useCallback(\n (date: Date) => {\n let newMonth = startOfMonth(date);\n if (navStart && newMonth < navStart) {\n newMonth = navStart;\n }\n if (navEnd && newMonth > navEnd) {\n newMonth = navEnd;\n }\n setInternalMonth(newMonth);\n onMonthChange?.(newMonth);\n },\n [navEnd, navStart, onMonthChange],\n );\n\n // For the live current month, once today reaches the 4th row, guarantee its\n // week plus the next two are visible. todayRow is today's 0-based row in the\n // natural (unshifted) grid.\n const naturalGridStart = startOfWeek(startOfMonth(firstMonth), weekStart);\n const todayRow = Math.floor(\n Math.round(\n (startOfDay(today).getTime() - naturalGridStart.getTime()) / 86_400_000,\n ) / 7,\n );\n const expand = shiftWeeks && isSameMonth(firstMonth, today) && todayRow >= 3;\n // Extend downward first; only drop early weeks (shift) when today sits so low\n // that the two trailing weeks wouldn't fit within the 6-row cap.\n const shiftRows = expand ? Math.max(0, todayRow - 3) : 0;\n\n const days = buildCalendarDays(firstMonth, weekStart, shiftRows, expand);\n const weeks = chunkWeeks(days);\n\n // Reserve any missing rows up to 6 (the most any month needs) with empty\n // placeholders, so the grid keeps a constant height across navigation. This\n // is 0 when expanding, since those months already fill all 6 rows with days.\n const placeholderRows = Math.max(0, 6 - weeks.length);\n\n const isSelected = (date: Date): boolean =>\n selected ? isSameDay(selected, date) : false;\n\n const getModifiers = (day: CalendarDay): DayModifiers => {\n const { date, outside } = day;\n const isBeforeNavStart = Boolean(navStart && date < navStart);\n const isAfterNavEnd = Boolean(navEnd && date > endOfMonth(navEnd));\n const isDisabled = disabled ? disabled(date) : false;\n // Leading previous-month days are never shown (past days aren't relevant).\n // Next-month days only belong in the grid while expanding; otherwise the\n // empty placeholder rows reserve their space instead of spilling over.\n const isPrevMonthDay = date < startOfMonth(firstMonth);\n const isNextMonthDay = date > endOfMonth(firstMonth);\n const isHidden =\n isBeforeNavStart ||\n isAfterNavEnd ||\n isPrevMonthDay ||\n (!expand && isNextMonthDay);\n const isToday = isSameDay(date, today);\n const isFocused =\n !isHidden &&\n !outside &&\n focusedDate !== undefined &&\n isSameDay(date, focusedDate);\n\n return {\n focused: isFocused,\n disabled: isDisabled,\n hidden: isHidden,\n outside,\n today: isToday,\n selected: isSelected(date),\n };\n };\n\n // Roving tabindex target. Priority: focused > last focused > selected > today\n // > first focusable day.\n const focusTarget = ((): CalendarDay | undefined => {\n let target: CalendarDay | undefined;\n let priority = -1;\n for (const day of days) {\n const modifiers = getModifiers(day);\n if (!isFocusableDay(modifiers)) continue;\n if (modifiers.focused && priority < 3) {\n target = day;\n priority = 3;\n } else if (\n lastFocusedDate &&\n isSameDay(day.date, lastFocusedDate) &&\n priority < 2\n ) {\n target = day;\n priority = 2;\n } else if (isSelected(day.date) && priority < 1) {\n target = day;\n priority = 1;\n } else if (modifiers.today && priority < 0) {\n target = day;\n priority = 0;\n }\n }\n if (!target) {\n target = days.find((day) => isFocusableDay(getModifiers(day)));\n }\n return target;\n })();\n\n // While expanding, mark the 1st of the next month (the first visible day past\n // the current month's end) with the month label.\n const monthEnd = endOfMonth(firstMonth);\n const nextMonthLabelIso = expand\n ? days.find((day) => {\n const modifiers = getModifiers(day);\n return day.date > monthEnd && !modifiers.hidden;\n })?.isoDate\n : undefined;\n\n const getFocusableDate = (\n moveBy: MoveBy,\n moveDir: MoveDir,\n refDate: Date,\n ): Date => {\n const delta = moveDir === \"after\" ? 1 : -1;\n let result: Date;\n switch (moveBy) {\n case \"day\":\n result = addDays(refDate, delta);\n break;\n case \"week\":\n result = addDays(refDate, delta * 7);\n break;\n case \"month\":\n result = addMonths(refDate, delta);\n break;\n case \"year\":\n result = addMonths(refDate, delta * 12);\n break;\n case \"startOfWeek\":\n result = startOfWeek(refDate, weekStart);\n break;\n case \"endOfWeek\":\n result = endOfWeek(refDate, weekStart);\n break;\n }\n if (moveDir === \"before\" && navStart && result < navStart) {\n result = navStart;\n }\n if (moveDir === \"after\" && navEnd && result > endOfMonth(navEnd)) {\n result = endOfMonth(navEnd);\n }\n return result;\n };\n\n const getNextFocus = (\n moveBy: MoveBy,\n moveDir: MoveDir,\n refDate: Date,\n attempt = 0,\n ): Date | undefined => {\n if (attempt > 365) return undefined;\n const next = getFocusableDate(moveBy, moveDir, refDate);\n const isDisabled = disabled ? disabled(next) : false;\n if (!isDisabled) return next;\n return getNextFocus(moveBy, moveDir, next, attempt + 1);\n };\n\n const moveFocus = (moveBy: MoveBy, moveDir: MoveDir) => {\n if (!focusedDate) return;\n const next = getNextFocus(moveBy, moveDir, focusedDate);\n if (!next) return;\n if (!isSameMonth(next, firstMonth)) {\n goToMonth(next);\n }\n setFocusedDate(next);\n };\n\n const handleDayClick =\n (day: CalendarDay, modifiers: DayModifiers) => (event: MouseEvent) => {\n event.preventDefault();\n event.stopPropagation();\n setFocusedDate(day.date);\n if (modifiers.disabled) return;\n const newDate =\n selected && isSameDay(day.date, selected) ? undefined : day.date;\n onSelect?.(newDate, day.date);\n };\n\n const handleDayFocus = (day: CalendarDay) => () => {\n setFocusedDate(day.date);\n };\n\n const handleDayBlur = () => {\n setLastFocusedDate(focusedDate);\n setFocusedDate(undefined);\n };\n\n const handleDayKeyDown = (event: KeyboardEvent) => {\n const keyMap: Record = {\n ArrowLeft: [event.shiftKey ? \"month\" : \"day\", \"before\"],\n ArrowRight: [event.shiftKey ? \"month\" : \"day\", \"after\"],\n ArrowDown: [event.shiftKey ? \"year\" : \"week\", \"after\"],\n ArrowUp: [event.shiftKey ? \"year\" : \"week\", \"before\"],\n PageUp: [event.shiftKey ? \"year\" : \"month\", \"before\"],\n PageDown: [event.shiftKey ? \"year\" : \"month\", \"after\"],\n Home: [\"startOfWeek\", \"before\"],\n End: [\"endOfWeek\", \"after\"],\n };\n const move = keyMap[event.key];\n if (move) {\n event.preventDefault();\n event.stopPropagation();\n const [moveBy, moveDir] = move;\n moveFocus(moveBy, moveDir);\n }\n };\n\n const weekdayHeaderStart = startOfWeek(today, weekStart);\n const weekdays = Array.from({ length: 7 }, (_, index) => {\n const date = addDays(weekdayHeaderStart, index);\n return {\n long: localization.weekdayLong(date),\n short: localization.weekdayShort(date),\n };\n });\n\n return (\n \n
\n
\n {localization.monthYearLabel(firstMonth)}\n
\n
\n {initialLoading ? (\n \n ) : (\n <>\n \n \n {\n if (previousMonth) goToMonth(previousMonth);\n }}\n >\n \n \n {\n if (nextMonth) goToMonth(nextMonth);\n }}\n >\n \n \n \n \n )}\n
\n \n \n \n \n {weekdays.map((weekday) => (\n \n {initialLoading ? (\n \n ) : (\n weekday.short\n )}\n \n ))}\n \n \n \n {weeks.map((week) => (\n \n {week.map((day) => {\n const modifiers = getModifiers(day);\n\n // The day button's own styling is driven by the `data-*`\n // attributes below; these cell classes only cover the\n // layout and the outside/hidden/today states.\n const cellClassName = cn(\n \"@max-3xl:max-h-9 flex-1 p-0\",\n modifiers.hidden && \"invisible\",\n modifiers.outside && \"text-muted-foreground/50\",\n modifiers.today &&\n !dayCellsLoading &&\n \"*:before:pointer-events-none *:before:absolute *:before:start-1/2 *:before:bottom-1/7 *:before:z-1 *:before:size-1 *:before:-translate-x-1/2 *:before:rounded-full *:before:bg-primary *:before:transition-colors [&[data-selected]>*]:before:bg-primary-foreground\",\n );\n\n // Label (e.g. \"Aug\") sits on the 1st of the next month.\n const nextMonthLabel =\n day.isoDate === nextMonthLabelIso\n ? localization.monthShortLabel(day.date)\n : undefined;\n\n // Next-month days only show in the shifted current-month\n // view; a month-name tooltip keeps them from being mistaken\n // for the current month.\n const monthTooltip =\n day.outside && !modifiers.disabled && !modifiers.hidden\n ? localization.monthLongLabel(day.date)\n : undefined;\n\n return (\n \n {modifiers.hidden ? null : dayCellsLoading ? (\n \n \n \n ) : (\n {\n let label = localization.fullDateLabel(day.date);\n if (modifiers.today) {\n label = `${resolvedLabels.today}, ${label}`;\n }\n if (modifiers.selected) {\n label = `${label}, ${resolvedLabels.selected}`;\n }\n return label;\n })()}\n onClick={handleDayClick(day, modifiers)}\n onBlur={handleDayBlur}\n onFocus={handleDayFocus(day)}\n onKeyDown={handleDayKeyDown}\n >\n {localization.dayNumber(day.date)}\n \n )}\n \n );\n })}\n \n ))}\n {Array.from(\n { length: placeholderRows },\n (_, rowIndex) => `placeholder-row-${rowIndex}`,\n ).map((rowKey) => (\n \n {weekdays.map((weekday) => (\n \n
\n \n ))}\n \n ))}\n \n \n \n {({ payload }) => (\n \n {payload}\n \n )}\n \n \n
\n
\n );\n}\n", "type": "registry:component", "target": "components/atoms/booker/booker-calendar.tsx" }, @@ -87,7 +85,7 @@ }, { "path": "registry/default/atoms/booker/time-picker.tsx", - "content": "\"use client\";\n\nimport { CalendarX2Icon } from \"lucide-react\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport {\n Empty,\n EmptyContent,\n EmptyDescription,\n EmptyHeader,\n EmptyMedia,\n EmptyTitle,\n} from \"@/registry/default/ui/empty\";\nimport { Label } from \"@/registry/default/ui/label\";\nimport { ScrollArea } from \"@/registry/default/ui/scroll-area\";\nimport { Skeleton } from \"@/registry/default/ui/skeleton\";\nimport { Switch } from \"@/registry/default/ui/switch\";\nimport {\n displayTimeLabel,\n formatSelectedDay,\n formatSelectedMonth,\n formatSelectedWeekday,\n isSingleDigit12HourLabel,\n} from \"@/lib/booker/utils\";\n\ntype TimePickerLabels = {\n noAvailableTimes: string;\n noSlotsAvailable: string;\n noSlotsThisDay: string;\n noSlotsThisMonth: string;\n viewFirstAvailability: string;\n use24Hour: string;\n hour12Short: string;\n hour24Short: string;\n};\n\ntype TimePickerProps = {\n availabilityLoading: boolean;\n currentMonth: Date;\n daySlots: string[];\n goToDate: (date: Date) => void;\n is24Hour: boolean;\n initialLoading: boolean;\n labels: TimePickerLabels;\n locale: string;\n nextAvailableDate: Date | null;\n onIs24HourChange: (value: boolean) => void;\n onSelectTime: (time: string) => void;\n selectedDate: Date | undefined;\n hasAvailabilityInView: boolean;\n};\n\nexport function TimePicker({\n availabilityLoading,\n currentMonth,\n daySlots,\n goToDate,\n is24Hour,\n initialLoading,\n labels,\n locale,\n nextAvailableDate,\n onIs24HourChange,\n onSelectTime,\n selectedDate,\n hasAvailabilityInView,\n}: TimePickerProps) {\n const slotsLoading = initialLoading || availabilityLoading;\n\n return (\n
\n
\n {(slotsLoading || selectedDate != null) && (\n
\n
\n {slotsLoading ? (\n \n ) : (\n
\n \n {formatSelectedWeekday(selectedDate ?? new Date(), locale)}\n \n \n {selectedDate &&\n selectedDate.getMonth() !== currentMonth.getMonth()\n ? `${formatSelectedMonth(selectedDate, locale)} `\n : null}\n {formatSelectedDay(selectedDate ?? new Date(), locale)}\n \n
\n )}\n {!initialLoading ? (\n \n ) : null}\n
\n
\n )}\n
\n {!slotsLoading && daySlots.length === 0 ? (\n
\n \n \n \n \n \n \n {labels.noAvailableTimes}\n \n \n {hasAvailabilityInView\n ? labels.noSlotsThisDay\n : nextAvailableDate\n ? labels.noSlotsThisMonth\n : labels.noSlotsAvailable}\n \n \n {nextAvailableDate ? (\n \n \n \n ) : null}\n \n
\n ) : (\n \n
\n {slotsLoading ? (\n
\n {Array.from(\n { length: 20 },\n (_, index) => `time-skeleton-${index}`,\n ).map((skeletonKey) => (\n \n ))}\n
\n ) : (\n
\n {daySlots.map((time) => (\n onSelectTime(time)}\n className=\"relative flex h-9 cursor-pointer items-center justify-center rounded-lg bg-muted font-medium text-foreground tabular-nums outline-none transition-shadow after:absolute @5xl:after:-inset-1 after:-inset-0.75 hover:ring-2 hover:ring-primary/72 focus-visible:z-1 focus-visible:ring-2 focus-visible:ring-primary/72 sm:h-8 sm:text-sm\"\n >\n \n {!is24Hour && isSingleDigit12HourLabel(time) ? (\n \n 0\n \n ) : null}\n {displayTimeLabel(time, !is24Hour)}\n \n \n ))}\n
\n )}\n
\n
\n )}\n
\n
\n
\n );\n}\n", + "content": "\"use client\";\n\nimport { CalendarX2Icon } from \"lucide-react\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport {\n Empty,\n EmptyContent,\n EmptyDescription,\n EmptyHeader,\n EmptyMedia,\n EmptyTitle,\n} from \"@/registry/default/ui/empty\";\nimport { Label } from \"@/registry/default/ui/label\";\nimport { ScrollArea } from \"@/registry/default/ui/scroll-area\";\nimport { Skeleton } from \"@/registry/default/ui/skeleton\";\nimport { Switch } from \"@/registry/default/ui/switch\";\nimport {\n displayTimeLabel,\n formatSelectedDay,\n formatSelectedMonth,\n formatSelectedWeekday,\n isSingleDigit12HourLabel,\n} from \"@/lib/booker/utils\";\n\ntype TimePickerLabels = {\n noAvailableTimes: string;\n noSlotsAvailable: string;\n noSlotsThisDay: string;\n noSlotsThisMonth: string;\n viewFirstAvailability: string;\n use24Hour: string;\n hour12Short: string;\n hour24Short: string;\n};\n\ntype TimePickerProps = {\n availabilityLoading: boolean;\n currentMonth: Date;\n daySlots: string[];\n goToDate: (date: Date) => void;\n is24Hour: boolean;\n initialLoading: boolean;\n labels: TimePickerLabels;\n locale: string;\n nextAvailableDate: Date | null;\n onIs24HourChange: (value: boolean) => void;\n onSelectTime: (time: string) => void;\n selectedDate: Date | undefined;\n hasAvailabilityInView: boolean;\n};\n\nexport function TimePicker({\n availabilityLoading,\n currentMonth,\n daySlots,\n goToDate,\n is24Hour,\n initialLoading,\n labels,\n locale,\n nextAvailableDate,\n onIs24HourChange,\n onSelectTime,\n selectedDate,\n hasAvailabilityInView,\n}: TimePickerProps) {\n const slotsLoading = initialLoading || availabilityLoading;\n\n return (\n
\n
\n {(slotsLoading || selectedDate != null) && (\n
\n
\n {slotsLoading ? (\n \n ) : (\n
\n \n {formatSelectedWeekday(selectedDate ?? new Date(), locale)}\n \n \n {selectedDate &&\n selectedDate.getMonth() !== currentMonth.getMonth()\n ? `${formatSelectedMonth(selectedDate, locale)} `\n : null}\n {formatSelectedDay(selectedDate ?? new Date(), locale)}\n \n
\n )}\n {!initialLoading ? (\n \n ) : null}\n
\n
\n )}\n
\n {!slotsLoading && daySlots.length === 0 ? (\n
\n \n \n \n \n \n \n {labels.noAvailableTimes}\n \n \n {hasAvailabilityInView\n ? labels.noSlotsThisDay\n : nextAvailableDate\n ? labels.noSlotsThisMonth\n : labels.noSlotsAvailable}\n \n \n {nextAvailableDate ? (\n \n \n \n ) : null}\n \n
\n ) : (\n \n
\n {slotsLoading ? (\n
\n {Array.from(\n { length: 20 },\n (_, index) => `time-skeleton-${index}`,\n ).map((skeletonKey) => (\n \n ))}\n
\n ) : (\n
\n {daySlots.map((time) => (\n onSelectTime(time)}\n className=\"relative flex h-9 cursor-pointer items-center justify-center rounded-lg bg-muted font-medium text-foreground tabular-nums outline-none transition-shadow after:absolute @5xl:after:-inset-1 after:-inset-0.75 hover:ring-2 hover:ring-primary/72 focus-visible:z-1 focus-visible:ring-2 focus-visible:ring-primary/72 sm:h-8 sm:text-sm\"\n >\n \n {!is24Hour && isSingleDigit12HourLabel(time) ? (\n \n 0\n \n ) : null}\n {displayTimeLabel(time, !is24Hour)}\n \n \n ))}\n
\n )}\n
\n
\n )}\n
\n
\n
\n );\n}\n", "type": "registry:component", "target": "components/atoms/booker/time-picker.tsx" }, @@ -99,7 +97,7 @@ }, { "path": "registry/default/atoms/lib/booker/use-booker.ts", - "content": "\"use client\";\n\nimport {\n useCallback,\n useEffect,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport {\n type FetchRawBookerResult,\n fetchRawBookerDataAction,\n} from \"@/lib/booker/actions\";\nimport {\n type BookerTarget,\n getDynamicContext,\n getOrgSlugFromTarget,\n} from \"@/lib/booker/target\";\nimport {\n type BookerMeta,\n type CalendarViewOptions,\n extractBookingWindowEnd,\n extractBookingWindowStart,\n extractEventTypeInfo,\n extractHost,\n extractHostAvatars,\n extractHostDisplayName,\n extractMinimumBookingNotice,\n extractSlotsByDate,\n filterBookableSlots,\n findNextAvailableDate,\n getMinimumNavigableMonth,\n getTodayInTimeZone,\n getWeekStartsOn,\n hasAvailabilityInVisibleGrid,\n isCalendarMonthBefore,\n pickDefaultSelectedDate,\n prefers24Hour,\n toCalendarDateKey,\n} from \"@/lib/booker/utils\";\n\nconst WINDOW_MONTHS = 3;\n\nexport type BookerErrorKind =\n | \"not-found\"\n | \"unpublished\"\n | \"network\"\n | \"unknown\";\n\nexport type BookerError = {\n kind: BookerErrorKind;\n message: string;\n};\n\nclass BookerDataError extends Error {\n code: string;\n constructor(message: string, code: string) {\n super(message);\n this.code = code;\n }\n}\n\ntype ResolvedEventType = Extract<\n FetchRawBookerResult,\n { ok: true }\n>[\"resolved\"];\n\nexport type BookerInitialData = {\n durationMinutes?: number;\n monthIso: string;\n monthsToFetch?: number;\n result: FetchRawBookerResult;\n timeZone: string;\n};\n\ntype DerivedBookerData = {\n hasMultiDurationOptions: boolean;\n meta: BookerMeta | null;\n minimumBookingNotice: number;\n selectedDurationMinutes: number | null;\n};\n\ntype HydratedBookerData = DerivedBookerData & {\n coveredMonthKeys: Set;\n error: BookerError | null;\n resolved: ResolvedEventType;\n slotsByDate: Record;\n slotsCacheKey: string;\n};\n\nfunction classifyError(caught: unknown): BookerError {\n const code = caught instanceof BookerDataError ? caught.code : \"\";\n const message =\n caught instanceof Error ? caught.message : \"Could not load booker data.\";\n\n if (code === \"UNPUBLISHED\") {\n return { kind: \"unpublished\", message };\n }\n if (code === \"NOT_FOUND\") {\n return { kind: \"not-found\", message };\n }\n if (\n code.startsWith(\"HTTP_4\") ||\n code.startsWith(\"HTTP_5\") ||\n message.includes(\"fetch\") ||\n message.includes(\"network\") ||\n message.includes(\"ETIMEDOUT\") ||\n message.includes(\"ECONNREFUSED\") ||\n /Cal\\.com API [45]\\d\\d/.test(message)\n ) {\n return { kind: \"network\", message };\n }\n return { kind: \"unknown\", message };\n}\n\nfunction getTargetIdentity(target: BookerTarget): string {\n if (target.type === \"user\") {\n return `user:${target.username}:${target.eventSlug}:${target.orgId ?? \"\"}`;\n }\n if (target.type === \"team\") {\n return `team:${target.teamId}:${target.eventSlug}:${target.orgId ?? \"\"}`;\n }\n if (target.type === \"dynamic\") {\n return `dynamic:${target.usernames.join(\",\")}:${target.orgSlug ?? \"\"}:${target.orgId ?? \"\"}`;\n }\n return `link:${target.bookingUrl}`;\n}\n\nfunction getMonthCacheKey(\n identity: string,\n timeZone: string,\n durationMinutes: number,\n month: Date,\n): string {\n return `${identity}|${timeZone}|${durationMinutes}|${month.getFullYear()}-${month.getMonth()}`;\n}\n\nfunction getSlotsCacheKey(\n identity: string,\n timeZone: string,\n durationMinutes: number,\n): string {\n return `${identity}|${timeZone}|${durationMinutes}`;\n}\n\nfunction deriveBookerData(\n result: Extract,\n target: BookerTarget,\n): DerivedBookerData {\n if (!result.raw.me) {\n return {\n hasMultiDurationOptions: false,\n meta: null,\n minimumBookingNotice: 0,\n selectedDurationMinutes: null,\n };\n }\n\n const host = extractHost(result.raw.me);\n const isDynamic = Boolean(getDynamicContext(target));\n const hostAvatars = extractHostAvatars(\n result.raw.selectedEventType,\n result.raw.me,\n { dynamic: isDynamic, orgSlug: getOrgSlugFromTarget(target) },\n );\n const hostName = extractHostDisplayName(\n result.raw.selectedEventType,\n result.raw.me,\n { dynamic: isDynamic },\n );\n const eventType = extractEventTypeInfo(result.raw.selectedEventType);\n const minimumBookingNotice = extractMinimumBookingNotice(\n result.raw.selectedEventType,\n );\n const hasMultiDurationOptions =\n (eventType.eventTypeDurationOptions?.length ?? 0) > 1;\n const selectedDurationMinutes =\n eventType.eventTypeDurationOptions?.[0] ??\n eventType.eventTypeDurationMinutes ??\n null;\n\n return {\n hasMultiDurationOptions,\n meta: {\n bookingWindowEnd: extractBookingWindowEnd(result.raw.selectedEventType),\n bookingWindowStart: extractBookingWindowStart(\n result.raw.selectedEventType,\n ),\n eventTypeDescription: eventType.eventTypeDescription,\n eventTypeDurationMinutes: eventType.eventTypeDurationMinutes,\n eventTypeDurationOptions: eventType.eventTypeDurationOptions,\n eventTypeImageUrl: result.raw.bannerUrl || eventType.eventTypeImageUrl,\n eventTypeLocations: eventType.eventTypeLocations,\n eventTypeTitle: eventType.eventTypeTitle,\n hostAvatarUrl: host.hostAvatarUrl,\n hostAvatars,\n hostName:\n host.hostName === \"Unknown user\" && target.type === \"user\"\n ? target.username\n : result.raw.publicDisplayName || hostName,\n },\n minimumBookingNotice,\n selectedDurationMinutes,\n };\n}\n\nfunction hydrateBookerData({\n initialData,\n selectedTimeZone,\n target,\n}: {\n initialData?: BookerInitialData;\n selectedTimeZone: string;\n target: BookerTarget;\n}): HydratedBookerData | null {\n if (!initialData) {\n return null;\n }\n\n if (initialData.timeZone !== selectedTimeZone) {\n return null;\n }\n\n if (!initialData.result.ok) {\n const error = classifyError(\n new BookerDataError(\n initialData.result.error,\n initialData.result.errorCode ?? \"UNKNOWN\",\n ),\n );\n\n if (error.kind !== \"not-found\" && error.kind !== \"unpublished\") {\n return null;\n }\n\n return {\n coveredMonthKeys: new Set(),\n error,\n hasMultiDurationOptions: false,\n meta: null,\n minimumBookingNotice: 0,\n resolved: { eventTypeId: null, eventTypeSlug: null },\n selectedDurationMinutes: null,\n slotsByDate: {},\n slotsCacheKey: \"\",\n };\n }\n\n const derived = deriveBookerData(initialData.result, target);\n const durationMinutes =\n derived.selectedDurationMinutes ?? initialData.durationMinutes ?? 30;\n const identity = getTargetIdentity(target);\n const anchor = new Date(initialData.monthIso);\n const safeAnchor = Number.isNaN(anchor.getTime()) ? new Date() : anchor;\n const monthsToFetch = Math.max(\n 1,\n Math.floor(initialData.monthsToFetch ?? WINDOW_MONTHS),\n );\n const coveredMonthKeys = new Set();\n\n for (let offset = 0; offset < monthsToFetch; offset += 1) {\n coveredMonthKeys.add(\n getMonthCacheKey(\n identity,\n selectedTimeZone,\n durationMinutes,\n new Date(safeAnchor.getFullYear(), safeAnchor.getMonth() + offset, 1),\n ),\n );\n }\n\n const slotsByDate = filterBookableSlots(\n extractSlotsByDate(initialData.result.raw.slots, selectedTimeZone),\n selectedTimeZone,\n derived.minimumBookingNotice,\n new Date(),\n );\n\n return {\n ...derived,\n coveredMonthKeys,\n error: null,\n resolved: initialData.result.resolved,\n selectedDurationMinutes: durationMinutes,\n slotsByDate,\n slotsCacheKey: getSlotsCacheKey(\n identity,\n selectedTimeZone,\n durationMinutes,\n ),\n };\n}\n\ntype UseBookerParams = {\n initialData?: BookerInitialData;\n target: BookerTarget;\n timezone?: string;\n};\n\ntype BookerLoadingState = {\n availability: boolean;\n busy: boolean;\n initial: boolean;\n};\n\ntype BookerTimezoneState = {\n onValueChange: (timeZone: string) => void;\n value: string;\n};\n\ntype BookerDurationState = {\n onValueChange: (minutes: number) => void;\n value: number;\n};\n\ntype BookerCalendarState = {\n availabilityLoading: boolean;\n disabled: (date: Date) => boolean;\n endMonth?: Date;\n initialLoading: boolean;\n locale: string;\n month: Date;\n onMonthChange: (month: Date) => void;\n onSelect: (nextDate: Date | undefined) => void;\n selected: Date | undefined;\n startMonth: Date;\n today: Date;\n};\n\ntype BookerTimePickerState = {\n availabilityLoading: boolean;\n currentMonth: Date;\n daySlots: string[];\n goToDate: (date: Date) => void;\n hasAvailabilityInView: boolean;\n initialLoading: boolean;\n is24Hour: boolean;\n locale: string;\n nextAvailableDate: Date | null;\n onIs24HourChange: (value: boolean) => void;\n onSelectTime: (time: string) => void;\n selectedDate: Date | undefined;\n};\n\nexport type BookerStep = \"select\" | \"confirm\";\n\nexport type UseBookerResult = {\n calendarProps: BookerCalendarState;\n durationProps: BookerDurationState;\n error: BookerError | null;\n loadingState: BookerLoadingState;\n meta: BookerMeta | null;\n onBack: () => void;\n retry: () => void;\n selectedTime: string | null;\n step: BookerStep;\n timePickerProps: BookerTimePickerState;\n timezoneProps: BookerTimezoneState;\n};\n\n// All booker data + interaction logic: fetching/windowing cal.com availability,\n// derived selection state, and the handlers the UI wires to the calendar.\nexport function useBooker({\n initialData,\n target,\n timezone,\n}: UseBookerParams): UseBookerResult {\n const [timeZone] = useState(\n () => Intl.DateTimeFormat().resolvedOptions().timeZone || \"UTC\",\n );\n const [locale] = useState(\n () => Intl.DateTimeFormat().resolvedOptions().locale || \"en-US\",\n );\n const initialSelectedTimeZone = initialData?.timeZone ?? timezone ?? timeZone;\n const hydratedData = useMemo(\n () =>\n hydrateBookerData({\n initialData,\n selectedTimeZone: initialSelectedTimeZone,\n target,\n }),\n [initialData, initialSelectedTimeZone, target],\n );\n const initialCurrentMonth = useMemo(() => {\n const initialMonth = initialData?.monthIso\n ? new Date(initialData.monthIso)\n : new Date();\n return Number.isNaN(initialMonth.getTime()) ? new Date() : initialMonth;\n }, [initialData?.monthIso]);\n const initialSelectedDate = useMemo(() => {\n if (!hydratedData || hydratedData.error) {\n return undefined;\n }\n\n return (\n pickDefaultSelectedDate(\n initialCurrentMonth,\n hydratedData.slotsByDate,\n initialSelectedTimeZone,\n getTodayInTimeZone(initialSelectedTimeZone),\n {\n endMonth: hydratedData.meta?.bookingWindowEnd ?? undefined,\n shiftWeeks: true,\n startMonth: getMinimumNavigableMonth(\n initialSelectedTimeZone,\n hydratedData.meta?.bookingWindowStart,\n ),\n weekStartsOn: getWeekStartsOn(locale),\n },\n ) ?? undefined\n );\n }, [hydratedData, initialCurrentMonth, initialSelectedTimeZone, locale]);\n const [meta, setMeta] = useState(\n hydratedData?.meta ?? null,\n );\n const [slotsByDate, setSlotsByDate] = useState>(\n hydratedData?.slotsByDate ?? {},\n );\n const [error, setError] = useState(\n hydratedData?.error ?? null,\n );\n const [isPending, setIsPending] = useState(false);\n const [currentMonth, setCurrentMonth] = useState(initialCurrentMonth);\n const [selectedTimeZone, setSelectedTimeZone] = useState(\n initialSelectedTimeZone,\n );\n const [selectedDate, setSelectedDate] = useState(\n initialSelectedDate,\n );\n const [_selectedTime, setSelectedTime] = useState(null);\n const [step, setStep] = useState(\"select\");\n const [selectedDurationMinutes, setSelectedDurationMinutes] = useState(\n hydratedData?.selectedDurationMinutes ?? 30,\n );\n const [is24Hour, setIs24Hour] = useState(() => prefers24Hour(locale));\n const coveredMonthsRef = useRef>(\n hydratedData?.coveredMonthKeys ?? new Set(),\n );\n const resolvedRef = useRef(\n hydratedData?.resolved ?? { eventTypeId: null, eventTypeSlug: null },\n );\n const lastIdentityRef = useRef(null);\n const lastTimeZoneRef = useRef(null);\n const lastDurationRef = useRef(null);\n const autoSelectedMonthRef = useRef(\n initialSelectedDate\n ? `${initialCurrentMonth.getFullYear()}-${initialCurrentMonth.getMonth()}`\n : null,\n );\n const slotsByDateRef = useRef>(\n hydratedData?.slotsByDate ?? {},\n );\n const slotsCacheRef = useRef>>(\n new Map(\n hydratedData?.slotsCacheKey\n ? [[hydratedData.slotsCacheKey, hydratedData.slotsByDate]]\n : [],\n ),\n );\n const minimumBookingNoticeRef = useRef(\n hydratedData?.minimumBookingNotice ?? 0,\n );\n const hasMultiDurationOptionsRef = useRef(\n hydratedData?.hasMultiDurationOptions ?? false,\n );\n const selectedDateRef = useRef(selectedDate);\n selectedDateRef.current = selectedDate;\n const inFlightMonthsRef = useRef>>(new Map());\n const availabilityRequestVersionRef = useRef(0);\n const skipInitialWarmRef = useRef(\n Boolean(hydratedData && !hydratedData.error),\n );\n\n const targetIdentity = useCallback((): string => {\n return getTargetIdentity(target);\n }, [target]);\n\n const buildKey = useCallback(\n (month: Date) =>\n getMonthCacheKey(\n targetIdentity(),\n selectedTimeZone,\n selectedDurationMinutes,\n month,\n ),\n [selectedDurationMinutes, selectedTimeZone, targetIdentity],\n );\n\n const slotsCacheKey = useCallback(\n () =>\n getSlotsCacheKey(\n targetIdentity(),\n selectedTimeZone,\n selectedDurationMinutes,\n ),\n [selectedDurationMinutes, selectedTimeZone, targetIdentity],\n );\n\n const restoreSlotsFromCache = useCallback(() => {\n const cached = slotsCacheRef.current.get(slotsCacheKey()) ?? {};\n slotsByDateRef.current = cached;\n setSlotsByDate(cached);\n }, [slotsCacheKey]);\n\n useLayoutEffect(() => {\n restoreSlotsFromCache();\n }, [restoreSlotsFromCache]);\n\n // Fetch a window of several months in a single request, anchored at `anchor`.\n const fetchWindow = useCallback(\n async (anchor: Date): Promise => {\n const anchorKey = buildKey(anchor);\n\n if (coveredMonthsRef.current.has(anchorKey)) {\n return;\n }\n\n const existingRequest = inFlightMonthsRef.current.get(anchorKey);\n if (existingRequest) {\n await existingRequest;\n return;\n }\n\n const requestVersion = availabilityRequestVersionRef.current;\n const windowMonthKeys = Array.from(\n { length: WINDOW_MONTHS },\n (_, offset) =>\n buildKey(\n new Date(anchor.getFullYear(), anchor.getMonth() + offset, 1),\n ),\n );\n const request = (async () => {\n const durationMinutes =\n getDynamicContext(target) || hasMultiDurationOptionsRef.current\n ? selectedDurationMinutes\n : undefined;\n\n const result = await fetchRawBookerDataAction({\n durationMinutes,\n fetchMeta: resolvedRef.current.eventTypeId == null,\n monthIso: anchor.toISOString(),\n monthsToFetch: WINDOW_MONTHS,\n target,\n timeZone: selectedTimeZone,\n eventTypeId: resolvedRef.current.eventTypeId ?? undefined,\n });\n\n if (!result.ok) {\n throw new BookerDataError(\n result.error,\n result.errorCode ?? \"UNKNOWN\",\n );\n }\n\n if (availabilityRequestVersionRef.current !== requestVersion) {\n return;\n }\n\n resolvedRef.current = result.resolved;\n\n for (let offset = 0; offset < WINDOW_MONTHS; offset += 1) {\n const month = new Date(\n anchor.getFullYear(),\n anchor.getMonth() + offset,\n 1,\n );\n coveredMonthsRef.current.add(buildKey(month));\n }\n\n // The host/event metadata only comes back on the full (non slots-only)\n // fetch, so set it once when present.\n if (result.raw.me) {\n const derived = deriveBookerData(result, target);\n minimumBookingNoticeRef.current = derived.minimumBookingNotice;\n hasMultiDurationOptionsRef.current = derived.hasMultiDurationOptions;\n setMeta((prev) => prev ?? derived.meta);\n\n const defaultDuration = derived.selectedDurationMinutes ?? 30;\n if (derived.meta?.eventTypeDurationOptions?.length) {\n if (\n !derived.meta.eventTypeDurationOptions.includes(\n selectedDurationMinutes,\n )\n ) {\n setSelectedDurationMinutes(defaultDuration);\n }\n } else if (\n derived.meta?.eventTypeDurationMinutes != null &&\n selectedDurationMinutes !== derived.meta.eventTypeDurationMinutes\n ) {\n setSelectedDurationMinutes(derived.meta.eventTypeDurationMinutes);\n }\n }\n\n const windowSlots = filterBookableSlots(\n extractSlotsByDate(result.raw.slots, selectedTimeZone),\n selectedTimeZone,\n minimumBookingNoticeRef.current,\n new Date(),\n );\n const cacheKey = getSlotsCacheKey(\n targetIdentity(),\n selectedTimeZone,\n selectedDurationMinutes,\n );\n const mergedSlots = {\n ...(slotsCacheRef.current.get(cacheKey) ?? {}),\n ...windowSlots,\n };\n slotsCacheRef.current.set(cacheKey, mergedSlots);\n slotsByDateRef.current = mergedSlots;\n setSlotsByDate(mergedSlots);\n })();\n\n for (const monthKey of windowMonthKeys) {\n inFlightMonthsRef.current.set(monthKey, request);\n }\n\n try {\n await request;\n } finally {\n for (const monthKey of windowMonthKeys) {\n if (inFlightMonthsRef.current.get(monthKey) === request) {\n inFlightMonthsRef.current.delete(monthKey);\n }\n }\n }\n },\n [\n buildKey,\n selectedDurationMinutes,\n selectedTimeZone,\n target,\n targetIdentity,\n ],\n );\n\n const todayStart = useMemo(\n () => getTodayInTimeZone(selectedTimeZone),\n [selectedTimeZone],\n );\n const startMonth = useMemo(\n () => getMinimumNavigableMonth(selectedTimeZone, meta?.bookingWindowStart),\n [meta?.bookingWindowStart, selectedTimeZone],\n );\n const calendarViewOptions = useMemo((): CalendarViewOptions => {\n return {\n endMonth: meta?.bookingWindowEnd ?? undefined,\n shiftWeeks: true,\n startMonth,\n weekStartsOn: getWeekStartsOn(locale),\n };\n }, [locale, meta?.bookingWindowEnd, startMonth]);\n\n const hasAvailabilityInView = useMemo(\n () =>\n hasAvailabilityInVisibleGrid(\n currentMonth,\n slotsByDate,\n todayStart,\n calendarViewOptions,\n ),\n [calendarViewOptions, currentMonth, slotsByDate, todayStart],\n );\n\n useEffect(() => {\n let cancelled = false;\n\n const load = async () => {\n const identity = targetIdentity();\n let deferLoadForMonthSync = false;\n\n // A different user/event means a different event type and metadata, so\n // reset everything and force a full fetch.\n if (\n lastIdentityRef.current !== null &&\n lastIdentityRef.current !== identity\n ) {\n coveredMonthsRef.current = new Set();\n inFlightMonthsRef.current.clear();\n availabilityRequestVersionRef.current += 1;\n resolvedRef.current = { eventTypeId: null, eventTypeSlug: null };\n hasMultiDurationOptionsRef.current = false;\n slotsCacheRef.current = new Map();\n slotsByDateRef.current = {};\n autoSelectedMonthRef.current = null;\n setMeta(null);\n setSelectedDurationMinutes(30);\n setSlotsByDate({});\n } else if (\n lastDurationRef.current !== null &&\n lastDurationRef.current !== selectedDurationMinutes\n ) {\n inFlightMonthsRef.current.clear();\n availabilityRequestVersionRef.current += 1;\n restoreSlotsFromCache();\n autoSelectedMonthRef.current = null;\n setSelectedTime(null);\n } else if (\n lastTimeZoneRef.current !== null &&\n lastTimeZoneRef.current !== selectedTimeZone\n ) {\n coveredMonthsRef.current = new Set();\n inFlightMonthsRef.current.clear();\n availabilityRequestVersionRef.current += 1;\n restoreSlotsFromCache();\n autoSelectedMonthRef.current = null;\n setSelectedTime(null);\n\n const minMonth = getMinimumNavigableMonth(\n selectedTimeZone,\n meta?.bookingWindowStart,\n );\n if (isCalendarMonthBefore(currentMonth, minMonth)) {\n setCurrentMonth(minMonth);\n deferLoadForMonthSync = true;\n } else {\n const todayInTz = getTodayInTimeZone(selectedTimeZone);\n if (\n selectedDateRef.current &&\n toCalendarDateKey(selectedDateRef.current) <\n toCalendarDateKey(todayInTz)\n ) {\n setSelectedDate(undefined);\n }\n }\n }\n lastIdentityRef.current = identity;\n lastTimeZoneRef.current = selectedTimeZone;\n lastDurationRef.current = selectedDurationMinutes;\n\n if (deferLoadForMonthSync) {\n return;\n }\n\n if (!coveredMonthsRef.current.has(buildKey(currentMonth))) {\n setIsPending(true);\n try {\n await fetchWindow(currentMonth);\n if (cancelled) {\n return;\n }\n setError(null);\n } catch (caught) {\n if (cancelled) {\n return;\n }\n setError(classifyError(caught));\n return;\n } finally {\n if (!cancelled) {\n setIsPending(false);\n }\n }\n } else {\n setError(null);\n setIsPending(false);\n }\n\n // Auto-select the first available day the first time this month's\n // availability is known (on load and on month navigation).\n const monthKey = `${currentMonth.getFullYear()}-${currentMonth.getMonth()}`;\n if (!cancelled && autoSelectedMonthRef.current !== monthKey) {\n const firstAvailable = pickDefaultSelectedDate(\n currentMonth,\n slotsByDateRef.current,\n selectedTimeZone,\n getTodayInTimeZone(selectedTimeZone),\n calendarViewOptions,\n );\n if (firstAvailable) {\n autoSelectedMonthRef.current = monthKey;\n setSelectedDate(firstAvailable);\n setSelectedTime(null);\n } else {\n autoSelectedMonthRef.current = monthKey;\n setSelectedDate(undefined);\n setSelectedTime(null);\n }\n }\n\n // Warm the next uncovered window boundary, not just the adjacent month.\n // This keeps navigation instant when the user reaches the end of the\n // currently-covered 3-month range.\n const nextWindow = new Date(\n currentMonth.getFullYear(),\n currentMonth.getMonth() + WINDOW_MONTHS,\n 1,\n );\n if (skipInitialWarmRef.current) {\n skipInitialWarmRef.current = false;\n return;\n }\n if (!coveredMonthsRef.current.has(buildKey(nextWindow))) {\n void fetchWindow(nextWindow).catch(() => {});\n }\n };\n\n void load();\n\n return () => {\n cancelled = true;\n };\n }, [\n buildKey,\n currentMonth,\n fetchWindow,\n selectedDurationMinutes,\n selectedTimeZone,\n meta?.bookingWindowStart,\n calendarViewOptions,\n restoreSlotsFromCache,\n targetIdentity,\n ]);\n\n const [retryCounter, setRetryCounter] = useState(0);\n\n const retry = useCallback(() => {\n coveredMonthsRef.current = new Set();\n inFlightMonthsRef.current.clear();\n availabilityRequestVersionRef.current += 1;\n slotsCacheRef.current = new Map();\n slotsByDateRef.current = {};\n autoSelectedMonthRef.current = null;\n setSlotsByDate({});\n setError(null);\n setRetryCounter((c) => c + 1);\n }, []);\n\n // Re-trigger the main load effect when the user retries.\n useEffect(() => {\n // The first render (retryCounter === 0) is handled by the main effect.\n if (retryCounter === 0) return;\n setIsPending(true);\n let cancelled = false;\n\n (async () => {\n try {\n await fetchWindow(currentMonth);\n if (cancelled) return;\n setError(null);\n\n const monthKey = `${currentMonth.getFullYear()}-${currentMonth.getMonth()}`;\n if (autoSelectedMonthRef.current !== monthKey) {\n const firstAvailable = pickDefaultSelectedDate(\n currentMonth,\n slotsByDateRef.current,\n selectedTimeZone,\n getTodayInTimeZone(selectedTimeZone),\n calendarViewOptions,\n );\n if (firstAvailable) {\n autoSelectedMonthRef.current = monthKey;\n setSelectedDate(firstAvailable);\n setSelectedTime(null);\n } else {\n autoSelectedMonthRef.current = monthKey;\n setSelectedDate(undefined);\n setSelectedTime(null);\n }\n }\n } catch (caught) {\n if (cancelled) return;\n setError(classifyError(caught));\n } finally {\n if (!cancelled) setIsPending(false);\n }\n })();\n\n return () => {\n cancelled = true;\n };\n }, [\n retryCounter,\n fetchWindow,\n currentMonth,\n selectedTimeZone,\n calendarViewOptions,\n ]);\n\n useEffect(() => {\n if (!timezone) {\n return;\n }\n setSelectedTimeZone(timezone);\n }, [timezone]);\n\n const selectedDateKey = toCalendarDateKey(selectedDate ?? todayStart);\n const daySlots =\n selectedDate != null ? (slotsByDate[selectedDateKey] ?? []) : [];\n const nextAvailableDate = findNextAvailableDate(\n slotsByDate,\n selectedTimeZone,\n todayStart,\n );\n\n const handleMonthChange = (month: Date) => {\n const monthKey = `${month.getFullYear()}-${month.getMonth()}`;\n const monthCovered = coveredMonthsRef.current.has(buildKey(month));\n setCurrentMonth(month);\n // Flip to pending in the same render as the month change when the target\n // month still needs fetching, so the skeleton shows immediately instead of\n // the empty state flashing for a frame before the async effect runs.\n if (!monthCovered) {\n setIsPending(true);\n }\n // For an already-loaded month, select the first available day in the same\n // update as the month change so the selection doesn't flicker.\n const firstAvailable = pickDefaultSelectedDate(\n month,\n slotsByDateRef.current,\n selectedTimeZone,\n getTodayInTimeZone(selectedTimeZone),\n calendarViewOptions,\n );\n if (firstAvailable) {\n autoSelectedMonthRef.current = monthKey;\n setSelectedDate(firstAvailable);\n setSelectedTime(null);\n } else {\n if (monthCovered) {\n autoSelectedMonthRef.current = monthKey;\n }\n setSelectedDate(undefined);\n setSelectedTime(null);\n }\n };\n\n const handleSelectDate = (nextDate: Date | undefined) => {\n if (!nextDate) {\n return;\n }\n setSelectedDate(nextDate);\n setSelectedTime(null);\n autoSelectedMonthRef.current = `${nextDate.getFullYear()}-${nextDate.getMonth()}`;\n const isDifferentMonth =\n nextDate.getMonth() !== currentMonth.getMonth() ||\n nextDate.getFullYear() !== currentMonth.getFullYear();\n if (isDifferentMonth) {\n setCurrentMonth(nextDate);\n }\n };\n\n const isDayDisabled = (date: Date) => {\n const dayKey = toCalendarDateKey(date);\n if (dayKey < toCalendarDateKey(todayStart)) {\n return true;\n }\n\n return (slotsByDate[dayKey] ?? []).length === 0;\n };\n\n const goToDate = (date: Date) => {\n setCurrentMonth(date);\n setSelectedDate(date);\n setSelectedTime(null);\n autoSelectedMonthRef.current = `${date.getFullYear()}-${date.getMonth()}`;\n };\n\n const handleSelectTime = (time: string) => {\n setSelectedTime(time);\n setStep(\"confirm\");\n };\n\n const handleBack = () => {\n setStep(\"select\");\n };\n\n const initialLoading = !meta;\n const currentMonthCovered = coveredMonthsRef.current.has(\n buildKey(currentMonth),\n );\n const availabilityLoading = isPending || !currentMonthCovered;\n\n return {\n calendarProps: {\n availabilityLoading,\n disabled: isDayDisabled,\n endMonth: meta?.bookingWindowEnd ?? undefined,\n initialLoading,\n locale,\n month: currentMonth,\n onMonthChange: handleMonthChange,\n onSelect: handleSelectDate,\n selected: selectedDate,\n startMonth,\n today: todayStart,\n },\n durationProps: {\n onValueChange: setSelectedDurationMinutes,\n value: selectedDurationMinutes,\n },\n error,\n loadingState: {\n availability: availabilityLoading,\n busy: initialLoading || availabilityLoading,\n initial: initialLoading,\n },\n meta,\n onBack: handleBack,\n retry,\n selectedTime: _selectedTime,\n step,\n timePickerProps: {\n availabilityLoading,\n currentMonth,\n daySlots,\n goToDate,\n hasAvailabilityInView,\n initialLoading,\n is24Hour,\n locale,\n nextAvailableDate,\n onIs24HourChange: setIs24Hour,\n onSelectTime: handleSelectTime,\n selectedDate,\n },\n timezoneProps: {\n onValueChange: setSelectedTimeZone,\n value: selectedTimeZone,\n },\n };\n}\n", + "content": "\"use client\";\n\nimport {\n useCallback,\n useEffect,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport {\n type FetchRawBookerResult,\n fetchRawBookerDataAction,\n} from \"@/lib/booker/actions\";\nimport {\n type BookerTarget,\n getDynamicContext,\n getOrgSlugFromTarget,\n} from \"@/lib/booker/target\";\nimport {\n type BookerMeta,\n type CalendarViewOptions,\n extractBookingWindowEnd,\n extractBookingWindowStart,\n extractEventTypeInfo,\n extractHost,\n extractHostAvatars,\n extractHostDisplayName,\n extractMinimumBookingNotice,\n extractSlotsByDate,\n filterBookableSlots,\n findNextAvailableDate,\n getMinimumNavigableMonth,\n getTodayInTimeZone,\n getWeekStartsOn,\n hasAvailabilityInVisibleGrid,\n isCalendarMonthBefore,\n pickDefaultSelectedDate,\n prefers24Hour,\n toCalendarDateKey,\n} from \"@/lib/booker/utils\";\n\nconst WINDOW_MONTHS = 3;\n\nexport type BookerErrorKind =\n | \"not-found\"\n | \"unpublished\"\n | \"network\"\n | \"unknown\";\n\nexport type BookerError = {\n kind: BookerErrorKind;\n message: string;\n};\n\nclass BookerDataError extends Error {\n code: string;\n constructor(message: string, code: string) {\n super(message);\n this.code = code;\n }\n}\n\ntype ResolvedEventType = Extract<\n FetchRawBookerResult,\n { ok: true }\n>[\"resolved\"];\n\nexport type BookerInitialData = {\n durationMinutes?: number;\n monthIso: string;\n monthsToFetch?: number;\n result: FetchRawBookerResult;\n timeZone: string;\n};\n\ntype DerivedBookerData = {\n hasMultiDurationOptions: boolean;\n meta: BookerMeta | null;\n minimumBookingNotice: number;\n selectedDurationMinutes: number | null;\n};\n\ntype HydratedBookerData = DerivedBookerData & {\n coveredMonthKeys: Set;\n error: BookerError | null;\n resolved: ResolvedEventType;\n slotsByDate: Record;\n slotsCacheKey: string;\n};\n\nfunction classifyError(caught: unknown): BookerError {\n const code = caught instanceof BookerDataError ? caught.code : \"\";\n const message =\n caught instanceof Error ? caught.message : \"Could not load booker data.\";\n\n if (code === \"UNPUBLISHED\") {\n return { kind: \"unpublished\", message };\n }\n if (code === \"NOT_FOUND\") {\n return { kind: \"not-found\", message };\n }\n if (\n code.startsWith(\"HTTP_4\") ||\n code.startsWith(\"HTTP_5\") ||\n message.includes(\"fetch\") ||\n message.includes(\"network\") ||\n message.includes(\"ETIMEDOUT\") ||\n message.includes(\"ECONNREFUSED\") ||\n /Cal\\.com API [45]\\d\\d/.test(message)\n ) {\n return { kind: \"network\", message };\n }\n return { kind: \"unknown\", message };\n}\n\nfunction getTargetIdentity(target: BookerTarget): string {\n if (target.type === \"user\") {\n return `user:${target.username}:${target.eventSlug}:${target.orgId ?? \"\"}`;\n }\n if (target.type === \"team\") {\n return `team:${target.teamId}:${target.eventSlug}:${target.orgId ?? \"\"}`;\n }\n if (target.type === \"dynamic\") {\n return `dynamic:${target.usernames.join(\",\")}:${target.orgSlug ?? \"\"}:${target.orgId ?? \"\"}`;\n }\n return `link:${target.bookingUrl}`;\n}\n\nfunction getMonthCacheKey(\n identity: string,\n timeZone: string,\n durationMinutes: number,\n month: Date,\n): string {\n return `${identity}|${timeZone}|${durationMinutes}|${month.getFullYear()}-${month.getMonth()}`;\n}\n\nfunction getSlotsCacheKey(\n identity: string,\n timeZone: string,\n durationMinutes: number,\n): string {\n return `${identity}|${timeZone}|${durationMinutes}`;\n}\n\nfunction deriveBookerData(\n result: Extract,\n target: BookerTarget,\n): DerivedBookerData {\n if (!result.raw.me) {\n return {\n hasMultiDurationOptions: false,\n meta: null,\n minimumBookingNotice: 0,\n selectedDurationMinutes: null,\n };\n }\n\n const host = extractHost(result.raw.me);\n const isDynamic = Boolean(getDynamicContext(target));\n const hostAvatars = extractHostAvatars(\n result.raw.selectedEventType,\n result.raw.me,\n { dynamic: isDynamic, orgSlug: getOrgSlugFromTarget(target) },\n );\n const hostName = extractHostDisplayName(\n result.raw.selectedEventType,\n result.raw.me,\n { dynamic: isDynamic },\n );\n const eventType = extractEventTypeInfo(result.raw.selectedEventType);\n const minimumBookingNotice = extractMinimumBookingNotice(\n result.raw.selectedEventType,\n );\n const hasMultiDurationOptions =\n (eventType.eventTypeDurationOptions?.length ?? 0) > 1;\n const selectedDurationMinutes =\n eventType.eventTypeDurationOptions?.[0] ??\n eventType.eventTypeDurationMinutes ??\n null;\n\n return {\n hasMultiDurationOptions,\n meta: {\n bookingWindowEnd: extractBookingWindowEnd(result.raw.selectedEventType),\n bookingWindowStart: extractBookingWindowStart(\n result.raw.selectedEventType,\n ),\n eventTypeDescription: eventType.eventTypeDescription,\n eventTypeDurationMinutes: eventType.eventTypeDurationMinutes,\n eventTypeDurationOptions: eventType.eventTypeDurationOptions,\n eventTypeImageUrl: result.raw.bannerUrl || eventType.eventTypeImageUrl,\n eventTypeLocations: eventType.eventTypeLocations,\n eventTypeTitle: eventType.eventTypeTitle,\n hostAvatarUrl: host.hostAvatarUrl,\n hostAvatars,\n hostName:\n host.hostName === \"Unknown user\" && target.type === \"user\"\n ? target.username\n : result.raw.publicDisplayName || hostName,\n },\n minimumBookingNotice,\n selectedDurationMinutes,\n };\n}\n\nfunction hydrateBookerData({\n initialData,\n selectedTimeZone,\n target,\n}: {\n initialData?: BookerInitialData;\n selectedTimeZone: string;\n target: BookerTarget;\n}): HydratedBookerData | null {\n if (!initialData) {\n return null;\n }\n\n if (initialData.timeZone !== selectedTimeZone) {\n return null;\n }\n\n if (!initialData.result.ok) {\n const error = classifyError(\n new BookerDataError(\n initialData.result.error,\n initialData.result.errorCode ?? \"UNKNOWN\",\n ),\n );\n\n if (error.kind !== \"not-found\" && error.kind !== \"unpublished\") {\n return null;\n }\n\n return {\n coveredMonthKeys: new Set(),\n error,\n hasMultiDurationOptions: false,\n meta: null,\n minimumBookingNotice: 0,\n resolved: { eventTypeId: null, eventTypeSlug: null },\n selectedDurationMinutes: null,\n slotsByDate: {},\n slotsCacheKey: \"\",\n };\n }\n\n const derived = deriveBookerData(initialData.result, target);\n const durationMinutes =\n derived.selectedDurationMinutes ?? initialData.durationMinutes ?? 30;\n const identity = getTargetIdentity(target);\n const anchor = new Date(initialData.monthIso);\n const safeAnchor = Number.isNaN(anchor.getTime()) ? new Date() : anchor;\n const monthsToFetch = Math.max(\n 1,\n Math.floor(initialData.monthsToFetch ?? WINDOW_MONTHS),\n );\n const coveredMonthKeys = new Set();\n\n for (let offset = 0; offset < monthsToFetch; offset += 1) {\n coveredMonthKeys.add(\n getMonthCacheKey(\n identity,\n selectedTimeZone,\n durationMinutes,\n new Date(safeAnchor.getFullYear(), safeAnchor.getMonth() + offset, 1),\n ),\n );\n }\n\n const slotsByDate = filterBookableSlots(\n extractSlotsByDate(initialData.result.raw.slots, selectedTimeZone),\n selectedTimeZone,\n derived.minimumBookingNotice,\n new Date(),\n );\n\n return {\n ...derived,\n coveredMonthKeys,\n error: null,\n resolved: initialData.result.resolved,\n selectedDurationMinutes: durationMinutes,\n slotsByDate,\n slotsCacheKey: getSlotsCacheKey(\n identity,\n selectedTimeZone,\n durationMinutes,\n ),\n };\n}\n\ntype UseBookerParams = {\n initialData?: BookerInitialData;\n target: BookerTarget;\n timezone?: string;\n};\n\ntype BookerLoadingState = {\n availability: boolean;\n busy: boolean;\n initial: boolean;\n};\n\ntype BookerTimezoneState = {\n onValueChange: (timeZone: string) => void;\n value: string;\n};\n\ntype BookerDurationState = {\n onValueChange: (minutes: number) => void;\n value: number;\n};\n\ntype BookerCalendarState = {\n availabilityLoading: boolean;\n disabled: (date: Date) => boolean;\n endMonth?: Date;\n initialLoading: boolean;\n locale: string;\n month: Date;\n onMonthChange: (month: Date) => void;\n onSelect: (nextDate: Date | undefined) => void;\n selected: Date | undefined;\n startMonth: Date;\n today: Date;\n};\n\ntype BookerTimePickerState = {\n availabilityLoading: boolean;\n currentMonth: Date;\n daySlots: string[];\n goToDate: (date: Date) => void;\n hasAvailabilityInView: boolean;\n initialLoading: boolean;\n is24Hour: boolean;\n locale: string;\n nextAvailableDate: Date | null;\n onIs24HourChange: (value: boolean) => void;\n onSelectTime: (time: string) => void;\n selectedDate: Date | undefined;\n};\n\nexport type UseBookerResult = {\n calendarProps: BookerCalendarState;\n durationProps: BookerDurationState;\n error: BookerError | null;\n loadingState: BookerLoadingState;\n meta: BookerMeta | null;\n retry: () => void;\n timePickerProps: BookerTimePickerState;\n timezoneProps: BookerTimezoneState;\n};\n\n// All booker data + interaction logic: fetching/windowing cal.com availability,\n// derived selection state, and the handlers the UI wires to the calendar.\nexport function useBooker({\n initialData,\n target,\n timezone,\n}: UseBookerParams): UseBookerResult {\n const [timeZone] = useState(\n () => Intl.DateTimeFormat().resolvedOptions().timeZone || \"UTC\",\n );\n const [locale] = useState(\n () => Intl.DateTimeFormat().resolvedOptions().locale || \"en-US\",\n );\n const initialSelectedTimeZone = initialData?.timeZone ?? timezone ?? timeZone;\n const hydratedData = useMemo(\n () =>\n hydrateBookerData({\n initialData,\n selectedTimeZone: initialSelectedTimeZone,\n target,\n }),\n [initialData, initialSelectedTimeZone, target],\n );\n const initialCurrentMonth = useMemo(() => {\n const initialMonth = initialData?.monthIso\n ? new Date(initialData.monthIso)\n : new Date();\n return Number.isNaN(initialMonth.getTime()) ? new Date() : initialMonth;\n }, [initialData?.monthIso]);\n const initialSelectedDate = useMemo(() => {\n if (!hydratedData || hydratedData.error) {\n return undefined;\n }\n\n return (\n pickDefaultSelectedDate(\n initialCurrentMonth,\n hydratedData.slotsByDate,\n initialSelectedTimeZone,\n getTodayInTimeZone(initialSelectedTimeZone),\n {\n endMonth: hydratedData.meta?.bookingWindowEnd ?? undefined,\n shiftWeeks: true,\n startMonth: getMinimumNavigableMonth(\n initialSelectedTimeZone,\n hydratedData.meta?.bookingWindowStart,\n ),\n weekStartsOn: getWeekStartsOn(locale),\n },\n ) ?? undefined\n );\n }, [hydratedData, initialCurrentMonth, initialSelectedTimeZone, locale]);\n const [meta, setMeta] = useState(\n hydratedData?.meta ?? null,\n );\n const [slotsByDate, setSlotsByDate] = useState>(\n hydratedData?.slotsByDate ?? {},\n );\n const [error, setError] = useState(\n hydratedData?.error ?? null,\n );\n const [isPending, setIsPending] = useState(false);\n const [currentMonth, setCurrentMonth] = useState(initialCurrentMonth);\n const [selectedTimeZone, setSelectedTimeZone] = useState(\n initialSelectedTimeZone,\n );\n const [selectedDate, setSelectedDate] = useState(\n initialSelectedDate,\n );\n const [_selectedTime, setSelectedTime] = useState(null);\n const [selectedDurationMinutes, setSelectedDurationMinutes] = useState(\n hydratedData?.selectedDurationMinutes ?? 30,\n );\n const [is24Hour, setIs24Hour] = useState(() => prefers24Hour(locale));\n const coveredMonthsRef = useRef>(\n hydratedData?.coveredMonthKeys ?? new Set(),\n );\n const resolvedRef = useRef(\n hydratedData?.resolved ?? { eventTypeId: null, eventTypeSlug: null },\n );\n const lastIdentityRef = useRef(null);\n const lastTimeZoneRef = useRef(null);\n const lastDurationRef = useRef(null);\n const autoSelectedMonthRef = useRef(\n initialSelectedDate\n ? `${initialCurrentMonth.getFullYear()}-${initialCurrentMonth.getMonth()}`\n : null,\n );\n const slotsByDateRef = useRef>(\n hydratedData?.slotsByDate ?? {},\n );\n const slotsCacheRef = useRef>>(\n new Map(\n hydratedData?.slotsCacheKey\n ? [[hydratedData.slotsCacheKey, hydratedData.slotsByDate]]\n : [],\n ),\n );\n const minimumBookingNoticeRef = useRef(\n hydratedData?.minimumBookingNotice ?? 0,\n );\n const hasMultiDurationOptionsRef = useRef(\n hydratedData?.hasMultiDurationOptions ?? false,\n );\n const selectedDateRef = useRef(selectedDate);\n selectedDateRef.current = selectedDate;\n const inFlightMonthsRef = useRef>>(new Map());\n const availabilityRequestVersionRef = useRef(0);\n const skipInitialWarmRef = useRef(\n Boolean(hydratedData && !hydratedData.error),\n );\n\n const targetIdentity = useCallback((): string => {\n return getTargetIdentity(target);\n }, [target]);\n\n const buildKey = useCallback(\n (month: Date) =>\n getMonthCacheKey(\n targetIdentity(),\n selectedTimeZone,\n selectedDurationMinutes,\n month,\n ),\n [selectedDurationMinutes, selectedTimeZone, targetIdentity],\n );\n\n const slotsCacheKey = useCallback(\n () =>\n getSlotsCacheKey(\n targetIdentity(),\n selectedTimeZone,\n selectedDurationMinutes,\n ),\n [selectedDurationMinutes, selectedTimeZone, targetIdentity],\n );\n\n const restoreSlotsFromCache = useCallback(() => {\n const cached = slotsCacheRef.current.get(slotsCacheKey()) ?? {};\n slotsByDateRef.current = cached;\n setSlotsByDate(cached);\n }, [slotsCacheKey]);\n\n useLayoutEffect(() => {\n restoreSlotsFromCache();\n }, [restoreSlotsFromCache]);\n\n // Fetch a window of several months in a single request, anchored at `anchor`.\n const fetchWindow = useCallback(\n async (anchor: Date): Promise => {\n const anchorKey = buildKey(anchor);\n\n if (coveredMonthsRef.current.has(anchorKey)) {\n return;\n }\n\n const existingRequest = inFlightMonthsRef.current.get(anchorKey);\n if (existingRequest) {\n await existingRequest;\n return;\n }\n\n const requestVersion = availabilityRequestVersionRef.current;\n const windowMonthKeys = Array.from(\n { length: WINDOW_MONTHS },\n (_, offset) =>\n buildKey(\n new Date(anchor.getFullYear(), anchor.getMonth() + offset, 1),\n ),\n );\n const request = (async () => {\n const durationMinutes =\n getDynamicContext(target) || hasMultiDurationOptionsRef.current\n ? selectedDurationMinutes\n : undefined;\n\n const result = await fetchRawBookerDataAction({\n durationMinutes,\n fetchMeta: resolvedRef.current.eventTypeId == null,\n monthIso: anchor.toISOString(),\n monthsToFetch: WINDOW_MONTHS,\n target,\n timeZone: selectedTimeZone,\n eventTypeId: resolvedRef.current.eventTypeId ?? undefined,\n });\n\n if (!result.ok) {\n throw new BookerDataError(\n result.error,\n result.errorCode ?? \"UNKNOWN\",\n );\n }\n\n if (availabilityRequestVersionRef.current !== requestVersion) {\n return;\n }\n\n resolvedRef.current = result.resolved;\n\n for (let offset = 0; offset < WINDOW_MONTHS; offset += 1) {\n const month = new Date(\n anchor.getFullYear(),\n anchor.getMonth() + offset,\n 1,\n );\n coveredMonthsRef.current.add(buildKey(month));\n }\n\n // The host/event metadata only comes back on the full (non slots-only)\n // fetch, so set it once when present.\n if (result.raw.me) {\n const derived = deriveBookerData(result, target);\n minimumBookingNoticeRef.current = derived.minimumBookingNotice;\n hasMultiDurationOptionsRef.current = derived.hasMultiDurationOptions;\n setMeta((prev) => prev ?? derived.meta);\n\n const defaultDuration = derived.selectedDurationMinutes ?? 30;\n if (derived.meta?.eventTypeDurationOptions?.length) {\n if (\n !derived.meta.eventTypeDurationOptions.includes(\n selectedDurationMinutes,\n )\n ) {\n setSelectedDurationMinutes(defaultDuration);\n }\n } else if (\n derived.meta?.eventTypeDurationMinutes != null &&\n selectedDurationMinutes !== derived.meta.eventTypeDurationMinutes\n ) {\n setSelectedDurationMinutes(derived.meta.eventTypeDurationMinutes);\n }\n }\n\n const windowSlots = filterBookableSlots(\n extractSlotsByDate(result.raw.slots, selectedTimeZone),\n selectedTimeZone,\n minimumBookingNoticeRef.current,\n new Date(),\n );\n const cacheKey = getSlotsCacheKey(\n targetIdentity(),\n selectedTimeZone,\n selectedDurationMinutes,\n );\n const mergedSlots = {\n ...(slotsCacheRef.current.get(cacheKey) ?? {}),\n ...windowSlots,\n };\n slotsCacheRef.current.set(cacheKey, mergedSlots);\n slotsByDateRef.current = mergedSlots;\n setSlotsByDate(mergedSlots);\n })();\n\n for (const monthKey of windowMonthKeys) {\n inFlightMonthsRef.current.set(monthKey, request);\n }\n\n try {\n await request;\n } finally {\n for (const monthKey of windowMonthKeys) {\n if (inFlightMonthsRef.current.get(monthKey) === request) {\n inFlightMonthsRef.current.delete(monthKey);\n }\n }\n }\n },\n [\n buildKey,\n selectedDurationMinutes,\n selectedTimeZone,\n target,\n targetIdentity,\n ],\n );\n\n const todayStart = useMemo(\n () => getTodayInTimeZone(selectedTimeZone),\n [selectedTimeZone],\n );\n const startMonth = useMemo(\n () => getMinimumNavigableMonth(selectedTimeZone, meta?.bookingWindowStart),\n [meta?.bookingWindowStart, selectedTimeZone],\n );\n const calendarViewOptions = useMemo((): CalendarViewOptions => {\n return {\n endMonth: meta?.bookingWindowEnd ?? undefined,\n shiftWeeks: true,\n startMonth,\n weekStartsOn: getWeekStartsOn(locale),\n };\n }, [locale, meta?.bookingWindowEnd, startMonth]);\n\n const hasAvailabilityInView = useMemo(\n () =>\n hasAvailabilityInVisibleGrid(\n currentMonth,\n slotsByDate,\n todayStart,\n calendarViewOptions,\n ),\n [calendarViewOptions, currentMonth, slotsByDate, todayStart],\n );\n\n useEffect(() => {\n let cancelled = false;\n\n const load = async () => {\n const identity = targetIdentity();\n let deferLoadForMonthSync = false;\n\n // A different user/event means a different event type and metadata, so\n // reset everything and force a full fetch.\n if (\n lastIdentityRef.current !== null &&\n lastIdentityRef.current !== identity\n ) {\n coveredMonthsRef.current = new Set();\n inFlightMonthsRef.current.clear();\n availabilityRequestVersionRef.current += 1;\n resolvedRef.current = { eventTypeId: null, eventTypeSlug: null };\n hasMultiDurationOptionsRef.current = false;\n slotsCacheRef.current = new Map();\n slotsByDateRef.current = {};\n autoSelectedMonthRef.current = null;\n setMeta(null);\n setSelectedDurationMinutes(30);\n setSlotsByDate({});\n } else if (\n lastDurationRef.current !== null &&\n lastDurationRef.current !== selectedDurationMinutes\n ) {\n inFlightMonthsRef.current.clear();\n availabilityRequestVersionRef.current += 1;\n restoreSlotsFromCache();\n autoSelectedMonthRef.current = null;\n setSelectedTime(null);\n } else if (\n lastTimeZoneRef.current !== null &&\n lastTimeZoneRef.current !== selectedTimeZone\n ) {\n coveredMonthsRef.current = new Set();\n inFlightMonthsRef.current.clear();\n availabilityRequestVersionRef.current += 1;\n restoreSlotsFromCache();\n autoSelectedMonthRef.current = null;\n setSelectedTime(null);\n\n const minMonth = getMinimumNavigableMonth(\n selectedTimeZone,\n meta?.bookingWindowStart,\n );\n if (isCalendarMonthBefore(currentMonth, minMonth)) {\n setCurrentMonth(minMonth);\n deferLoadForMonthSync = true;\n } else {\n const todayInTz = getTodayInTimeZone(selectedTimeZone);\n if (\n selectedDateRef.current &&\n toCalendarDateKey(selectedDateRef.current) <\n toCalendarDateKey(todayInTz)\n ) {\n setSelectedDate(undefined);\n }\n }\n }\n lastIdentityRef.current = identity;\n lastTimeZoneRef.current = selectedTimeZone;\n lastDurationRef.current = selectedDurationMinutes;\n\n if (deferLoadForMonthSync) {\n return;\n }\n\n if (!coveredMonthsRef.current.has(buildKey(currentMonth))) {\n setIsPending(true);\n try {\n await fetchWindow(currentMonth);\n if (cancelled) {\n return;\n }\n setError(null);\n } catch (caught) {\n if (cancelled) {\n return;\n }\n setError(classifyError(caught));\n return;\n } finally {\n if (!cancelled) {\n setIsPending(false);\n }\n }\n } else {\n setError(null);\n setIsPending(false);\n }\n\n // Auto-select the first available day the first time this month's\n // availability is known (on load and on month navigation).\n const monthKey = `${currentMonth.getFullYear()}-${currentMonth.getMonth()}`;\n if (!cancelled && autoSelectedMonthRef.current !== monthKey) {\n const firstAvailable = pickDefaultSelectedDate(\n currentMonth,\n slotsByDateRef.current,\n selectedTimeZone,\n getTodayInTimeZone(selectedTimeZone),\n calendarViewOptions,\n );\n if (firstAvailable) {\n autoSelectedMonthRef.current = monthKey;\n setSelectedDate(firstAvailable);\n setSelectedTime(null);\n } else {\n autoSelectedMonthRef.current = monthKey;\n setSelectedDate(undefined);\n setSelectedTime(null);\n }\n }\n\n // Warm the next uncovered window boundary, not just the adjacent month.\n // This keeps navigation instant when the user reaches the end of the\n // currently-covered 3-month range.\n const nextWindow = new Date(\n currentMonth.getFullYear(),\n currentMonth.getMonth() + WINDOW_MONTHS,\n 1,\n );\n if (skipInitialWarmRef.current) {\n skipInitialWarmRef.current = false;\n return;\n }\n if (!coveredMonthsRef.current.has(buildKey(nextWindow))) {\n void fetchWindow(nextWindow).catch(() => {});\n }\n };\n\n void load();\n\n return () => {\n cancelled = true;\n };\n }, [\n buildKey,\n currentMonth,\n fetchWindow,\n selectedDurationMinutes,\n selectedTimeZone,\n meta?.bookingWindowStart,\n calendarViewOptions,\n restoreSlotsFromCache,\n targetIdentity,\n ]);\n\n const [retryCounter, setRetryCounter] = useState(0);\n\n const retry = useCallback(() => {\n coveredMonthsRef.current = new Set();\n inFlightMonthsRef.current.clear();\n availabilityRequestVersionRef.current += 1;\n slotsCacheRef.current = new Map();\n slotsByDateRef.current = {};\n autoSelectedMonthRef.current = null;\n setSlotsByDate({});\n setError(null);\n setRetryCounter((c) => c + 1);\n }, []);\n\n // Re-trigger the main load effect when the user retries.\n useEffect(() => {\n // The first render (retryCounter === 0) is handled by the main effect.\n if (retryCounter === 0) return;\n setIsPending(true);\n let cancelled = false;\n\n (async () => {\n try {\n await fetchWindow(currentMonth);\n if (cancelled) return;\n setError(null);\n\n const monthKey = `${currentMonth.getFullYear()}-${currentMonth.getMonth()}`;\n if (autoSelectedMonthRef.current !== monthKey) {\n const firstAvailable = pickDefaultSelectedDate(\n currentMonth,\n slotsByDateRef.current,\n selectedTimeZone,\n getTodayInTimeZone(selectedTimeZone),\n calendarViewOptions,\n );\n if (firstAvailable) {\n autoSelectedMonthRef.current = monthKey;\n setSelectedDate(firstAvailable);\n setSelectedTime(null);\n } else {\n autoSelectedMonthRef.current = monthKey;\n setSelectedDate(undefined);\n setSelectedTime(null);\n }\n }\n } catch (caught) {\n if (cancelled) return;\n setError(classifyError(caught));\n } finally {\n if (!cancelled) setIsPending(false);\n }\n })();\n\n return () => {\n cancelled = true;\n };\n }, [\n retryCounter,\n fetchWindow,\n currentMonth,\n selectedTimeZone,\n calendarViewOptions,\n ]);\n\n useEffect(() => {\n if (!timezone) {\n return;\n }\n setSelectedTimeZone(timezone);\n }, [timezone]);\n\n const selectedDateKey = toCalendarDateKey(selectedDate ?? todayStart);\n const daySlots =\n selectedDate != null ? (slotsByDate[selectedDateKey] ?? []) : [];\n const nextAvailableDate = findNextAvailableDate(\n slotsByDate,\n selectedTimeZone,\n todayStart,\n );\n\n const handleMonthChange = (month: Date) => {\n const monthKey = `${month.getFullYear()}-${month.getMonth()}`;\n const monthCovered = coveredMonthsRef.current.has(buildKey(month));\n setCurrentMonth(month);\n // Flip to pending in the same render as the month change when the target\n // month still needs fetching, so the skeleton shows immediately instead of\n // the empty state flashing for a frame before the async effect runs.\n if (!monthCovered) {\n setIsPending(true);\n }\n // For an already-loaded month, select the first available day in the same\n // update as the month change so the selection doesn't flicker.\n const firstAvailable = pickDefaultSelectedDate(\n month,\n slotsByDateRef.current,\n selectedTimeZone,\n getTodayInTimeZone(selectedTimeZone),\n calendarViewOptions,\n );\n if (firstAvailable) {\n autoSelectedMonthRef.current = monthKey;\n setSelectedDate(firstAvailable);\n setSelectedTime(null);\n } else {\n if (monthCovered) {\n autoSelectedMonthRef.current = monthKey;\n }\n setSelectedDate(undefined);\n setSelectedTime(null);\n }\n };\n\n const handleSelectDate = (nextDate: Date | undefined) => {\n if (!nextDate) {\n return;\n }\n setSelectedDate(nextDate);\n setSelectedTime(null);\n autoSelectedMonthRef.current = `${nextDate.getFullYear()}-${nextDate.getMonth()}`;\n const isDifferentMonth =\n nextDate.getMonth() !== currentMonth.getMonth() ||\n nextDate.getFullYear() !== currentMonth.getFullYear();\n if (isDifferentMonth) {\n setCurrentMonth(nextDate);\n }\n };\n\n const isDayDisabled = (date: Date) => {\n const dayKey = toCalendarDateKey(date);\n if (dayKey < toCalendarDateKey(todayStart)) {\n return true;\n }\n\n return (slotsByDate[dayKey] ?? []).length === 0;\n };\n\n const goToDate = (date: Date) => {\n setCurrentMonth(date);\n setSelectedDate(date);\n setSelectedTime(null);\n autoSelectedMonthRef.current = `${date.getFullYear()}-${date.getMonth()}`;\n };\n\n const initialLoading = !meta;\n const currentMonthCovered = coveredMonthsRef.current.has(\n buildKey(currentMonth),\n );\n const availabilityLoading = isPending || !currentMonthCovered;\n\n return {\n calendarProps: {\n availabilityLoading,\n disabled: isDayDisabled,\n endMonth: meta?.bookingWindowEnd ?? undefined,\n initialLoading,\n locale,\n month: currentMonth,\n onMonthChange: handleMonthChange,\n onSelect: handleSelectDate,\n selected: selectedDate,\n startMonth,\n today: todayStart,\n },\n durationProps: {\n onValueChange: setSelectedDurationMinutes,\n value: selectedDurationMinutes,\n },\n error,\n loadingState: {\n availability: availabilityLoading,\n busy: initialLoading || availabilityLoading,\n initial: initialLoading,\n },\n meta,\n retry,\n timePickerProps: {\n availabilityLoading,\n currentMonth,\n daySlots,\n goToDate,\n hasAvailabilityInView,\n initialLoading,\n is24Hour,\n locale,\n nextAvailableDate,\n onIs24HourChange: setIs24Hour,\n onSelectTime: setSelectedTime,\n selectedDate,\n },\n timezoneProps: {\n onValueChange: setSelectedTimeZone,\n value: selectedTimeZone,\n },\n };\n}\n", "type": "registry:lib", "target": "lib/booker/use-booker.ts" }, diff --git a/apps/ui/public/r/registry.json b/apps/ui/public/r/registry.json index 6be348fd5..6d11f0b7c 100644 --- a/apps/ui/public/r/registry.json +++ b/apps/ui/public/r/registry.json @@ -46,7 +46,6 @@ "@coss/separator", "@coss/sheet", "@coss/sidebar", - "@coss/size-transition", "@coss/skeleton", "@coss/slider", "@coss/spinner", @@ -729,19 +728,6 @@ ], "type": "registry:ui" }, - { - "dependencies": [ - "@base-ui/react" - ], - "files": [ - { - "path": "registry/default/ui/size-transition.tsx", - "type": "registry:ui" - } - ], - "name": "size-transition", - "type": "registry:ui" - }, { "css": { "@keyframes skeleton": { @@ -11430,12 +11416,10 @@ "@coss/card", "@coss/combobox", "@coss/empty", - "@coss/input", "@coss/label", "@coss/popover", "@coss/scroll-area", "@coss/select", - "@coss/size-transition", "@coss/skeleton", "@coss/switch", "@coss/tooltip" diff --git a/apps/ui/public/r/size-transition.json b/apps/ui/public/r/size-transition.json deleted file mode 100644 index 5775ae04a..000000000 --- a/apps/ui/public/r/size-transition.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema/registry-item.json", - "name": "size-transition", - "dependencies": [ - "@base-ui/react" - ], - "files": [ - { - "path": "registry/default/ui/size-transition.tsx", - "content": "\"use client\";\n\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport * as React from \"react\";\nimport * as ReactDOM from \"react-dom\";\n\nconst useIsoLayoutEffect =\n typeof window === \"undefined\" ? React.useEffect : React.useLayoutEffect;\n\ntype Size = { width: number; height: number };\n\nexport type SizeTransitionAxis = \"both\" | \"width\" | \"height\";\n\nexport type SizeTransitionProps = useRender.ComponentProps<\"div\"> & {\n /**\n * The value whose change triggers a transition. When it changes, the\n * outgoing content is cross-faded into the incoming `children` while the\n * element animates between the two content sizes.\n */\n transitionKey: React.Key;\n /**\n * Which dimensions to animate. Non-animated dimensions stay `auto`.\n * @default \"both\"\n */\n axis?: SizeTransitionAxis;\n};\n\nfunction measureSize(element: HTMLElement): Size {\n return { width: element.offsetWidth, height: element.offsetHeight };\n}\n\nfunction freezeSize(\n element: HTMLElement,\n size: Size,\n axis: SizeTransitionAxis,\n) {\n if (axis !== \"height\") {\n element.style.setProperty(\"--size-width\", `${size.width}px`);\n }\n if (axis !== \"width\") {\n element.style.setProperty(\"--size-height\", `${size.height}px`);\n }\n}\n\nfunction releaseSize(element: HTMLElement) {\n element.style.removeProperty(\"--size-width\");\n element.style.removeProperty(\"--size-height\");\n}\n\n/**\n * Runs `callback` once every animation currently running on `element` has\n * finished. Falls back to running synchronously when the Web Animations API is\n * unavailable (e.g. jsdom) or nothing is animating.\n */\nfunction runAfterAnimations(\n element: HTMLElement | null,\n callback: () => void,\n signal: AbortSignal,\n): number {\n if (!element || typeof element.getAnimations !== \"function\") {\n callback();\n return -1;\n }\n // Wait a frame so freshly-triggered transitions have registered.\n return requestAnimationFrame(() => {\n if (signal.aborted) {\n return;\n }\n const animations = element.getAnimations();\n if (animations.length === 0) {\n callback();\n return;\n }\n Promise.all(animations.map((animation) => animation.finished))\n .then(() => {\n if (!signal.aborted) {\n callback();\n }\n })\n .catch(() => {\n if (!signal.aborted) {\n callback();\n }\n });\n });\n}\n\n/**\n * Animates its element between the sizes of successive `children` and\n * cross-fades the outgoing content into the incoming one whenever\n * `transitionKey` changes.\n *\n * Size is exposed as `--size-width` / `--size-height` (pixels during a\n * transition, `auto` at rest), so the consumer opts in via classes such as\n * `w-(--size-width,auto) h-(--size-height,auto) transition-[width,height]`.\n * Content layers carry Base UI's transition data attributes\n * (`data-current`, `data-previous`, `data-starting-style`, `data-ending-style`)\n * and the element carries `data-transitioning` while a transition is running.\n */\nexport function SizeTransition({\n axis = \"both\",\n transitionKey,\n children,\n render,\n ...props\n}: SizeTransitionProps): React.ReactElement {\n const rootRef = React.useRef(null);\n const currentRef = React.useRef(null);\n const previousRef = React.useRef(null);\n // A DOM clone of the current content, refreshed after every render so the\n // outgoing subtree is available the moment `transitionKey` changes.\n const snapshotRef = React.useRef(null);\n const committedSizeRef = React.useRef(null);\n const previousKeyRef = React.useRef(transitionKey);\n const isFirstRenderRef = React.useRef(true);\n\n const [previousContent, setPreviousContent] =\n React.useState(null);\n const [previousSize, setPreviousSize] = React.useState(null);\n const [startingStyle, setStartingStyle] = React.useState(false);\n\n const isTransitioning = previousContent !== null;\n\n // Drive the size animation and cross-fade when the key changes.\n useIsoLayoutEffect(() => {\n const element = rootRef.current;\n if (!element) {\n return;\n }\n\n if (isFirstRenderRef.current) {\n isFirstRenderRef.current = false;\n previousKeyRef.current = transitionKey;\n committedSizeRef.current = measureSize(element);\n return;\n }\n\n if (previousKeyRef.current === transitionKey) {\n return;\n }\n previousKeyRef.current = transitionKey;\n\n const outgoingContent = snapshotRef.current;\n const previousSizeValue = committedSizeRef.current;\n\n if (outgoingContent) {\n setPreviousContent(outgoingContent);\n setStartingStyle(true);\n }\n\n // Measure the incoming content at its natural size, then keep transitions\n // suppressed on the entering layer so its `data-starting-style` snaps into\n // place (e.g. opacity 0) instead of animating away from the resting value.\n releaseSize(element);\n currentRef.current?.style.removeProperty(\"width\");\n currentRef.current?.style.setProperty(\"transition\", \"none\");\n const newSize = measureSize(element);\n committedSizeRef.current = newSize;\n\n // Pin the entering layer to its final width so it lays out at full size\n // from the first frame and is merely clipped while the element grows,\n // instead of reflowing (e.g. buttons shrinking) as the width animates.\n if (axis !== \"height\") {\n currentRef.current?.style.setProperty(\"width\", `${newSize.width}px`);\n }\n\n if (previousSizeValue) {\n freezeSize(element, previousSizeValue, axis);\n setPreviousSize(previousSizeValue);\n }\n\n const sizeAbort = new AbortController();\n const fadeAbort = new AbortController();\n let sizeFinishFrame = -1;\n let fadeFinishFrame = -1;\n\n const frame = requestAnimationFrame(() => {\n // The frozen size and `data-starting-style` were committed on the\n // previous (synchronous) render but not yet computed by the browser.\n // Force a style/layout flush so the starting values are registered,\n // otherwise the browser coalesces them with the changes below and the\n // transitions never run.\n void element.offsetHeight;\n\n freezeSize(element, newSize, axis);\n // Re-enable the entering layer's transition now that its starting style\n // has been registered, so removing `data-starting-style` animates.\n currentRef.current?.style.removeProperty(\"transition\");\n ReactDOM.flushSync(() => setStartingStyle(false));\n\n sizeFinishFrame = runAfterAnimations(\n element,\n () => {\n releaseSize(element);\n currentRef.current?.style.removeProperty(\"width\");\n },\n sizeAbort.signal,\n );\n fadeFinishFrame = runAfterAnimations(\n currentRef.current,\n () => {\n setPreviousContent(null);\n setPreviousSize(null);\n snapshotRef.current = null;\n },\n fadeAbort.signal,\n );\n });\n\n return () => {\n sizeAbort.abort();\n fadeAbort.abort();\n cancelAnimationFrame(frame);\n cancelAnimationFrame(sizeFinishFrame);\n cancelAnimationFrame(fadeFinishFrame);\n };\n }, [axis, transitionKey]);\n\n // Populate the previous layer with the cloned outgoing subtree.\n useIsoLayoutEffect(() => {\n const container = previousRef.current;\n if (!container || !previousContent) {\n return;\n }\n container.replaceChildren(...Array.from(previousContent.childNodes));\n }, [previousContent]);\n\n // Snapshot the current content after every render so it can become the\n // outgoing layer on the next key change. Declared last so the transition\n // effect above reads the prior render's snapshot before it is refreshed.\n useIsoLayoutEffect(() => {\n const source = currentRef.current;\n if (!source) {\n return;\n }\n const wrapper = source.ownerDocument.createElement(\"div\");\n for (const child of Array.from(source.childNodes)) {\n wrapper.appendChild(child.cloneNode(true));\n }\n snapshotRef.current = wrapper;\n });\n\n const currentLayer = (\n \n {children}\n \n );\n\n const content = isTransitioning ? (\n \n \n {currentLayer}\n \n ) : (\n currentLayer\n );\n\n const defaultProps = {\n className: \"relative\",\n \"data-slot\": \"size-transition\",\n };\n\n return useRender({\n defaultTagName: \"div\",\n props: mergeProps<\"div\">(defaultProps, props, { children: content }),\n render,\n ref: rootRef,\n state: { transitioning: isTransitioning },\n stateAttributesMapping: {\n transitioning: (value) => (value ? { \"data-transitioning\": \"\" } : null),\n },\n });\n}\n", - "type": "registry:ui" - } - ], - "type": "registry:ui" -} \ No newline at end of file diff --git a/apps/ui/public/r/ui.json b/apps/ui/public/r/ui.json index 8d590c513..81c6d2f84 100644 --- a/apps/ui/public/r/ui.json +++ b/apps/ui/public/r/ui.json @@ -44,7 +44,6 @@ "@coss/separator", "@coss/sheet", "@coss/sidebar", - "@coss/size-transition", "@coss/skeleton", "@coss/slider", "@coss/spinner", diff --git a/apps/ui/registry.json b/apps/ui/registry.json index 6be348fd5..6d11f0b7c 100644 --- a/apps/ui/registry.json +++ b/apps/ui/registry.json @@ -46,7 +46,6 @@ "@coss/separator", "@coss/sheet", "@coss/sidebar", - "@coss/size-transition", "@coss/skeleton", "@coss/slider", "@coss/spinner", @@ -729,19 +728,6 @@ ], "type": "registry:ui" }, - { - "dependencies": [ - "@base-ui/react" - ], - "files": [ - { - "path": "registry/default/ui/size-transition.tsx", - "type": "registry:ui" - } - ], - "name": "size-transition", - "type": "registry:ui" - }, { "css": { "@keyframes skeleton": { @@ -11430,12 +11416,10 @@ "@coss/card", "@coss/combobox", "@coss/empty", - "@coss/input", "@coss/label", "@coss/popover", "@coss/scroll-area", "@coss/select", - "@coss/size-transition", "@coss/skeleton", "@coss/switch", "@coss/tooltip" diff --git a/apps/ui/registry/__index__.tsx b/apps/ui/registry/__index__.tsx index fe5698140..1a53230aa 100644 --- a/apps/ui/registry/__index__.tsx +++ b/apps/ui/registry/__index__.tsx @@ -763,24 +763,6 @@ export const Index: Record = { categories: undefined, meta: undefined, }, - "size-transition": { - name: "size-transition", - description: "", - type: "registry:ui", - registryDependencies: undefined, - files: [{ - path: "registry/default/ui/size-transition.tsx", - type: "registry:ui", - target: "" - }], - component: React.lazy(async () => { - const mod = await import("@/registry/default/ui/size-transition.tsx") - const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name - return { default: mod.default || mod[exportName] } - }), - categories: undefined, - meta: undefined, - }, "skeleton": { name: "skeleton", description: "", @@ -9857,7 +9839,7 @@ export const Index: Record = { name: "booker-1", description: "A full scheduling flow powered by Cal.com API v2.", type: "registry:block", - registryDependencies: ["@coss/avatar","@coss/button","@coss/cal-api","@coss/card","@coss/combobox","@coss/empty","@coss/input","@coss/label","@coss/popover","@coss/scroll-area","@coss/select","@coss/size-transition","@coss/skeleton","@coss/switch","@coss/tooltip"], + registryDependencies: ["@coss/avatar","@coss/button","@coss/cal-api","@coss/card","@coss/combobox","@coss/empty","@coss/label","@coss/popover","@coss/scroll-area","@coss/select","@coss/skeleton","@coss/switch","@coss/tooltip"], files: [{ path: "registry/default/atoms/booker-1.tsx", type: "registry:block", diff --git a/apps/ui/registry/default/atoms/booker-1.tsx b/apps/ui/registry/default/atoms/booker-1.tsx index 7baae65f2..aac1b2813 100644 --- a/apps/ui/registry/default/atoms/booker-1.tsx +++ b/apps/ui/registry/default/atoms/booker-1.tsx @@ -4,10 +4,6 @@ import { ArrowLeftIcon, Clock3Icon } from "lucide-react"; import { Button } from "@/registry/default/ui/button"; import { Card } from "@/registry/default/ui/card"; import { Input } from "@/registry/default/ui/input"; -import { - SizeTransition, - SizeTransitionPanel, -} from "@/registry/default/ui/size-transition"; import { Skeleton } from "@/registry/default/ui/skeleton"; import { BookerAvatars } from "./booker/booker-avatars"; import { BookerCalendar } from "./booker/booker-calendar"; @@ -144,16 +140,9 @@ export function Booker({ initialData, target, timezone, labels }: BookerProps) { )} - - } - > +
{booker.step === "select" ? ( - } - > + <> {/* Calendar */}
@@ -172,21 +161,17 @@ export function Booker({ initialData, target, timezone, labels }: BookerProps) { }} {...booker.timePickerProps} /> - + ) : ( - - } - > +
- +
)} - +
; - transitioning: boolean; - startingStyle: boolean; -}; - -const SizeTransitionContext: React.Context = - React.createContext(null); - -function useSizeTransitionContext(): SizeTransitionContextValue { - const context = React.useContext(SizeTransitionContext); - if (!context) { - throw new Error( - "`SizeTransitionPanel` must be used within a `SizeTransition`.", - ); - } - return context; -} - -function measureSize(element: HTMLElement): Size { - return { width: element.offsetWidth, height: element.offsetHeight }; -} - -function freezeSize( - element: HTMLElement, - size: Size, - axis: SizeTransitionAxis, -) { - if (axis !== "height") { - element.style.setProperty("--size-width", `${size.width}px`); - } - if (axis !== "width") { - element.style.setProperty("--size-height", `${size.height}px`); - } -} - -function releaseSize(element: HTMLElement) { - element.style.removeProperty("--size-width"); - element.style.removeProperty("--size-height"); -} - -/** - * Runs `callback` once every animation currently running on `element` has - * finished. Falls back to running synchronously when the Web Animations API is - * unavailable (e.g. jsdom) or nothing is animating. - */ -function runAfterAnimations( - element: HTMLElement | null, - callback: () => void, - signal: AbortSignal, -): number { - if (!element || typeof element.getAnimations !== "function") { - callback(); - return -1; - } - return requestAnimationFrame(() => { - if (signal.aborted) { - return; - } - const animations = element.getAnimations(); - if (animations.length === 0) { - callback(); - return; - } - Promise.all(animations.map((animation) => animation.finished)) - .then(() => { - if (!signal.aborted) { - callback(); - } - }) - .catch(() => { - if (!signal.aborted) { - callback(); - } - }); - }); -} - -export type SizeTransitionProps = useRender.ComponentProps<"div"> & { - /** Value whose change triggers a size animation and content cross-fade. */ - transitionKey: React.Key; - /** - * Which dimensions to animate. Non-animated dimensions stay `auto`. - * @default "both" - */ - axis?: SizeTransitionAxis; -}; - -/** - * Animates between the sizes of successive `SizeTransitionPanel` children and - * cross-fades outgoing content into incoming content when `transitionKey` - * changes. - * - * Size is exposed as `--size-width` / `--size-height` (pixels during a - * transition, `auto` at rest). Opt in via classes such as - * `w-(--size-width,auto) h-(--size-height,auto) transition-[width,height]`. - */ -export function SizeTransition({ - axis = "both", - transitionKey, - children, - render, - ...props -}: SizeTransitionProps): React.ReactElement { - const rootRef = React.useRef(null); - const currentRef = React.useRef(null); - const previousRef = React.useRef(null); - const snapshotRef = React.useRef(null); - const committedSizeRef = React.useRef(null); - const previousKeyRef = React.useRef(transitionKey); - const isFirstRenderRef = React.useRef(true); - - const [previousContent, setPreviousContent] = - React.useState(null); - const [previousSize, setPreviousSize] = React.useState(null); - const [startingStyle, setStartingStyle] = React.useState(false); - - const isTransitioning = previousContent !== null; - - useIsoLayoutEffect(() => { - const element = rootRef.current; - if (!element) { - return; - } - - if (isFirstRenderRef.current) { - isFirstRenderRef.current = false; - previousKeyRef.current = transitionKey; - committedSizeRef.current = measureSize(element); - return; - } - - if (previousKeyRef.current === transitionKey) { - return; - } - previousKeyRef.current = transitionKey; - - const outgoingContent = snapshotRef.current; - const previousSizeValue = committedSizeRef.current; - - if (outgoingContent) { - setPreviousContent(outgoingContent); - setStartingStyle(true); - } - - releaseSize(element); - currentRef.current?.style.removeProperty("width"); - currentRef.current?.style.setProperty("transition", "none"); - const newSize = measureSize(element); - committedSizeRef.current = newSize; - - // Pin the entering panel to its final width so content is clipped, not reflowed. - if (axis !== "height") { - currentRef.current?.style.setProperty("width", `${newSize.width}px`); - } - - if (previousSizeValue) { - freezeSize(element, previousSizeValue, axis); - setPreviousSize(previousSizeValue); - } - - const sizeAbort = new AbortController(); - const fadeAbort = new AbortController(); - let sizeFinishFrame = -1; - let fadeFinishFrame = -1; - - const frame = requestAnimationFrame(() => { - // Force a reflow so starting styles register before ending styles apply. - void element.offsetHeight; - - freezeSize(element, newSize, axis); - currentRef.current?.style.removeProperty("transition"); - ReactDOM.flushSync(() => setStartingStyle(false)); - - sizeFinishFrame = runAfterAnimations( - element, - () => { - releaseSize(element); - currentRef.current?.style.removeProperty("width"); - }, - sizeAbort.signal, - ); - fadeFinishFrame = runAfterAnimations( - currentRef.current, - () => { - setPreviousContent(null); - setPreviousSize(null); - snapshotRef.current = null; - }, - fadeAbort.signal, - ); - }); - - return () => { - sizeAbort.abort(); - fadeAbort.abort(); - cancelAnimationFrame(frame); - cancelAnimationFrame(sizeFinishFrame); - cancelAnimationFrame(fadeFinishFrame); - }; - }, [axis, transitionKey]); - - useIsoLayoutEffect(() => { - const container = previousRef.current; - if (!container || !previousContent) { - return; - } - container.replaceChildren(previousContent); - }, [previousContent]); - - // Declared last so the transition effect reads the prior render's snapshot. - useIsoLayoutEffect(() => { - const source = currentRef.current; - if (!source) { - return; - } - const clone = source.cloneNode(true) as HTMLElement; - clone.removeAttribute("data-current"); - clone.removeAttribute("data-starting-style"); - clone.style.removeProperty("width"); - clone.style.removeProperty("transition"); - snapshotRef.current = clone; - }); - - const contextValue = React.useMemo( - () => ({ currentRef, startingStyle, transitioning: isTransitioning }), - [isTransitioning, startingStyle], - ); - - const content = isTransitioning ? ( - - {children} - {/* After `children` so inserting this layer never remounts the panel. */} -
- - ) : ( - children - ); - - const defaultProps = { - className: "relative", - "data-slot": "size-transition", - }; - - const element = useRender({ - defaultTagName: "div", - props: mergeProps<"div">(defaultProps, props, { children: content }), - render, - ref: rootRef, - state: { transitioning: isTransitioning }, - stateAttributesMapping: { - transitioning: (value) => (value ? { "data-transitioning": "" } : null), - }, - }); - - return ( - - {element} - - ); -} - -export type SizeTransitionPanelProps = useRender.ComponentProps<"div">; - -/** - * Active content layer of a `SizeTransition`. Uses the `render` element as - * `data-current` so no extra wrapper is added. - */ -export function SizeTransitionPanel({ - render, - ...props -}: SizeTransitionPanelProps): React.ReactElement { - const { currentRef, startingStyle, transitioning } = - useSizeTransitionContext(); - - return useRender({ - defaultTagName: "div", - props, - render, - ref: currentRef, - state: { current: true, startingStyle: transitioning && startingStyle }, - stateAttributesMapping: { - current: () => ({ - "data-current": "", - "data-slot": "size-transition-panel", - }), - startingStyle: (value) => (value ? { "data-starting-style": "" } : null), - }, - }); -} diff --git a/apps/ui/registry/registry-atoms.ts b/apps/ui/registry/registry-atoms.ts index 3e21df0d5..1ecf936cc 100644 --- a/apps/ui/registry/registry-atoms.ts +++ b/apps/ui/registry/registry-atoms.ts @@ -107,7 +107,6 @@ export const atoms: AtomItem[] = [ "@coss/popover", "@coss/scroll-area", "@coss/select", - "@coss/size-transition", "@coss/skeleton", "@coss/switch", "@coss/tooltip", diff --git a/apps/ui/registry/registry-ui.ts b/apps/ui/registry/registry-ui.ts index c8fc02df1..0837520dd 100644 --- a/apps/ui/registry/registry-ui.ts +++ b/apps/ui/registry/registry-ui.ts @@ -46,7 +46,6 @@ export const ui: Registry["items"] = [ "@coss/separator", "@coss/sheet", "@coss/sidebar", - "@coss/size-transition", "@coss/skeleton", "@coss/slider", "@coss/spinner", @@ -628,17 +627,6 @@ export const ui: Registry["items"] = [ ], type: "registry:ui", }, - { - dependencies: ["@base-ui/react"], - files: [ - { - path: "ui/size-transition.tsx", - type: "registry:ui", - }, - ], - name: "size-transition", - type: "registry:ui", - }, { css: { "@keyframes skeleton": { From 67b9fa321744ff5158c160ffee46330cb735bdcd Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:32:00 +0000 Subject: [PATCH 03/24] feat(booker): animate step 1->2 with motion instead of custom SizeTransition Co-Authored-By: pasquale --- apps/ui/package.json | 1 + apps/ui/public/r/booker-1.json | 12 +-- apps/ui/public/r/registry.json | 4 +- apps/ui/registry.json | 4 +- apps/ui/registry/__index__.tsx | 2 +- apps/ui/registry/default/atoms/booker-1.tsx | 97 ++++++++++++++------- apps/ui/registry/registry-atoms.ts | 2 +- bun.lock | 10 +++ 8 files changed, 91 insertions(+), 41 deletions(-) diff --git a/apps/ui/package.json b/apps/ui/package.json index fc45274ff..a4e362af9 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -15,6 +15,7 @@ "jotai": "^2.15.1", "lucide-react": "^0.555.0", "marked": "^18.0.5", + "motion": "12.42.0", "next": "16.2.5", "react": "^19.2.6", "react-day-picker": "^9.13.0", diff --git a/apps/ui/public/r/booker-1.json b/apps/ui/public/r/booker-1.json index 0b6431ef9..173b792b4 100644 --- a/apps/ui/public/r/booker-1.json +++ b/apps/ui/public/r/booker-1.json @@ -5,7 +5,8 @@ "dependencies": [ "isomorphic-dompurify", "marked", - "lucide-react" + "lucide-react", + "motion" ], "registryDependencies": [ "@coss/avatar", @@ -14,6 +15,7 @@ "@coss/card", "@coss/combobox", "@coss/empty", + "@coss/input", "@coss/label", "@coss/popover", "@coss/scroll-area", @@ -25,13 +27,13 @@ "files": [ { "path": "registry/default/atoms/booker-1.tsx", - "content": "\"use client\";\n\nimport { Clock3Icon } from \"lucide-react\";\nimport { Card } from \"@/registry/default/ui/card\";\nimport { Skeleton } from \"@/registry/default/ui/skeleton\";\nimport { BookerAvatars } from \"./booker/booker-avatars\";\nimport { BookerCalendar } from \"./booker/booker-calendar\";\nimport { BookerErrorState } from \"./booker/booker-error-state\";\nimport { type BookerLabels, getBookerLabels } from \"./booker/booker-labels\";\nimport { DurationPicker } from \"./booker/duration-picker\";\nimport { EventDescription } from \"./booker/event-description\";\nimport { HeaderBanner } from \"./booker/header-banner\";\nimport { Location } from \"./booker/location\";\nimport { TimePicker } from \"./booker/time-picker\";\nimport { TimezonePicker } from \"./booker/timezone-picker\";\nimport type { BookerTarget } from \"@/lib/booker/target\";\nimport { type BookerInitialData, useBooker } from \"@/lib/booker/use-booker\";\n\ntype BookerProps = {\n target: BookerTarget;\n timezone?: string;\n initialData?: BookerInitialData;\n defaultFormValues?: Record;\n onCreateBookingSuccess?: (data: unknown) => void;\n labels?: Partial;\n};\n\nexport function Booker({ initialData, target, timezone, labels }: BookerProps) {\n const t = getBookerLabels(labels);\n const booker = useBooker({ initialData, target, timezone });\n\n if (booker.error) {\n return (\n \n );\n }\n\n const displayMeta = booker.meta;\n const headerImageAlt = displayMeta\n ? t.headerImageAlt(displayMeta.hostName, displayMeta.eventTypeTitle)\n : \"Booker header\";\n const metaContentClassName = [\n displayMeta?.eventTypeImageUrl ? \"relative -mt-11 @5xl:-mt-13\" : \"\",\n \"flex flex-col gap-6 @5xl:p-6 p-4\",\n ]\n .filter(Boolean)\n .join(\" \");\n\n return (\n \n \n {/* Meta */}\n
\n \n
\n
\n
\n \n {displayMeta ? (\n

\n {displayMeta.hostName}\n

\n ) : (\n \n )}\n
\n
\n {displayMeta ? (\n <>\n

\n {displayMeta.eventTypeTitle}\n

\n \n \n ) : (\n <>\n \n \n \n \n )}\n
\n
\n\n {displayMeta ? (\n
\n {displayMeta.eventTypeDurationOptions ? (\n \n ) : (\n
\n \n \n {displayMeta.eventTypeDurationMinutes\n ? t.durationMinutes(\n displayMeta.eventTypeDurationMinutes,\n )\n : t.durationUnknown}\n \n
\n )}\n \n \n
\n ) : (\n
\n \n \n \n
\n )}\n
\n
\n {/* Calendar */}\n
\n \n
\n {/* Time picker */}\n \n
\n \n Cal.com\n
\n
\n );\n}\n\nexport default Booker;\n", + "content": "\"use client\";\n\nimport { ArrowLeftIcon, Clock3Icon } from \"lucide-react\";\nimport {\n AnimatePresence,\n domMax,\n LazyMotion,\n MotionConfig,\n m,\n} from \"motion/react\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport { Card } from \"@/registry/default/ui/card\";\nimport { Input } from \"@/registry/default/ui/input\";\nimport { Skeleton } from \"@/registry/default/ui/skeleton\";\nimport { BookerAvatars } from \"./booker/booker-avatars\";\nimport { BookerCalendar } from \"./booker/booker-calendar\";\nimport { BookerErrorState } from \"./booker/booker-error-state\";\nimport { type BookerLabels, getBookerLabels } from \"./booker/booker-labels\";\nimport { DurationPicker } from \"./booker/duration-picker\";\nimport { EventDescription } from \"./booker/event-description\";\nimport { HeaderBanner } from \"./booker/header-banner\";\nimport { Location } from \"./booker/location\";\nimport { TimePicker } from \"./booker/time-picker\";\nimport { TimezonePicker } from \"./booker/timezone-picker\";\nimport type { BookerTarget } from \"@/lib/booker/target\";\nimport { type BookerInitialData, useBooker } from \"@/lib/booker/use-booker\";\n\ntype BookerProps = {\n target: BookerTarget;\n timezone?: string;\n initialData?: BookerInitialData;\n defaultFormValues?: Record;\n onCreateBookingSuccess?: (data: unknown) => void;\n labels?: Partial;\n};\n\nexport function Booker({ initialData, target, timezone, labels }: BookerProps) {\n const t = getBookerLabels(labels);\n const booker = useBooker({ initialData, target, timezone });\n\n if (booker.error) {\n return (\n \n );\n }\n\n const displayMeta = booker.meta;\n const headerImageAlt = displayMeta\n ? t.headerImageAlt(displayMeta.hostName, displayMeta.eventTypeTitle)\n : \"Booker header\";\n const metaContentClassName = [\n displayMeta?.eventTypeImageUrl ? \"relative -mt-11 @5xl:-mt-13\" : \"\",\n \"flex flex-col gap-6 @5xl:p-6 p-4\",\n ]\n .filter(Boolean)\n .join(\" \");\n\n return (\n \n \n {/* Meta */}\n
\n \n
\n
\n
\n \n {displayMeta ? (\n

\n {displayMeta.hostName}\n

\n ) : (\n \n )}\n
\n
\n {displayMeta ? (\n <>\n

\n {displayMeta.eventTypeTitle}\n

\n \n \n ) : (\n <>\n \n \n \n \n )}\n
\n
\n\n {displayMeta ? (\n
\n {displayMeta.eventTypeDurationOptions ? (\n \n ) : (\n
\n \n \n {displayMeta.eventTypeDurationMinutes\n ? t.durationMinutes(\n displayMeta.eventTypeDurationMinutes,\n )\n : t.durationUnknown}\n \n
\n )}\n \n \n
\n ) : (\n
\n \n \n \n
\n )}\n
\n
\n \n \n \n \n {booker.step === \"select\" ? (\n \n {/* Calendar */}\n
\n \n
\n {/* Time picker */}\n \n \n ) : (\n \n \n \n \n )}\n
\n \n \n
\n
\n \n Cal.com\n \n
\n );\n}\n\nexport default Booker;\n", "type": "registry:block", "target": "components/atoms/booker-1.tsx" }, { "path": "registry/default/atoms/booker/booker-calendar.tsx", - "content": "\"use client\";\n\nimport { ChevronLeftIcon, ChevronRightIcon } from \"lucide-react\";\nimport {\n type ComponentProps,\n type KeyboardEvent,\n type MouseEvent,\n type ReactElement,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport { cn } from \"@/registry/default/lib/utils\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport { Skeleton } from \"@/registry/default/ui/skeleton\";\nimport {\n Tooltip,\n TooltipCreateHandle,\n TooltipPopup,\n TooltipProvider,\n TooltipTrigger,\n} from \"@/registry/default/ui/tooltip\";\n\n// A single shared tooltip reused across every day cell (via the handle/payload\n// pattern). Reusing one instance lets it gracefully move/scale between days\n// instead of mounting a separate tooltip per cell.\nconst monthTooltipHandle = TooltipCreateHandle();\n\n// Falls back to English (US) when no `locale` prop is provided.\nconst DEFAULT_LOCALE = \"en-US\";\n\ntype CalendarLabels = {\n /** ARIA label for the previous-month navigation button. */\n previousMonth: string;\n /** ARIA label for the next-month navigation button. */\n nextMonth: string;\n /** ARIA label for the navigation toolbar. */\n nav: string;\n /** Prefix for today's day button label, e.g. \"Today, Monday, …\". */\n today: string;\n /** Suffix for the selected day button label, e.g. \"…, selected\". */\n selected: string;\n};\n\n// English defaults. Localize by passing translated strings via the `labels`\n// prop (wire it up to your app's own i18n layer); date/weekday/month names are\n// still localized automatically from `locale` via `Intl`.\nconst DEFAULT_LABELS: CalendarLabels = {\n previousMonth: \"Go to the Previous Month\",\n nextMonth: \"Go to the Next Month\",\n nav: \"Navigation bar\",\n today: \"Today\",\n selected: \"selected\",\n};\n\n// Resolves the text direction for the locale (e.g. \"rtl\" for Arabic/Hebrew).\nfunction getTextDirection(locale: string): \"ltr\" | \"rtl\" {\n try {\n const localeObj = new Intl.Locale(locale) as Intl.Locale & {\n textInfo?: { direction?: string };\n getTextInfo?: () => { direction?: string };\n };\n const info =\n typeof localeObj.getTextInfo === \"function\"\n ? localeObj.getTextInfo()\n : localeObj.textInfo;\n if (info?.direction === \"rtl\") {\n return \"rtl\";\n }\n } catch {\n // Fall through to the ltr default below.\n }\n return \"ltr\";\n}\n\n// Locale-aware formatting/ordering, derived from a BCP-47 locale string.\ntype Localization = {\n weekStartsOn: number;\n monthYearLabel: (date: Date) => string;\n fullDateLabel: (date: Date) => string;\n monthShortLabel: (date: Date) => string;\n monthLongLabel: (date: Date) => string;\n weekdayLong: (date: Date) => string;\n weekdayShort: (date: Date) => string;\n dayNumber: (date: Date) => string;\n captionFormatter: Intl.DateTimeFormat;\n};\n\n// 1 = Monday … 7 = Sunday (ISO) → JS getDay() index (0 = Sunday … 6 = Saturday).\nfunction getWeekStartsOn(locale: string): number {\n try {\n const localeObj = new Intl.Locale(locale) as Intl.Locale & {\n weekInfo?: { firstDay?: number };\n getWeekInfo?: () => { firstDay?: number };\n };\n const info =\n typeof localeObj.getWeekInfo === \"function\"\n ? localeObj.getWeekInfo()\n : localeObj.weekInfo;\n const firstDay = info?.firstDay;\n if (typeof firstDay === \"number\") {\n return firstDay % 7;\n }\n } catch {\n // Fall through to the Sunday default below.\n }\n return 0;\n}\n\nfunction buildLocalization(\n locale: string,\n weekStartsOnOverride?: number,\n): Localization {\n const longDateFormatter = new Intl.DateTimeFormat(locale, {\n weekday: \"long\",\n year: \"numeric\",\n month: \"long\",\n day: \"numeric\",\n });\n const monthYearFormatter = new Intl.DateTimeFormat(locale, {\n month: \"long\",\n year: \"numeric\",\n });\n const monthShortFormatter = new Intl.DateTimeFormat(locale, {\n month: \"short\",\n });\n const monthLongFormatter = new Intl.DateTimeFormat(locale, {\n month: \"long\",\n });\n const weekdayLongFormatter = new Intl.DateTimeFormat(locale, {\n weekday: \"long\",\n });\n const weekdayShortFormatter = new Intl.DateTimeFormat(locale, {\n weekday: \"short\",\n });\n const dayFormatter = new Intl.DateTimeFormat(locale, { day: \"numeric\" });\n\n return {\n weekStartsOn: weekStartsOnOverride ?? getWeekStartsOn(locale),\n monthYearLabel: (date) => monthYearFormatter.format(date),\n fullDateLabel: (date) => longDateFormatter.format(date),\n monthShortLabel: (date) => monthShortFormatter.format(date),\n monthLongLabel: (date) => monthLongFormatter.format(date),\n weekdayLong: (date) => weekdayLongFormatter.format(date),\n weekdayShort: (date) => weekdayShortFormatter.format(date),\n dayNumber: (date) => dayFormatter.format(date),\n captionFormatter: monthYearFormatter,\n };\n}\n\nfunction pad2(value: number): string {\n return value < 10 ? `0${value}` : String(value);\n}\n\nfunction isoDate(date: Date): string {\n return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;\n}\n\nfunction isoMonth(date: Date): string {\n return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}`;\n}\n\nfunction startOfDay(date: Date): Date {\n return new Date(date.getFullYear(), date.getMonth(), date.getDate());\n}\n\nfunction startOfMonth(date: Date): Date {\n return new Date(date.getFullYear(), date.getMonth(), 1);\n}\n\nfunction endOfMonth(date: Date): Date {\n return new Date(date.getFullYear(), date.getMonth() + 1, 0);\n}\n\nfunction addDays(date: Date, amount: number): Date {\n return new Date(date.getFullYear(), date.getMonth(), date.getDate() + amount);\n}\n\nfunction addMonths(date: Date, amount: number): Date {\n return new Date(date.getFullYear(), date.getMonth() + amount, date.getDate());\n}\n\nfunction startOfWeek(date: Date, weekStartsOn = 0): Date {\n const day = startOfDay(date);\n const diff = (day.getDay() - weekStartsOn + 7) % 7;\n return addDays(day, -diff);\n}\n\nfunction endOfWeek(date: Date, weekStartsOn = 0): Date {\n return addDays(startOfWeek(date, weekStartsOn), 6);\n}\n\nfunction isSameDay(a: Date, b: Date): boolean {\n return (\n a.getFullYear() === b.getFullYear() &&\n a.getMonth() === b.getMonth() &&\n a.getDate() === b.getDate()\n );\n}\n\nfunction isSameMonth(a: Date, b: Date): boolean {\n return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();\n}\n\nfunction differenceInCalendarMonths(a: Date, b: Date): number {\n return (\n (a.getFullYear() - b.getFullYear()) * 12 + (a.getMonth() - b.getMonth())\n );\n}\n\ntype CalendarDay = {\n date: Date;\n outside: boolean;\n isoDate: string;\n dateMonthId: string;\n};\n\ntype DayModifiers = {\n focused: boolean;\n disabled: boolean;\n hidden: boolean;\n outside: boolean;\n today: boolean;\n selected: boolean;\n};\n\n// Builds the month grid as full weeks covering the month.\n//\n// `shiftRows` slides the window forward by that many weeks, dropping the\n// month's earliest week(s). When `fillToSix` is set the grid is then padded to\n// a full 6 rows with days from the next month. Together they keep today's week\n// plus the next two visible without exceeding 6 rows; with both at their\n// defaults the month spans only the weeks it naturally needs.\nfunction buildCalendarDays(\n month: Date,\n weekStartsOn: number,\n shiftRows: number,\n fillToSix: boolean,\n): CalendarDay[] {\n const monthStart = startOfMonth(month);\n const offset = shiftRows * 7;\n const gridStart = addDays(startOfWeek(monthStart, weekStartsOn), offset);\n const gridEnd = addDays(endOfWeek(endOfMonth(month), weekStartsOn), offset);\n\n const dates: Date[] = [];\n for (let cursor = gridStart; cursor <= gridEnd; cursor = addDays(cursor, 1)) {\n dates.push(cursor);\n }\n\n if (fillToSix) {\n while (dates.length < 42) {\n const lastDate = dates.at(-1);\n if (!lastDate) {\n break;\n }\n dates.push(addDays(lastDate, 1));\n }\n }\n\n return dates.map((date) => ({\n date,\n outside: !isSameMonth(date, month),\n isoDate: isoDate(date),\n dateMonthId: isoMonth(date),\n }));\n}\n\nfunction chunkWeeks(days: CalendarDay[]): CalendarDay[][] {\n const weeks: CalendarDay[][] = [];\n for (let i = 0; i < days.length; i += 7) {\n weeks.push(days.slice(i, i + 7));\n }\n return weeks;\n}\n\nfunction isFocusableDay(modifiers: DayModifiers): boolean {\n return !modifiers.disabled && !modifiers.hidden && !modifiers.outside;\n}\n\n// A single day cell's button. Focuses itself when it becomes the roving-tabindex\n// target so keyboard navigation moves the actual DOM focus.\nfunction DayButton({\n focused,\n nextMonthLabel,\n tooltip,\n children,\n ...buttonProps\n}: ComponentProps<\"button\"> & {\n focused: boolean;\n nextMonthLabel?: string;\n tooltip?: string;\n}): ReactElement {\n const ref = useRef(null);\n\n useEffect(() => {\n if (focused) ref.current?.focus();\n }, [focused]);\n\n const button = (\n \n );\n\n if (!tooltip) return button;\n\n return (\n \n );\n}\n\n// Month + year heading; the year is muted via formatToParts so it stays\n// locale-correct (some locales render \"2026 June\").\nfunction MonthCaption({\n date,\n formatter,\n className,\n}: {\n date: Date;\n formatter: Intl.DateTimeFormat;\n className?: string;\n}): ReactElement {\n return (\n
\n \n {formatter.formatToParts(date).map((part) =>\n part.type === \"year\" ? (\n \n {part.value}\n \n ) : (\n part.value\n ),\n )}\n \n
\n );\n}\n\ntype MoveBy = \"day\" | \"week\" | \"month\" | \"year\" | \"startOfWeek\" | \"endOfWeek\";\ntype MoveDir = \"before\" | \"after\";\n\nexport type BookerCalendarProps = {\n className?: string;\n /**\n * For the current month, once today reaches the 4th row, keep its week plus\n * the next two visible within the 6-row grid: extend into next-month days,\n * and slide the window down (dropping early weeks) only when today sits too\n * low for those two weeks to fit. Off renders a plain month grid. On by\n * default.\n */\n shiftWeeks?: boolean;\n availabilityLoading?: boolean;\n initialLoading?: boolean;\n month?: Date;\n defaultMonth?: Date;\n startMonth?: Date;\n endMonth?: Date;\n selected?: Date;\n onSelect?: (date: Date | undefined, triggerDate: Date) => void;\n onMonthChange?: (month: Date) => void;\n disabled?: (date: Date) => boolean;\n /** BCP-47 locale tag used to localize day/month/weekday names and ARIA labels. */\n locale?: string;\n /** Override the locale's first day of the week (0 = Sunday … 6 = Saturday). */\n weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;\n /** Text direction; defaults to the locale's direction (\"rtl\" for ar/he/…). */\n dir?: \"ltr\" | \"rtl\";\n /** Translatable strings for the navigation buttons and day button states. */\n labels?: Partial;\n /** Reference \"today\" for highlighting and week shifting; defaults to now. */\n today?: Date;\n};\n\nexport function BookerCalendar({\n className,\n shiftWeeks = true,\n availabilityLoading = false,\n initialLoading = false,\n month,\n defaultMonth,\n startMonth,\n endMonth,\n selected,\n onSelect,\n onMonthChange,\n disabled,\n locale = DEFAULT_LOCALE,\n weekStartsOn,\n dir,\n labels,\n today: todayProp,\n}: BookerCalendarProps): ReactElement {\n const dayCellsLoading = initialLoading || availabilityLoading;\n const localization = useMemo(\n () => buildLocalization(locale, weekStartsOn),\n [locale, weekStartsOn],\n );\n const resolvedLabels = { ...DEFAULT_LABELS, ...labels };\n const resolvedDir = dir ?? getTextDirection(locale);\n const weekStart = localization.weekStartsOn;\n\n const today = todayProp ?? new Date();\n\n // The month is controlled by the parent (via `month`/`onMonthChange`), but we\n // keep an internal fallback so the calendar works uncontrolled too.\n const [internalMonth, setInternalMonth] = useState(() =>\n startOfMonth(month ?? defaultMonth ?? today),\n );\n const monthTime = month ? startOfMonth(month).getTime() : undefined;\n useEffect(() => {\n if (monthTime !== undefined) {\n setInternalMonth(new Date(monthTime));\n }\n }, [monthTime]);\n\n const firstMonth = month ? startOfMonth(month) : internalMonth;\n\n const [focusedDate, setFocusedDate] = useState(undefined);\n const [lastFocusedDate, setLastFocusedDate] = useState(\n undefined,\n );\n\n const navStart = startMonth ? startOfMonth(startMonth) : undefined;\n const navEnd = endMonth ? startOfMonth(endMonth) : undefined;\n\n const previousMonth =\n navStart && differenceInCalendarMonths(firstMonth, navStart) <= 0\n ? undefined\n : addMonths(firstMonth, -1);\n const nextMonth =\n navEnd && differenceInCalendarMonths(navEnd, firstMonth) <= 0\n ? undefined\n : addMonths(firstMonth, 1);\n\n const goToMonth = useCallback(\n (date: Date) => {\n let newMonth = startOfMonth(date);\n if (navStart && newMonth < navStart) {\n newMonth = navStart;\n }\n if (navEnd && newMonth > navEnd) {\n newMonth = navEnd;\n }\n setInternalMonth(newMonth);\n onMonthChange?.(newMonth);\n },\n [navEnd, navStart, onMonthChange],\n );\n\n // For the live current month, once today reaches the 4th row, guarantee its\n // week plus the next two are visible. todayRow is today's 0-based row in the\n // natural (unshifted) grid.\n const naturalGridStart = startOfWeek(startOfMonth(firstMonth), weekStart);\n const todayRow = Math.floor(\n Math.round(\n (startOfDay(today).getTime() - naturalGridStart.getTime()) / 86_400_000,\n ) / 7,\n );\n const expand = shiftWeeks && isSameMonth(firstMonth, today) && todayRow >= 3;\n // Extend downward first; only drop early weeks (shift) when today sits so low\n // that the two trailing weeks wouldn't fit within the 6-row cap.\n const shiftRows = expand ? Math.max(0, todayRow - 3) : 0;\n\n const days = buildCalendarDays(firstMonth, weekStart, shiftRows, expand);\n const weeks = chunkWeeks(days);\n\n // Reserve any missing rows up to 6 (the most any month needs) with empty\n // placeholders, so the grid keeps a constant height across navigation. This\n // is 0 when expanding, since those months already fill all 6 rows with days.\n const placeholderRows = Math.max(0, 6 - weeks.length);\n\n const isSelected = (date: Date): boolean =>\n selected ? isSameDay(selected, date) : false;\n\n const getModifiers = (day: CalendarDay): DayModifiers => {\n const { date, outside } = day;\n const isBeforeNavStart = Boolean(navStart && date < navStart);\n const isAfterNavEnd = Boolean(navEnd && date > endOfMonth(navEnd));\n const isDisabled = disabled ? disabled(date) : false;\n // Leading previous-month days are never shown (past days aren't relevant).\n // Next-month days only belong in the grid while expanding; otherwise the\n // empty placeholder rows reserve their space instead of spilling over.\n const isPrevMonthDay = date < startOfMonth(firstMonth);\n const isNextMonthDay = date > endOfMonth(firstMonth);\n const isHidden =\n isBeforeNavStart ||\n isAfterNavEnd ||\n isPrevMonthDay ||\n (!expand && isNextMonthDay);\n const isToday = isSameDay(date, today);\n const isFocused =\n !isHidden &&\n !outside &&\n focusedDate !== undefined &&\n isSameDay(date, focusedDate);\n\n return {\n focused: isFocused,\n disabled: isDisabled,\n hidden: isHidden,\n outside,\n today: isToday,\n selected: isSelected(date),\n };\n };\n\n // Roving tabindex target. Priority: focused > last focused > selected > today\n // > first focusable day.\n const focusTarget = ((): CalendarDay | undefined => {\n let target: CalendarDay | undefined;\n let priority = -1;\n for (const day of days) {\n const modifiers = getModifiers(day);\n if (!isFocusableDay(modifiers)) continue;\n if (modifiers.focused && priority < 3) {\n target = day;\n priority = 3;\n } else if (\n lastFocusedDate &&\n isSameDay(day.date, lastFocusedDate) &&\n priority < 2\n ) {\n target = day;\n priority = 2;\n } else if (isSelected(day.date) && priority < 1) {\n target = day;\n priority = 1;\n } else if (modifiers.today && priority < 0) {\n target = day;\n priority = 0;\n }\n }\n if (!target) {\n target = days.find((day) => isFocusableDay(getModifiers(day)));\n }\n return target;\n })();\n\n // While expanding, mark the 1st of the next month (the first visible day past\n // the current month's end) with the month label.\n const monthEnd = endOfMonth(firstMonth);\n const nextMonthLabelIso = expand\n ? days.find((day) => {\n const modifiers = getModifiers(day);\n return day.date > monthEnd && !modifiers.hidden;\n })?.isoDate\n : undefined;\n\n const getFocusableDate = (\n moveBy: MoveBy,\n moveDir: MoveDir,\n refDate: Date,\n ): Date => {\n const delta = moveDir === \"after\" ? 1 : -1;\n let result: Date;\n switch (moveBy) {\n case \"day\":\n result = addDays(refDate, delta);\n break;\n case \"week\":\n result = addDays(refDate, delta * 7);\n break;\n case \"month\":\n result = addMonths(refDate, delta);\n break;\n case \"year\":\n result = addMonths(refDate, delta * 12);\n break;\n case \"startOfWeek\":\n result = startOfWeek(refDate, weekStart);\n break;\n case \"endOfWeek\":\n result = endOfWeek(refDate, weekStart);\n break;\n }\n if (moveDir === \"before\" && navStart && result < navStart) {\n result = navStart;\n }\n if (moveDir === \"after\" && navEnd && result > endOfMonth(navEnd)) {\n result = endOfMonth(navEnd);\n }\n return result;\n };\n\n const getNextFocus = (\n moveBy: MoveBy,\n moveDir: MoveDir,\n refDate: Date,\n attempt = 0,\n ): Date | undefined => {\n if (attempt > 365) return undefined;\n const next = getFocusableDate(moveBy, moveDir, refDate);\n const isDisabled = disabled ? disabled(next) : false;\n if (!isDisabled) return next;\n return getNextFocus(moveBy, moveDir, next, attempt + 1);\n };\n\n const moveFocus = (moveBy: MoveBy, moveDir: MoveDir) => {\n if (!focusedDate) return;\n const next = getNextFocus(moveBy, moveDir, focusedDate);\n if (!next) return;\n if (!isSameMonth(next, firstMonth)) {\n goToMonth(next);\n }\n setFocusedDate(next);\n };\n\n const handleDayClick =\n (day: CalendarDay, modifiers: DayModifiers) => (event: MouseEvent) => {\n event.preventDefault();\n event.stopPropagation();\n setFocusedDate(day.date);\n if (modifiers.disabled) return;\n const newDate =\n selected && isSameDay(day.date, selected) ? undefined : day.date;\n onSelect?.(newDate, day.date);\n };\n\n const handleDayFocus = (day: CalendarDay) => () => {\n setFocusedDate(day.date);\n };\n\n const handleDayBlur = () => {\n setLastFocusedDate(focusedDate);\n setFocusedDate(undefined);\n };\n\n const handleDayKeyDown = (event: KeyboardEvent) => {\n const keyMap: Record = {\n ArrowLeft: [event.shiftKey ? \"month\" : \"day\", \"before\"],\n ArrowRight: [event.shiftKey ? \"month\" : \"day\", \"after\"],\n ArrowDown: [event.shiftKey ? \"year\" : \"week\", \"after\"],\n ArrowUp: [event.shiftKey ? \"year\" : \"week\", \"before\"],\n PageUp: [event.shiftKey ? \"year\" : \"month\", \"before\"],\n PageDown: [event.shiftKey ? \"year\" : \"month\", \"after\"],\n Home: [\"startOfWeek\", \"before\"],\n End: [\"endOfWeek\", \"after\"],\n };\n const move = keyMap[event.key];\n if (move) {\n event.preventDefault();\n event.stopPropagation();\n const [moveBy, moveDir] = move;\n moveFocus(moveBy, moveDir);\n }\n };\n\n const weekdayHeaderStart = startOfWeek(today, weekStart);\n const weekdays = Array.from({ length: 7 }, (_, index) => {\n const date = addDays(weekdayHeaderStart, index);\n return {\n long: localization.weekdayLong(date),\n short: localization.weekdayShort(date),\n };\n });\n\n return (\n \n
\n
\n {localization.monthYearLabel(firstMonth)}\n
\n
\n {initialLoading ? (\n \n ) : (\n <>\n \n \n {\n if (previousMonth) goToMonth(previousMonth);\n }}\n >\n \n \n {\n if (nextMonth) goToMonth(nextMonth);\n }}\n >\n \n \n \n \n )}\n
\n \n \n \n \n {weekdays.map((weekday) => (\n \n {initialLoading ? (\n \n ) : (\n weekday.short\n )}\n \n ))}\n \n \n \n {weeks.map((week) => (\n \n {week.map((day) => {\n const modifiers = getModifiers(day);\n\n // The day button's own styling is driven by the `data-*`\n // attributes below; these cell classes only cover the\n // layout and the outside/hidden/today states.\n const cellClassName = cn(\n \"@max-3xl:max-h-9 flex-1 p-0\",\n modifiers.hidden && \"invisible\",\n modifiers.outside && \"text-muted-foreground/50\",\n modifiers.today &&\n !dayCellsLoading &&\n \"*:before:pointer-events-none *:before:absolute *:before:start-1/2 *:before:bottom-1/7 *:before:z-1 *:before:size-1 *:before:-translate-x-1/2 *:before:rounded-full *:before:bg-primary *:before:transition-colors [&[data-selected]>*]:before:bg-primary-foreground\",\n );\n\n // Label (e.g. \"Aug\") sits on the 1st of the next month.\n const nextMonthLabel =\n day.isoDate === nextMonthLabelIso\n ? localization.monthShortLabel(day.date)\n : undefined;\n\n // Next-month days only show in the shifted current-month\n // view; a month-name tooltip keeps them from being mistaken\n // for the current month.\n const monthTooltip =\n day.outside && !modifiers.disabled && !modifiers.hidden\n ? localization.monthLongLabel(day.date)\n : undefined;\n\n return (\n \n {modifiers.hidden ? null : dayCellsLoading ? (\n \n \n \n ) : (\n {\n let label = localization.fullDateLabel(day.date);\n if (modifiers.today) {\n label = `${resolvedLabels.today}, ${label}`;\n }\n if (modifiers.selected) {\n label = `${label}, ${resolvedLabels.selected}`;\n }\n return label;\n })()}\n onClick={handleDayClick(day, modifiers)}\n onBlur={handleDayBlur}\n onFocus={handleDayFocus(day)}\n onKeyDown={handleDayKeyDown}\n >\n {localization.dayNumber(day.date)}\n \n )}\n \n );\n })}\n \n ))}\n {Array.from(\n { length: placeholderRows },\n (_, rowIndex) => `placeholder-row-${rowIndex}`,\n ).map((rowKey) => (\n \n {weekdays.map((weekday) => (\n \n
\n \n ))}\n \n ))}\n \n \n \n {({ payload }) => (\n \n {payload}\n \n )}\n \n \n
\n
\n );\n}\n", + "content": "\"use client\";\n\nimport { ChevronLeftIcon, ChevronRightIcon } from \"lucide-react\";\nimport {\n type ComponentProps,\n type KeyboardEvent,\n type MouseEvent,\n type ReactElement,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport { cn } from \"@/registry/default/lib/utils\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport { Skeleton } from \"@/registry/default/ui/skeleton\";\nimport {\n Tooltip,\n TooltipCreateHandle,\n TooltipPopup,\n TooltipProvider,\n TooltipTrigger,\n} from \"@/registry/default/ui/tooltip\";\n\n// A single shared tooltip reused across every day cell (via the handle/payload\n// pattern). Reusing one instance lets it gracefully move/scale between days\n// instead of mounting a separate tooltip per cell.\nconst monthTooltipHandle = TooltipCreateHandle();\n\n// Falls back to English (US) when no `locale` prop is provided.\nconst DEFAULT_LOCALE = \"en-US\";\n\ntype CalendarLabels = {\n /** ARIA label for the previous-month navigation button. */\n previousMonth: string;\n /** ARIA label for the next-month navigation button. */\n nextMonth: string;\n /** ARIA label for the navigation toolbar. */\n nav: string;\n /** Prefix for today's day button label, e.g. \"Today, Monday, …\". */\n today: string;\n /** Suffix for the selected day button label, e.g. \"…, selected\". */\n selected: string;\n};\n\n// English defaults. Localize by passing translated strings via the `labels`\n// prop (wire it up to your app's own i18n layer); date/weekday/month names are\n// still localized automatically from `locale` via `Intl`.\nconst DEFAULT_LABELS: CalendarLabels = {\n previousMonth: \"Go to the Previous Month\",\n nextMonth: \"Go to the Next Month\",\n nav: \"Navigation bar\",\n today: \"Today\",\n selected: \"selected\",\n};\n\n// Resolves the text direction for the locale (e.g. \"rtl\" for Arabic/Hebrew).\nfunction getTextDirection(locale: string): \"ltr\" | \"rtl\" {\n try {\n const localeObj = new Intl.Locale(locale) as Intl.Locale & {\n textInfo?: { direction?: string };\n getTextInfo?: () => { direction?: string };\n };\n const info =\n typeof localeObj.getTextInfo === \"function\"\n ? localeObj.getTextInfo()\n : localeObj.textInfo;\n if (info?.direction === \"rtl\") {\n return \"rtl\";\n }\n } catch {\n // Fall through to the ltr default below.\n }\n return \"ltr\";\n}\n\n// Locale-aware formatting/ordering, derived from a BCP-47 locale string.\ntype Localization = {\n weekStartsOn: number;\n monthYearLabel: (date: Date) => string;\n fullDateLabel: (date: Date) => string;\n monthShortLabel: (date: Date) => string;\n monthLongLabel: (date: Date) => string;\n weekdayLong: (date: Date) => string;\n weekdayShort: (date: Date) => string;\n dayNumber: (date: Date) => string;\n captionFormatter: Intl.DateTimeFormat;\n};\n\n// 1 = Monday … 7 = Sunday (ISO) → JS getDay() index (0 = Sunday … 6 = Saturday).\nfunction getWeekStartsOn(locale: string): number {\n try {\n const localeObj = new Intl.Locale(locale) as Intl.Locale & {\n weekInfo?: { firstDay?: number };\n getWeekInfo?: () => { firstDay?: number };\n };\n const info =\n typeof localeObj.getWeekInfo === \"function\"\n ? localeObj.getWeekInfo()\n : localeObj.weekInfo;\n const firstDay = info?.firstDay;\n if (typeof firstDay === \"number\") {\n return firstDay % 7;\n }\n } catch {\n // Fall through to the Sunday default below.\n }\n return 0;\n}\n\nfunction buildLocalization(\n locale: string,\n weekStartsOnOverride?: number,\n): Localization {\n const longDateFormatter = new Intl.DateTimeFormat(locale, {\n weekday: \"long\",\n year: \"numeric\",\n month: \"long\",\n day: \"numeric\",\n });\n const monthYearFormatter = new Intl.DateTimeFormat(locale, {\n month: \"long\",\n year: \"numeric\",\n });\n const monthShortFormatter = new Intl.DateTimeFormat(locale, {\n month: \"short\",\n });\n const monthLongFormatter = new Intl.DateTimeFormat(locale, {\n month: \"long\",\n });\n const weekdayLongFormatter = new Intl.DateTimeFormat(locale, {\n weekday: \"long\",\n });\n const weekdayShortFormatter = new Intl.DateTimeFormat(locale, {\n weekday: \"short\",\n });\n const dayFormatter = new Intl.DateTimeFormat(locale, { day: \"numeric\" });\n\n return {\n weekStartsOn: weekStartsOnOverride ?? getWeekStartsOn(locale),\n monthYearLabel: (date) => monthYearFormatter.format(date),\n fullDateLabel: (date) => longDateFormatter.format(date),\n monthShortLabel: (date) => monthShortFormatter.format(date),\n monthLongLabel: (date) => monthLongFormatter.format(date),\n weekdayLong: (date) => weekdayLongFormatter.format(date),\n weekdayShort: (date) => weekdayShortFormatter.format(date),\n dayNumber: (date) => dayFormatter.format(date),\n captionFormatter: monthYearFormatter,\n };\n}\n\nfunction pad2(value: number): string {\n return value < 10 ? `0${value}` : String(value);\n}\n\nfunction isoDate(date: Date): string {\n return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;\n}\n\nfunction isoMonth(date: Date): string {\n return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}`;\n}\n\nfunction startOfDay(date: Date): Date {\n return new Date(date.getFullYear(), date.getMonth(), date.getDate());\n}\n\nfunction startOfMonth(date: Date): Date {\n return new Date(date.getFullYear(), date.getMonth(), 1);\n}\n\nfunction endOfMonth(date: Date): Date {\n return new Date(date.getFullYear(), date.getMonth() + 1, 0);\n}\n\nfunction addDays(date: Date, amount: number): Date {\n return new Date(date.getFullYear(), date.getMonth(), date.getDate() + amount);\n}\n\nfunction addMonths(date: Date, amount: number): Date {\n return new Date(date.getFullYear(), date.getMonth() + amount, date.getDate());\n}\n\nfunction startOfWeek(date: Date, weekStartsOn = 0): Date {\n const day = startOfDay(date);\n const diff = (day.getDay() - weekStartsOn + 7) % 7;\n return addDays(day, -diff);\n}\n\nfunction endOfWeek(date: Date, weekStartsOn = 0): Date {\n return addDays(startOfWeek(date, weekStartsOn), 6);\n}\n\nfunction isSameDay(a: Date, b: Date): boolean {\n return (\n a.getFullYear() === b.getFullYear() &&\n a.getMonth() === b.getMonth() &&\n a.getDate() === b.getDate()\n );\n}\n\nfunction isSameMonth(a: Date, b: Date): boolean {\n return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();\n}\n\nfunction differenceInCalendarMonths(a: Date, b: Date): number {\n return (\n (a.getFullYear() - b.getFullYear()) * 12 + (a.getMonth() - b.getMonth())\n );\n}\n\ntype CalendarDay = {\n date: Date;\n outside: boolean;\n isoDate: string;\n dateMonthId: string;\n};\n\ntype DayModifiers = {\n focused: boolean;\n disabled: boolean;\n hidden: boolean;\n outside: boolean;\n today: boolean;\n selected: boolean;\n};\n\n// Builds the month grid as full weeks covering the month.\n//\n// `shiftRows` slides the window forward by that many weeks, dropping the\n// month's earliest week(s). When `fillToSix` is set the grid is then padded to\n// a full 6 rows with days from the next month. Together they keep today's week\n// plus the next two visible without exceeding 6 rows; with both at their\n// defaults the month spans only the weeks it naturally needs.\nfunction buildCalendarDays(\n month: Date,\n weekStartsOn: number,\n shiftRows: number,\n fillToSix: boolean,\n): CalendarDay[] {\n const monthStart = startOfMonth(month);\n const offset = shiftRows * 7;\n const gridStart = addDays(startOfWeek(monthStart, weekStartsOn), offset);\n const gridEnd = addDays(endOfWeek(endOfMonth(month), weekStartsOn), offset);\n\n const dates: Date[] = [];\n for (let cursor = gridStart; cursor <= gridEnd; cursor = addDays(cursor, 1)) {\n dates.push(cursor);\n }\n\n if (fillToSix) {\n while (dates.length < 42) {\n const lastDate = dates.at(-1);\n if (!lastDate) {\n break;\n }\n dates.push(addDays(lastDate, 1));\n }\n }\n\n return dates.map((date) => ({\n date,\n outside: !isSameMonth(date, month),\n isoDate: isoDate(date),\n dateMonthId: isoMonth(date),\n }));\n}\n\nfunction chunkWeeks(days: CalendarDay[]): CalendarDay[][] {\n const weeks: CalendarDay[][] = [];\n for (let i = 0; i < days.length; i += 7) {\n weeks.push(days.slice(i, i + 7));\n }\n return weeks;\n}\n\nfunction isFocusableDay(modifiers: DayModifiers): boolean {\n return !modifiers.disabled && !modifiers.hidden && !modifiers.outside;\n}\n\n// A single day cell's button. Focuses itself when it becomes the roving-tabindex\n// target so keyboard navigation moves the actual DOM focus.\nfunction DayButton({\n focused,\n nextMonthLabel,\n tooltip,\n children,\n ...buttonProps\n}: ComponentProps<\"button\"> & {\n focused: boolean;\n nextMonthLabel?: string;\n tooltip?: string;\n}): ReactElement {\n const ref = useRef(null);\n\n useEffect(() => {\n if (focused) ref.current?.focus();\n }, [focused]);\n\n const button = (\n \n );\n\n if (!tooltip) return button;\n\n return (\n \n );\n}\n\n// Month + year heading; the year is muted via formatToParts so it stays\n// locale-correct (some locales render \"2026 June\").\nfunction MonthCaption({\n date,\n formatter,\n className,\n}: {\n date: Date;\n formatter: Intl.DateTimeFormat;\n className?: string;\n}): ReactElement {\n return (\n \n {formatter.formatToParts(date).map((part) =>\n part.type === \"year\" ? (\n \n {part.value}\n \n ) : (\n part.value\n ),\n )}\n \n );\n}\n\ntype MoveBy = \"day\" | \"week\" | \"month\" | \"year\" | \"startOfWeek\" | \"endOfWeek\";\ntype MoveDir = \"before\" | \"after\";\n\nexport type BookerCalendarProps = {\n className?: string;\n /**\n * For the current month, once today reaches the 4th row, keep its week plus\n * the next two visible within the 6-row grid: extend into next-month days,\n * and slide the window down (dropping early weeks) only when today sits too\n * low for those two weeks to fit. Off renders a plain month grid. On by\n * default.\n */\n shiftWeeks?: boolean;\n availabilityLoading?: boolean;\n initialLoading?: boolean;\n month?: Date;\n defaultMonth?: Date;\n startMonth?: Date;\n endMonth?: Date;\n selected?: Date;\n onSelect?: (date: Date | undefined, triggerDate: Date) => void;\n onMonthChange?: (month: Date) => void;\n disabled?: (date: Date) => boolean;\n /** BCP-47 locale tag used to localize day/month/weekday names and ARIA labels. */\n locale?: string;\n /** Override the locale's first day of the week (0 = Sunday … 6 = Saturday). */\n weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;\n /** Text direction; defaults to the locale's direction (\"rtl\" for ar/he/…). */\n dir?: \"ltr\" | \"rtl\";\n /** Translatable strings for the navigation buttons and day button states. */\n labels?: Partial;\n /** Reference \"today\" for highlighting and week shifting; defaults to now. */\n today?: Date;\n};\n\nexport function BookerCalendar({\n className,\n shiftWeeks = true,\n availabilityLoading = false,\n initialLoading = false,\n month,\n defaultMonth,\n startMonth,\n endMonth,\n selected,\n onSelect,\n onMonthChange,\n disabled,\n locale = DEFAULT_LOCALE,\n weekStartsOn,\n dir,\n labels,\n today: todayProp,\n}: BookerCalendarProps): ReactElement {\n const dayCellsLoading = initialLoading || availabilityLoading;\n const localization = useMemo(\n () => buildLocalization(locale, weekStartsOn),\n [locale, weekStartsOn],\n );\n const resolvedLabels = { ...DEFAULT_LABELS, ...labels };\n const resolvedDir = dir ?? getTextDirection(locale);\n const weekStart = localization.weekStartsOn;\n\n const today = todayProp ?? new Date();\n\n // The month is controlled by the parent (via `month`/`onMonthChange`), but we\n // keep an internal fallback so the calendar works uncontrolled too.\n const [internalMonth, setInternalMonth] = useState(() =>\n startOfMonth(month ?? defaultMonth ?? today),\n );\n const monthTime = month ? startOfMonth(month).getTime() : undefined;\n useEffect(() => {\n if (monthTime !== undefined) {\n setInternalMonth(new Date(monthTime));\n }\n }, [monthTime]);\n\n const firstMonth = month ? startOfMonth(month) : internalMonth;\n\n const [focusedDate, setFocusedDate] = useState(undefined);\n const [lastFocusedDate, setLastFocusedDate] = useState(\n undefined,\n );\n\n const navStart = startMonth ? startOfMonth(startMonth) : undefined;\n const navEnd = endMonth ? startOfMonth(endMonth) : undefined;\n\n const previousMonth =\n navStart && differenceInCalendarMonths(firstMonth, navStart) <= 0\n ? undefined\n : addMonths(firstMonth, -1);\n const nextMonth =\n navEnd && differenceInCalendarMonths(navEnd, firstMonth) <= 0\n ? undefined\n : addMonths(firstMonth, 1);\n\n const goToMonth = useCallback(\n (date: Date) => {\n let newMonth = startOfMonth(date);\n if (navStart && newMonth < navStart) {\n newMonth = navStart;\n }\n if (navEnd && newMonth > navEnd) {\n newMonth = navEnd;\n }\n setInternalMonth(newMonth);\n onMonthChange?.(newMonth);\n },\n [navEnd, navStart, onMonthChange],\n );\n\n // For the live current month, once today reaches the 4th row, guarantee its\n // week plus the next two are visible. todayRow is today's 0-based row in the\n // natural (unshifted) grid.\n const naturalGridStart = startOfWeek(startOfMonth(firstMonth), weekStart);\n const todayRow = Math.floor(\n Math.round(\n (startOfDay(today).getTime() - naturalGridStart.getTime()) / 86_400_000,\n ) / 7,\n );\n const expand = shiftWeeks && isSameMonth(firstMonth, today) && todayRow >= 3;\n // Extend downward first; only drop early weeks (shift) when today sits so low\n // that the two trailing weeks wouldn't fit within the 6-row cap.\n const shiftRows = expand ? Math.max(0, todayRow - 3) : 0;\n\n const days = buildCalendarDays(firstMonth, weekStart, shiftRows, expand);\n const weeks = chunkWeeks(days);\n\n // Reserve any missing rows up to 6 (the most any month needs) with empty\n // placeholders, so the grid keeps a constant height across navigation. This\n // is 0 when expanding, since those months already fill all 6 rows with days.\n const placeholderRows = Math.max(0, 6 - weeks.length);\n\n const isSelected = (date: Date): boolean =>\n selected ? isSameDay(selected, date) : false;\n\n const getModifiers = (day: CalendarDay): DayModifiers => {\n const { date, outside } = day;\n const isBeforeNavStart = Boolean(navStart && date < navStart);\n const isAfterNavEnd = Boolean(navEnd && date > endOfMonth(navEnd));\n const isDisabled = disabled ? disabled(date) : false;\n // Leading previous-month days are never shown (past days aren't relevant).\n // Next-month days only belong in the grid while expanding; otherwise the\n // empty placeholder rows reserve their space instead of spilling over.\n const isPrevMonthDay = date < startOfMonth(firstMonth);\n const isNextMonthDay = date > endOfMonth(firstMonth);\n const isHidden =\n isBeforeNavStart ||\n isAfterNavEnd ||\n isPrevMonthDay ||\n (!expand && isNextMonthDay);\n const isToday = isSameDay(date, today);\n const isFocused =\n !isHidden &&\n !outside &&\n focusedDate !== undefined &&\n isSameDay(date, focusedDate);\n\n return {\n focused: isFocused,\n disabled: isDisabled,\n hidden: isHidden,\n outside,\n today: isToday,\n selected: isSelected(date),\n };\n };\n\n // Roving tabindex target. Priority: focused > last focused > selected > today\n // > first focusable day.\n const focusTarget = ((): CalendarDay | undefined => {\n let target: CalendarDay | undefined;\n let priority = -1;\n for (const day of days) {\n const modifiers = getModifiers(day);\n if (!isFocusableDay(modifiers)) continue;\n if (modifiers.focused && priority < 3) {\n target = day;\n priority = 3;\n } else if (\n lastFocusedDate &&\n isSameDay(day.date, lastFocusedDate) &&\n priority < 2\n ) {\n target = day;\n priority = 2;\n } else if (isSelected(day.date) && priority < 1) {\n target = day;\n priority = 1;\n } else if (modifiers.today && priority < 0) {\n target = day;\n priority = 0;\n }\n }\n if (!target) {\n target = days.find((day) => isFocusableDay(getModifiers(day)));\n }\n return target;\n })();\n\n // While expanding, mark the 1st of the next month (the first visible day past\n // the current month's end) with the month label.\n const monthEnd = endOfMonth(firstMonth);\n const nextMonthLabelIso = expand\n ? days.find((day) => {\n const modifiers = getModifiers(day);\n return day.date > monthEnd && !modifiers.hidden;\n })?.isoDate\n : undefined;\n\n const getFocusableDate = (\n moveBy: MoveBy,\n moveDir: MoveDir,\n refDate: Date,\n ): Date => {\n const delta = moveDir === \"after\" ? 1 : -1;\n let result: Date;\n switch (moveBy) {\n case \"day\":\n result = addDays(refDate, delta);\n break;\n case \"week\":\n result = addDays(refDate, delta * 7);\n break;\n case \"month\":\n result = addMonths(refDate, delta);\n break;\n case \"year\":\n result = addMonths(refDate, delta * 12);\n break;\n case \"startOfWeek\":\n result = startOfWeek(refDate, weekStart);\n break;\n case \"endOfWeek\":\n result = endOfWeek(refDate, weekStart);\n break;\n }\n if (moveDir === \"before\" && navStart && result < navStart) {\n result = navStart;\n }\n if (moveDir === \"after\" && navEnd && result > endOfMonth(navEnd)) {\n result = endOfMonth(navEnd);\n }\n return result;\n };\n\n const getNextFocus = (\n moveBy: MoveBy,\n moveDir: MoveDir,\n refDate: Date,\n attempt = 0,\n ): Date | undefined => {\n if (attempt > 365) return undefined;\n const next = getFocusableDate(moveBy, moveDir, refDate);\n const isDisabled = disabled ? disabled(next) : false;\n if (!isDisabled) return next;\n return getNextFocus(moveBy, moveDir, next, attempt + 1);\n };\n\n const moveFocus = (moveBy: MoveBy, moveDir: MoveDir) => {\n if (!focusedDate) return;\n const next = getNextFocus(moveBy, moveDir, focusedDate);\n if (!next) return;\n if (!isSameMonth(next, firstMonth)) {\n goToMonth(next);\n }\n setFocusedDate(next);\n };\n\n const handleDayClick =\n (day: CalendarDay, modifiers: DayModifiers) => (event: MouseEvent) => {\n event.preventDefault();\n event.stopPropagation();\n setFocusedDate(day.date);\n if (modifiers.disabled) return;\n const newDate =\n selected && isSameDay(day.date, selected) ? undefined : day.date;\n onSelect?.(newDate, day.date);\n };\n\n const handleDayFocus = (day: CalendarDay) => () => {\n setFocusedDate(day.date);\n };\n\n const handleDayBlur = () => {\n setLastFocusedDate(focusedDate);\n setFocusedDate(undefined);\n };\n\n const handleDayKeyDown = (event: KeyboardEvent) => {\n const keyMap: Record = {\n ArrowLeft: [event.shiftKey ? \"month\" : \"day\", \"before\"],\n ArrowRight: [event.shiftKey ? \"month\" : \"day\", \"after\"],\n ArrowDown: [event.shiftKey ? \"year\" : \"week\", \"after\"],\n ArrowUp: [event.shiftKey ? \"year\" : \"week\", \"before\"],\n PageUp: [event.shiftKey ? \"year\" : \"month\", \"before\"],\n PageDown: [event.shiftKey ? \"year\" : \"month\", \"after\"],\n Home: [\"startOfWeek\", \"before\"],\n End: [\"endOfWeek\", \"after\"],\n };\n const move = keyMap[event.key];\n if (move) {\n event.preventDefault();\n event.stopPropagation();\n const [moveBy, moveDir] = move;\n moveFocus(moveBy, moveDir);\n }\n };\n\n const weekdayHeaderStart = startOfWeek(today, weekStart);\n const weekdays = Array.from({ length: 7 }, (_, index) => {\n const date = addDays(weekdayHeaderStart, index);\n return {\n long: localization.weekdayLong(date),\n short: localization.weekdayShort(date),\n };\n });\n\n return (\n \n
\n
\n {localization.monthYearLabel(firstMonth)}\n
\n
\n {initialLoading ? (\n \n ) : (\n <>\n \n \n {\n if (previousMonth) goToMonth(previousMonth);\n }}\n >\n \n \n {\n if (nextMonth) goToMonth(nextMonth);\n }}\n >\n \n \n \n \n )}\n
\n \n \n \n \n {weekdays.map((weekday) => (\n \n {initialLoading ? (\n \n ) : (\n weekday.short\n )}\n \n ))}\n \n \n \n {weeks.map((week) => (\n \n {week.map((day) => {\n const modifiers = getModifiers(day);\n\n // The day button's own styling is driven by the `data-*`\n // attributes below; these cell classes only cover the\n // layout and the outside/hidden/today states.\n const cellClassName = cn(\n \"@max-3xl:max-h-9 flex-1 p-0\",\n modifiers.hidden && \"invisible\",\n modifiers.outside && \"text-muted-foreground/50\",\n modifiers.today &&\n !dayCellsLoading &&\n \"*:before:pointer-events-none *:before:absolute *:before:start-1/2 *:before:bottom-1/7 *:before:z-1 *:before:size-1 *:before:-translate-x-1/2 *:before:rounded-full *:before:bg-primary *:before:transition-colors [&[data-selected]>*]:before:bg-primary-foreground\",\n );\n\n // Label (e.g. \"Aug\") sits on the 1st of the next month.\n const nextMonthLabel =\n day.isoDate === nextMonthLabelIso\n ? localization.monthShortLabel(day.date)\n : undefined;\n\n // Next-month days only show in the shifted current-month\n // view; a month-name tooltip keeps them from being mistaken\n // for the current month.\n const monthTooltip =\n day.outside && !modifiers.disabled && !modifiers.hidden\n ? localization.monthLongLabel(day.date)\n : undefined;\n\n return (\n \n {modifiers.hidden ? null : dayCellsLoading ? (\n \n \n \n ) : (\n {\n let label = localization.fullDateLabel(day.date);\n if (modifiers.today) {\n label = `${resolvedLabels.today}, ${label}`;\n }\n if (modifiers.selected) {\n label = `${label}, ${resolvedLabels.selected}`;\n }\n return label;\n })()}\n onClick={handleDayClick(day, modifiers)}\n onBlur={handleDayBlur}\n onFocus={handleDayFocus(day)}\n onKeyDown={handleDayKeyDown}\n >\n {localization.dayNumber(day.date)}\n \n )}\n \n );\n })}\n \n ))}\n {Array.from(\n { length: placeholderRows },\n (_, rowIndex) => `placeholder-row-${rowIndex}`,\n ).map((rowKey) => (\n \n {weekdays.map((weekday) => (\n \n
\n \n ))}\n \n ))}\n \n \n \n {({ payload }) => (\n \n {payload}\n \n )}\n \n \n
\n
\n );\n}\n", "type": "registry:component", "target": "components/atoms/booker/booker-calendar.tsx" }, @@ -85,7 +87,7 @@ }, { "path": "registry/default/atoms/booker/time-picker.tsx", - "content": "\"use client\";\n\nimport { CalendarX2Icon } from \"lucide-react\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport {\n Empty,\n EmptyContent,\n EmptyDescription,\n EmptyHeader,\n EmptyMedia,\n EmptyTitle,\n} from \"@/registry/default/ui/empty\";\nimport { Label } from \"@/registry/default/ui/label\";\nimport { ScrollArea } from \"@/registry/default/ui/scroll-area\";\nimport { Skeleton } from \"@/registry/default/ui/skeleton\";\nimport { Switch } from \"@/registry/default/ui/switch\";\nimport {\n displayTimeLabel,\n formatSelectedDay,\n formatSelectedMonth,\n formatSelectedWeekday,\n isSingleDigit12HourLabel,\n} from \"@/lib/booker/utils\";\n\ntype TimePickerLabels = {\n noAvailableTimes: string;\n noSlotsAvailable: string;\n noSlotsThisDay: string;\n noSlotsThisMonth: string;\n viewFirstAvailability: string;\n use24Hour: string;\n hour12Short: string;\n hour24Short: string;\n};\n\ntype TimePickerProps = {\n availabilityLoading: boolean;\n currentMonth: Date;\n daySlots: string[];\n goToDate: (date: Date) => void;\n is24Hour: boolean;\n initialLoading: boolean;\n labels: TimePickerLabels;\n locale: string;\n nextAvailableDate: Date | null;\n onIs24HourChange: (value: boolean) => void;\n onSelectTime: (time: string) => void;\n selectedDate: Date | undefined;\n hasAvailabilityInView: boolean;\n};\n\nexport function TimePicker({\n availabilityLoading,\n currentMonth,\n daySlots,\n goToDate,\n is24Hour,\n initialLoading,\n labels,\n locale,\n nextAvailableDate,\n onIs24HourChange,\n onSelectTime,\n selectedDate,\n hasAvailabilityInView,\n}: TimePickerProps) {\n const slotsLoading = initialLoading || availabilityLoading;\n\n return (\n
\n
\n {(slotsLoading || selectedDate != null) && (\n
\n
\n {slotsLoading ? (\n \n ) : (\n
\n \n {formatSelectedWeekday(selectedDate ?? new Date(), locale)}\n \n \n {selectedDate &&\n selectedDate.getMonth() !== currentMonth.getMonth()\n ? `${formatSelectedMonth(selectedDate, locale)} `\n : null}\n {formatSelectedDay(selectedDate ?? new Date(), locale)}\n \n
\n )}\n {!initialLoading ? (\n \n ) : null}\n
\n
\n )}\n
\n {!slotsLoading && daySlots.length === 0 ? (\n
\n \n \n \n \n \n \n {labels.noAvailableTimes}\n \n \n {hasAvailabilityInView\n ? labels.noSlotsThisDay\n : nextAvailableDate\n ? labels.noSlotsThisMonth\n : labels.noSlotsAvailable}\n \n \n {nextAvailableDate ? (\n \n \n \n ) : null}\n \n
\n ) : (\n \n
\n {slotsLoading ? (\n
\n {Array.from(\n { length: 20 },\n (_, index) => `time-skeleton-${index}`,\n ).map((skeletonKey) => (\n \n ))}\n
\n ) : (\n
\n {daySlots.map((time) => (\n onSelectTime(time)}\n className=\"relative flex h-9 cursor-pointer items-center justify-center rounded-lg bg-muted font-medium text-foreground tabular-nums outline-none transition-shadow after:absolute @5xl:after:-inset-1 after:-inset-0.75 hover:ring-2 hover:ring-primary/72 focus-visible:z-1 focus-visible:ring-2 focus-visible:ring-primary/72 sm:h-8 sm:text-sm\"\n >\n \n {!is24Hour && isSingleDigit12HourLabel(time) ? (\n \n 0\n \n ) : null}\n {displayTimeLabel(time, !is24Hour)}\n \n \n ))}\n
\n )}\n
\n
\n )}\n
\n
\n
\n );\n}\n", + "content": "\"use client\";\n\nimport { CalendarX2Icon } from \"lucide-react\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport {\n Empty,\n EmptyContent,\n EmptyDescription,\n EmptyHeader,\n EmptyMedia,\n EmptyTitle,\n} from \"@/registry/default/ui/empty\";\nimport { Label } from \"@/registry/default/ui/label\";\nimport { ScrollArea } from \"@/registry/default/ui/scroll-area\";\nimport { Skeleton } from \"@/registry/default/ui/skeleton\";\nimport { Switch } from \"@/registry/default/ui/switch\";\nimport {\n displayTimeLabel,\n formatSelectedDay,\n formatSelectedMonth,\n formatSelectedWeekday,\n isSingleDigit12HourLabel,\n} from \"@/lib/booker/utils\";\n\ntype TimePickerLabels = {\n noAvailableTimes: string;\n noSlotsAvailable: string;\n noSlotsThisDay: string;\n noSlotsThisMonth: string;\n viewFirstAvailability: string;\n use24Hour: string;\n hour12Short: string;\n hour24Short: string;\n};\n\ntype TimePickerProps = {\n availabilityLoading: boolean;\n currentMonth: Date;\n daySlots: string[];\n goToDate: (date: Date) => void;\n is24Hour: boolean;\n initialLoading: boolean;\n labels: TimePickerLabels;\n locale: string;\n nextAvailableDate: Date | null;\n onIs24HourChange: (value: boolean) => void;\n onSelectTime: (time: string) => void;\n selectedDate: Date | undefined;\n hasAvailabilityInView: boolean;\n};\n\nexport function TimePicker({\n availabilityLoading,\n currentMonth,\n daySlots,\n goToDate,\n is24Hour,\n initialLoading,\n labels,\n locale,\n nextAvailableDate,\n onIs24HourChange,\n onSelectTime,\n selectedDate,\n hasAvailabilityInView,\n}: TimePickerProps) {\n const slotsLoading = initialLoading || availabilityLoading;\n\n return (\n
\n
\n {(slotsLoading || selectedDate != null) && (\n
\n
\n {slotsLoading ? (\n \n ) : (\n
\n \n {formatSelectedWeekday(selectedDate ?? new Date(), locale)}\n \n \n {selectedDate &&\n selectedDate.getMonth() !== currentMonth.getMonth()\n ? `${formatSelectedMonth(selectedDate, locale)} `\n : null}\n {formatSelectedDay(selectedDate ?? new Date(), locale)}\n \n
\n )}\n {!initialLoading ? (\n \n ) : null}\n
\n
\n )}\n
\n {!slotsLoading && daySlots.length === 0 ? (\n
\n \n \n \n \n \n \n {labels.noAvailableTimes}\n \n \n {hasAvailabilityInView\n ? labels.noSlotsThisDay\n : nextAvailableDate\n ? labels.noSlotsThisMonth\n : labels.noSlotsAvailable}\n \n \n {nextAvailableDate ? (\n \n \n \n ) : null}\n \n
\n ) : (\n \n
\n {slotsLoading ? (\n
\n {Array.from(\n { length: 20 },\n (_, index) => `time-skeleton-${index}`,\n ).map((skeletonKey) => (\n \n ))}\n
\n ) : (\n
\n {daySlots.map((time) => (\n onSelectTime(time)}\n className=\"relative flex h-9 cursor-pointer items-center justify-center rounded-lg bg-muted font-medium text-foreground tabular-nums outline-none transition-shadow after:absolute @5xl:after:-inset-1 after:-inset-0.75 hover:ring-2 hover:ring-primary/72 focus-visible:z-1 focus-visible:ring-2 focus-visible:ring-primary/72 sm:h-8 sm:text-sm\"\n >\n \n {!is24Hour && isSingleDigit12HourLabel(time) ? (\n \n 0\n \n ) : null}\n {displayTimeLabel(time, !is24Hour)}\n \n \n ))}\n
\n )}\n
\n
\n )}\n
\n
\n
\n );\n}\n", "type": "registry:component", "target": "components/atoms/booker/time-picker.tsx" }, @@ -97,7 +99,7 @@ }, { "path": "registry/default/atoms/lib/booker/use-booker.ts", - "content": "\"use client\";\n\nimport {\n useCallback,\n useEffect,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport {\n type FetchRawBookerResult,\n fetchRawBookerDataAction,\n} from \"@/lib/booker/actions\";\nimport {\n type BookerTarget,\n getDynamicContext,\n getOrgSlugFromTarget,\n} from \"@/lib/booker/target\";\nimport {\n type BookerMeta,\n type CalendarViewOptions,\n extractBookingWindowEnd,\n extractBookingWindowStart,\n extractEventTypeInfo,\n extractHost,\n extractHostAvatars,\n extractHostDisplayName,\n extractMinimumBookingNotice,\n extractSlotsByDate,\n filterBookableSlots,\n findNextAvailableDate,\n getMinimumNavigableMonth,\n getTodayInTimeZone,\n getWeekStartsOn,\n hasAvailabilityInVisibleGrid,\n isCalendarMonthBefore,\n pickDefaultSelectedDate,\n prefers24Hour,\n toCalendarDateKey,\n} from \"@/lib/booker/utils\";\n\nconst WINDOW_MONTHS = 3;\n\nexport type BookerErrorKind =\n | \"not-found\"\n | \"unpublished\"\n | \"network\"\n | \"unknown\";\n\nexport type BookerError = {\n kind: BookerErrorKind;\n message: string;\n};\n\nclass BookerDataError extends Error {\n code: string;\n constructor(message: string, code: string) {\n super(message);\n this.code = code;\n }\n}\n\ntype ResolvedEventType = Extract<\n FetchRawBookerResult,\n { ok: true }\n>[\"resolved\"];\n\nexport type BookerInitialData = {\n durationMinutes?: number;\n monthIso: string;\n monthsToFetch?: number;\n result: FetchRawBookerResult;\n timeZone: string;\n};\n\ntype DerivedBookerData = {\n hasMultiDurationOptions: boolean;\n meta: BookerMeta | null;\n minimumBookingNotice: number;\n selectedDurationMinutes: number | null;\n};\n\ntype HydratedBookerData = DerivedBookerData & {\n coveredMonthKeys: Set;\n error: BookerError | null;\n resolved: ResolvedEventType;\n slotsByDate: Record;\n slotsCacheKey: string;\n};\n\nfunction classifyError(caught: unknown): BookerError {\n const code = caught instanceof BookerDataError ? caught.code : \"\";\n const message =\n caught instanceof Error ? caught.message : \"Could not load booker data.\";\n\n if (code === \"UNPUBLISHED\") {\n return { kind: \"unpublished\", message };\n }\n if (code === \"NOT_FOUND\") {\n return { kind: \"not-found\", message };\n }\n if (\n code.startsWith(\"HTTP_4\") ||\n code.startsWith(\"HTTP_5\") ||\n message.includes(\"fetch\") ||\n message.includes(\"network\") ||\n message.includes(\"ETIMEDOUT\") ||\n message.includes(\"ECONNREFUSED\") ||\n /Cal\\.com API [45]\\d\\d/.test(message)\n ) {\n return { kind: \"network\", message };\n }\n return { kind: \"unknown\", message };\n}\n\nfunction getTargetIdentity(target: BookerTarget): string {\n if (target.type === \"user\") {\n return `user:${target.username}:${target.eventSlug}:${target.orgId ?? \"\"}`;\n }\n if (target.type === \"team\") {\n return `team:${target.teamId}:${target.eventSlug}:${target.orgId ?? \"\"}`;\n }\n if (target.type === \"dynamic\") {\n return `dynamic:${target.usernames.join(\",\")}:${target.orgSlug ?? \"\"}:${target.orgId ?? \"\"}`;\n }\n return `link:${target.bookingUrl}`;\n}\n\nfunction getMonthCacheKey(\n identity: string,\n timeZone: string,\n durationMinutes: number,\n month: Date,\n): string {\n return `${identity}|${timeZone}|${durationMinutes}|${month.getFullYear()}-${month.getMonth()}`;\n}\n\nfunction getSlotsCacheKey(\n identity: string,\n timeZone: string,\n durationMinutes: number,\n): string {\n return `${identity}|${timeZone}|${durationMinutes}`;\n}\n\nfunction deriveBookerData(\n result: Extract,\n target: BookerTarget,\n): DerivedBookerData {\n if (!result.raw.me) {\n return {\n hasMultiDurationOptions: false,\n meta: null,\n minimumBookingNotice: 0,\n selectedDurationMinutes: null,\n };\n }\n\n const host = extractHost(result.raw.me);\n const isDynamic = Boolean(getDynamicContext(target));\n const hostAvatars = extractHostAvatars(\n result.raw.selectedEventType,\n result.raw.me,\n { dynamic: isDynamic, orgSlug: getOrgSlugFromTarget(target) },\n );\n const hostName = extractHostDisplayName(\n result.raw.selectedEventType,\n result.raw.me,\n { dynamic: isDynamic },\n );\n const eventType = extractEventTypeInfo(result.raw.selectedEventType);\n const minimumBookingNotice = extractMinimumBookingNotice(\n result.raw.selectedEventType,\n );\n const hasMultiDurationOptions =\n (eventType.eventTypeDurationOptions?.length ?? 0) > 1;\n const selectedDurationMinutes =\n eventType.eventTypeDurationOptions?.[0] ??\n eventType.eventTypeDurationMinutes ??\n null;\n\n return {\n hasMultiDurationOptions,\n meta: {\n bookingWindowEnd: extractBookingWindowEnd(result.raw.selectedEventType),\n bookingWindowStart: extractBookingWindowStart(\n result.raw.selectedEventType,\n ),\n eventTypeDescription: eventType.eventTypeDescription,\n eventTypeDurationMinutes: eventType.eventTypeDurationMinutes,\n eventTypeDurationOptions: eventType.eventTypeDurationOptions,\n eventTypeImageUrl: result.raw.bannerUrl || eventType.eventTypeImageUrl,\n eventTypeLocations: eventType.eventTypeLocations,\n eventTypeTitle: eventType.eventTypeTitle,\n hostAvatarUrl: host.hostAvatarUrl,\n hostAvatars,\n hostName:\n host.hostName === \"Unknown user\" && target.type === \"user\"\n ? target.username\n : result.raw.publicDisplayName || hostName,\n },\n minimumBookingNotice,\n selectedDurationMinutes,\n };\n}\n\nfunction hydrateBookerData({\n initialData,\n selectedTimeZone,\n target,\n}: {\n initialData?: BookerInitialData;\n selectedTimeZone: string;\n target: BookerTarget;\n}): HydratedBookerData | null {\n if (!initialData) {\n return null;\n }\n\n if (initialData.timeZone !== selectedTimeZone) {\n return null;\n }\n\n if (!initialData.result.ok) {\n const error = classifyError(\n new BookerDataError(\n initialData.result.error,\n initialData.result.errorCode ?? \"UNKNOWN\",\n ),\n );\n\n if (error.kind !== \"not-found\" && error.kind !== \"unpublished\") {\n return null;\n }\n\n return {\n coveredMonthKeys: new Set(),\n error,\n hasMultiDurationOptions: false,\n meta: null,\n minimumBookingNotice: 0,\n resolved: { eventTypeId: null, eventTypeSlug: null },\n selectedDurationMinutes: null,\n slotsByDate: {},\n slotsCacheKey: \"\",\n };\n }\n\n const derived = deriveBookerData(initialData.result, target);\n const durationMinutes =\n derived.selectedDurationMinutes ?? initialData.durationMinutes ?? 30;\n const identity = getTargetIdentity(target);\n const anchor = new Date(initialData.monthIso);\n const safeAnchor = Number.isNaN(anchor.getTime()) ? new Date() : anchor;\n const monthsToFetch = Math.max(\n 1,\n Math.floor(initialData.monthsToFetch ?? WINDOW_MONTHS),\n );\n const coveredMonthKeys = new Set();\n\n for (let offset = 0; offset < monthsToFetch; offset += 1) {\n coveredMonthKeys.add(\n getMonthCacheKey(\n identity,\n selectedTimeZone,\n durationMinutes,\n new Date(safeAnchor.getFullYear(), safeAnchor.getMonth() + offset, 1),\n ),\n );\n }\n\n const slotsByDate = filterBookableSlots(\n extractSlotsByDate(initialData.result.raw.slots, selectedTimeZone),\n selectedTimeZone,\n derived.minimumBookingNotice,\n new Date(),\n );\n\n return {\n ...derived,\n coveredMonthKeys,\n error: null,\n resolved: initialData.result.resolved,\n selectedDurationMinutes: durationMinutes,\n slotsByDate,\n slotsCacheKey: getSlotsCacheKey(\n identity,\n selectedTimeZone,\n durationMinutes,\n ),\n };\n}\n\ntype UseBookerParams = {\n initialData?: BookerInitialData;\n target: BookerTarget;\n timezone?: string;\n};\n\ntype BookerLoadingState = {\n availability: boolean;\n busy: boolean;\n initial: boolean;\n};\n\ntype BookerTimezoneState = {\n onValueChange: (timeZone: string) => void;\n value: string;\n};\n\ntype BookerDurationState = {\n onValueChange: (minutes: number) => void;\n value: number;\n};\n\ntype BookerCalendarState = {\n availabilityLoading: boolean;\n disabled: (date: Date) => boolean;\n endMonth?: Date;\n initialLoading: boolean;\n locale: string;\n month: Date;\n onMonthChange: (month: Date) => void;\n onSelect: (nextDate: Date | undefined) => void;\n selected: Date | undefined;\n startMonth: Date;\n today: Date;\n};\n\ntype BookerTimePickerState = {\n availabilityLoading: boolean;\n currentMonth: Date;\n daySlots: string[];\n goToDate: (date: Date) => void;\n hasAvailabilityInView: boolean;\n initialLoading: boolean;\n is24Hour: boolean;\n locale: string;\n nextAvailableDate: Date | null;\n onIs24HourChange: (value: boolean) => void;\n onSelectTime: (time: string) => void;\n selectedDate: Date | undefined;\n};\n\nexport type UseBookerResult = {\n calendarProps: BookerCalendarState;\n durationProps: BookerDurationState;\n error: BookerError | null;\n loadingState: BookerLoadingState;\n meta: BookerMeta | null;\n retry: () => void;\n timePickerProps: BookerTimePickerState;\n timezoneProps: BookerTimezoneState;\n};\n\n// All booker data + interaction logic: fetching/windowing cal.com availability,\n// derived selection state, and the handlers the UI wires to the calendar.\nexport function useBooker({\n initialData,\n target,\n timezone,\n}: UseBookerParams): UseBookerResult {\n const [timeZone] = useState(\n () => Intl.DateTimeFormat().resolvedOptions().timeZone || \"UTC\",\n );\n const [locale] = useState(\n () => Intl.DateTimeFormat().resolvedOptions().locale || \"en-US\",\n );\n const initialSelectedTimeZone = initialData?.timeZone ?? timezone ?? timeZone;\n const hydratedData = useMemo(\n () =>\n hydrateBookerData({\n initialData,\n selectedTimeZone: initialSelectedTimeZone,\n target,\n }),\n [initialData, initialSelectedTimeZone, target],\n );\n const initialCurrentMonth = useMemo(() => {\n const initialMonth = initialData?.monthIso\n ? new Date(initialData.monthIso)\n : new Date();\n return Number.isNaN(initialMonth.getTime()) ? new Date() : initialMonth;\n }, [initialData?.monthIso]);\n const initialSelectedDate = useMemo(() => {\n if (!hydratedData || hydratedData.error) {\n return undefined;\n }\n\n return (\n pickDefaultSelectedDate(\n initialCurrentMonth,\n hydratedData.slotsByDate,\n initialSelectedTimeZone,\n getTodayInTimeZone(initialSelectedTimeZone),\n {\n endMonth: hydratedData.meta?.bookingWindowEnd ?? undefined,\n shiftWeeks: true,\n startMonth: getMinimumNavigableMonth(\n initialSelectedTimeZone,\n hydratedData.meta?.bookingWindowStart,\n ),\n weekStartsOn: getWeekStartsOn(locale),\n },\n ) ?? undefined\n );\n }, [hydratedData, initialCurrentMonth, initialSelectedTimeZone, locale]);\n const [meta, setMeta] = useState(\n hydratedData?.meta ?? null,\n );\n const [slotsByDate, setSlotsByDate] = useState>(\n hydratedData?.slotsByDate ?? {},\n );\n const [error, setError] = useState(\n hydratedData?.error ?? null,\n );\n const [isPending, setIsPending] = useState(false);\n const [currentMonth, setCurrentMonth] = useState(initialCurrentMonth);\n const [selectedTimeZone, setSelectedTimeZone] = useState(\n initialSelectedTimeZone,\n );\n const [selectedDate, setSelectedDate] = useState(\n initialSelectedDate,\n );\n const [_selectedTime, setSelectedTime] = useState(null);\n const [selectedDurationMinutes, setSelectedDurationMinutes] = useState(\n hydratedData?.selectedDurationMinutes ?? 30,\n );\n const [is24Hour, setIs24Hour] = useState(() => prefers24Hour(locale));\n const coveredMonthsRef = useRef>(\n hydratedData?.coveredMonthKeys ?? new Set(),\n );\n const resolvedRef = useRef(\n hydratedData?.resolved ?? { eventTypeId: null, eventTypeSlug: null },\n );\n const lastIdentityRef = useRef(null);\n const lastTimeZoneRef = useRef(null);\n const lastDurationRef = useRef(null);\n const autoSelectedMonthRef = useRef(\n initialSelectedDate\n ? `${initialCurrentMonth.getFullYear()}-${initialCurrentMonth.getMonth()}`\n : null,\n );\n const slotsByDateRef = useRef>(\n hydratedData?.slotsByDate ?? {},\n );\n const slotsCacheRef = useRef>>(\n new Map(\n hydratedData?.slotsCacheKey\n ? [[hydratedData.slotsCacheKey, hydratedData.slotsByDate]]\n : [],\n ),\n );\n const minimumBookingNoticeRef = useRef(\n hydratedData?.minimumBookingNotice ?? 0,\n );\n const hasMultiDurationOptionsRef = useRef(\n hydratedData?.hasMultiDurationOptions ?? false,\n );\n const selectedDateRef = useRef(selectedDate);\n selectedDateRef.current = selectedDate;\n const inFlightMonthsRef = useRef>>(new Map());\n const availabilityRequestVersionRef = useRef(0);\n const skipInitialWarmRef = useRef(\n Boolean(hydratedData && !hydratedData.error),\n );\n\n const targetIdentity = useCallback((): string => {\n return getTargetIdentity(target);\n }, [target]);\n\n const buildKey = useCallback(\n (month: Date) =>\n getMonthCacheKey(\n targetIdentity(),\n selectedTimeZone,\n selectedDurationMinutes,\n month,\n ),\n [selectedDurationMinutes, selectedTimeZone, targetIdentity],\n );\n\n const slotsCacheKey = useCallback(\n () =>\n getSlotsCacheKey(\n targetIdentity(),\n selectedTimeZone,\n selectedDurationMinutes,\n ),\n [selectedDurationMinutes, selectedTimeZone, targetIdentity],\n );\n\n const restoreSlotsFromCache = useCallback(() => {\n const cached = slotsCacheRef.current.get(slotsCacheKey()) ?? {};\n slotsByDateRef.current = cached;\n setSlotsByDate(cached);\n }, [slotsCacheKey]);\n\n useLayoutEffect(() => {\n restoreSlotsFromCache();\n }, [restoreSlotsFromCache]);\n\n // Fetch a window of several months in a single request, anchored at `anchor`.\n const fetchWindow = useCallback(\n async (anchor: Date): Promise => {\n const anchorKey = buildKey(anchor);\n\n if (coveredMonthsRef.current.has(anchorKey)) {\n return;\n }\n\n const existingRequest = inFlightMonthsRef.current.get(anchorKey);\n if (existingRequest) {\n await existingRequest;\n return;\n }\n\n const requestVersion = availabilityRequestVersionRef.current;\n const windowMonthKeys = Array.from(\n { length: WINDOW_MONTHS },\n (_, offset) =>\n buildKey(\n new Date(anchor.getFullYear(), anchor.getMonth() + offset, 1),\n ),\n );\n const request = (async () => {\n const durationMinutes =\n getDynamicContext(target) || hasMultiDurationOptionsRef.current\n ? selectedDurationMinutes\n : undefined;\n\n const result = await fetchRawBookerDataAction({\n durationMinutes,\n fetchMeta: resolvedRef.current.eventTypeId == null,\n monthIso: anchor.toISOString(),\n monthsToFetch: WINDOW_MONTHS,\n target,\n timeZone: selectedTimeZone,\n eventTypeId: resolvedRef.current.eventTypeId ?? undefined,\n });\n\n if (!result.ok) {\n throw new BookerDataError(\n result.error,\n result.errorCode ?? \"UNKNOWN\",\n );\n }\n\n if (availabilityRequestVersionRef.current !== requestVersion) {\n return;\n }\n\n resolvedRef.current = result.resolved;\n\n for (let offset = 0; offset < WINDOW_MONTHS; offset += 1) {\n const month = new Date(\n anchor.getFullYear(),\n anchor.getMonth() + offset,\n 1,\n );\n coveredMonthsRef.current.add(buildKey(month));\n }\n\n // The host/event metadata only comes back on the full (non slots-only)\n // fetch, so set it once when present.\n if (result.raw.me) {\n const derived = deriveBookerData(result, target);\n minimumBookingNoticeRef.current = derived.minimumBookingNotice;\n hasMultiDurationOptionsRef.current = derived.hasMultiDurationOptions;\n setMeta((prev) => prev ?? derived.meta);\n\n const defaultDuration = derived.selectedDurationMinutes ?? 30;\n if (derived.meta?.eventTypeDurationOptions?.length) {\n if (\n !derived.meta.eventTypeDurationOptions.includes(\n selectedDurationMinutes,\n )\n ) {\n setSelectedDurationMinutes(defaultDuration);\n }\n } else if (\n derived.meta?.eventTypeDurationMinutes != null &&\n selectedDurationMinutes !== derived.meta.eventTypeDurationMinutes\n ) {\n setSelectedDurationMinutes(derived.meta.eventTypeDurationMinutes);\n }\n }\n\n const windowSlots = filterBookableSlots(\n extractSlotsByDate(result.raw.slots, selectedTimeZone),\n selectedTimeZone,\n minimumBookingNoticeRef.current,\n new Date(),\n );\n const cacheKey = getSlotsCacheKey(\n targetIdentity(),\n selectedTimeZone,\n selectedDurationMinutes,\n );\n const mergedSlots = {\n ...(slotsCacheRef.current.get(cacheKey) ?? {}),\n ...windowSlots,\n };\n slotsCacheRef.current.set(cacheKey, mergedSlots);\n slotsByDateRef.current = mergedSlots;\n setSlotsByDate(mergedSlots);\n })();\n\n for (const monthKey of windowMonthKeys) {\n inFlightMonthsRef.current.set(monthKey, request);\n }\n\n try {\n await request;\n } finally {\n for (const monthKey of windowMonthKeys) {\n if (inFlightMonthsRef.current.get(monthKey) === request) {\n inFlightMonthsRef.current.delete(monthKey);\n }\n }\n }\n },\n [\n buildKey,\n selectedDurationMinutes,\n selectedTimeZone,\n target,\n targetIdentity,\n ],\n );\n\n const todayStart = useMemo(\n () => getTodayInTimeZone(selectedTimeZone),\n [selectedTimeZone],\n );\n const startMonth = useMemo(\n () => getMinimumNavigableMonth(selectedTimeZone, meta?.bookingWindowStart),\n [meta?.bookingWindowStart, selectedTimeZone],\n );\n const calendarViewOptions = useMemo((): CalendarViewOptions => {\n return {\n endMonth: meta?.bookingWindowEnd ?? undefined,\n shiftWeeks: true,\n startMonth,\n weekStartsOn: getWeekStartsOn(locale),\n };\n }, [locale, meta?.bookingWindowEnd, startMonth]);\n\n const hasAvailabilityInView = useMemo(\n () =>\n hasAvailabilityInVisibleGrid(\n currentMonth,\n slotsByDate,\n todayStart,\n calendarViewOptions,\n ),\n [calendarViewOptions, currentMonth, slotsByDate, todayStart],\n );\n\n useEffect(() => {\n let cancelled = false;\n\n const load = async () => {\n const identity = targetIdentity();\n let deferLoadForMonthSync = false;\n\n // A different user/event means a different event type and metadata, so\n // reset everything and force a full fetch.\n if (\n lastIdentityRef.current !== null &&\n lastIdentityRef.current !== identity\n ) {\n coveredMonthsRef.current = new Set();\n inFlightMonthsRef.current.clear();\n availabilityRequestVersionRef.current += 1;\n resolvedRef.current = { eventTypeId: null, eventTypeSlug: null };\n hasMultiDurationOptionsRef.current = false;\n slotsCacheRef.current = new Map();\n slotsByDateRef.current = {};\n autoSelectedMonthRef.current = null;\n setMeta(null);\n setSelectedDurationMinutes(30);\n setSlotsByDate({});\n } else if (\n lastDurationRef.current !== null &&\n lastDurationRef.current !== selectedDurationMinutes\n ) {\n inFlightMonthsRef.current.clear();\n availabilityRequestVersionRef.current += 1;\n restoreSlotsFromCache();\n autoSelectedMonthRef.current = null;\n setSelectedTime(null);\n } else if (\n lastTimeZoneRef.current !== null &&\n lastTimeZoneRef.current !== selectedTimeZone\n ) {\n coveredMonthsRef.current = new Set();\n inFlightMonthsRef.current.clear();\n availabilityRequestVersionRef.current += 1;\n restoreSlotsFromCache();\n autoSelectedMonthRef.current = null;\n setSelectedTime(null);\n\n const minMonth = getMinimumNavigableMonth(\n selectedTimeZone,\n meta?.bookingWindowStart,\n );\n if (isCalendarMonthBefore(currentMonth, minMonth)) {\n setCurrentMonth(minMonth);\n deferLoadForMonthSync = true;\n } else {\n const todayInTz = getTodayInTimeZone(selectedTimeZone);\n if (\n selectedDateRef.current &&\n toCalendarDateKey(selectedDateRef.current) <\n toCalendarDateKey(todayInTz)\n ) {\n setSelectedDate(undefined);\n }\n }\n }\n lastIdentityRef.current = identity;\n lastTimeZoneRef.current = selectedTimeZone;\n lastDurationRef.current = selectedDurationMinutes;\n\n if (deferLoadForMonthSync) {\n return;\n }\n\n if (!coveredMonthsRef.current.has(buildKey(currentMonth))) {\n setIsPending(true);\n try {\n await fetchWindow(currentMonth);\n if (cancelled) {\n return;\n }\n setError(null);\n } catch (caught) {\n if (cancelled) {\n return;\n }\n setError(classifyError(caught));\n return;\n } finally {\n if (!cancelled) {\n setIsPending(false);\n }\n }\n } else {\n setError(null);\n setIsPending(false);\n }\n\n // Auto-select the first available day the first time this month's\n // availability is known (on load and on month navigation).\n const monthKey = `${currentMonth.getFullYear()}-${currentMonth.getMonth()}`;\n if (!cancelled && autoSelectedMonthRef.current !== monthKey) {\n const firstAvailable = pickDefaultSelectedDate(\n currentMonth,\n slotsByDateRef.current,\n selectedTimeZone,\n getTodayInTimeZone(selectedTimeZone),\n calendarViewOptions,\n );\n if (firstAvailable) {\n autoSelectedMonthRef.current = monthKey;\n setSelectedDate(firstAvailable);\n setSelectedTime(null);\n } else {\n autoSelectedMonthRef.current = monthKey;\n setSelectedDate(undefined);\n setSelectedTime(null);\n }\n }\n\n // Warm the next uncovered window boundary, not just the adjacent month.\n // This keeps navigation instant when the user reaches the end of the\n // currently-covered 3-month range.\n const nextWindow = new Date(\n currentMonth.getFullYear(),\n currentMonth.getMonth() + WINDOW_MONTHS,\n 1,\n );\n if (skipInitialWarmRef.current) {\n skipInitialWarmRef.current = false;\n return;\n }\n if (!coveredMonthsRef.current.has(buildKey(nextWindow))) {\n void fetchWindow(nextWindow).catch(() => {});\n }\n };\n\n void load();\n\n return () => {\n cancelled = true;\n };\n }, [\n buildKey,\n currentMonth,\n fetchWindow,\n selectedDurationMinutes,\n selectedTimeZone,\n meta?.bookingWindowStart,\n calendarViewOptions,\n restoreSlotsFromCache,\n targetIdentity,\n ]);\n\n const [retryCounter, setRetryCounter] = useState(0);\n\n const retry = useCallback(() => {\n coveredMonthsRef.current = new Set();\n inFlightMonthsRef.current.clear();\n availabilityRequestVersionRef.current += 1;\n slotsCacheRef.current = new Map();\n slotsByDateRef.current = {};\n autoSelectedMonthRef.current = null;\n setSlotsByDate({});\n setError(null);\n setRetryCounter((c) => c + 1);\n }, []);\n\n // Re-trigger the main load effect when the user retries.\n useEffect(() => {\n // The first render (retryCounter === 0) is handled by the main effect.\n if (retryCounter === 0) return;\n setIsPending(true);\n let cancelled = false;\n\n (async () => {\n try {\n await fetchWindow(currentMonth);\n if (cancelled) return;\n setError(null);\n\n const monthKey = `${currentMonth.getFullYear()}-${currentMonth.getMonth()}`;\n if (autoSelectedMonthRef.current !== monthKey) {\n const firstAvailable = pickDefaultSelectedDate(\n currentMonth,\n slotsByDateRef.current,\n selectedTimeZone,\n getTodayInTimeZone(selectedTimeZone),\n calendarViewOptions,\n );\n if (firstAvailable) {\n autoSelectedMonthRef.current = monthKey;\n setSelectedDate(firstAvailable);\n setSelectedTime(null);\n } else {\n autoSelectedMonthRef.current = monthKey;\n setSelectedDate(undefined);\n setSelectedTime(null);\n }\n }\n } catch (caught) {\n if (cancelled) return;\n setError(classifyError(caught));\n } finally {\n if (!cancelled) setIsPending(false);\n }\n })();\n\n return () => {\n cancelled = true;\n };\n }, [\n retryCounter,\n fetchWindow,\n currentMonth,\n selectedTimeZone,\n calendarViewOptions,\n ]);\n\n useEffect(() => {\n if (!timezone) {\n return;\n }\n setSelectedTimeZone(timezone);\n }, [timezone]);\n\n const selectedDateKey = toCalendarDateKey(selectedDate ?? todayStart);\n const daySlots =\n selectedDate != null ? (slotsByDate[selectedDateKey] ?? []) : [];\n const nextAvailableDate = findNextAvailableDate(\n slotsByDate,\n selectedTimeZone,\n todayStart,\n );\n\n const handleMonthChange = (month: Date) => {\n const monthKey = `${month.getFullYear()}-${month.getMonth()}`;\n const monthCovered = coveredMonthsRef.current.has(buildKey(month));\n setCurrentMonth(month);\n // Flip to pending in the same render as the month change when the target\n // month still needs fetching, so the skeleton shows immediately instead of\n // the empty state flashing for a frame before the async effect runs.\n if (!monthCovered) {\n setIsPending(true);\n }\n // For an already-loaded month, select the first available day in the same\n // update as the month change so the selection doesn't flicker.\n const firstAvailable = pickDefaultSelectedDate(\n month,\n slotsByDateRef.current,\n selectedTimeZone,\n getTodayInTimeZone(selectedTimeZone),\n calendarViewOptions,\n );\n if (firstAvailable) {\n autoSelectedMonthRef.current = monthKey;\n setSelectedDate(firstAvailable);\n setSelectedTime(null);\n } else {\n if (monthCovered) {\n autoSelectedMonthRef.current = monthKey;\n }\n setSelectedDate(undefined);\n setSelectedTime(null);\n }\n };\n\n const handleSelectDate = (nextDate: Date | undefined) => {\n if (!nextDate) {\n return;\n }\n setSelectedDate(nextDate);\n setSelectedTime(null);\n autoSelectedMonthRef.current = `${nextDate.getFullYear()}-${nextDate.getMonth()}`;\n const isDifferentMonth =\n nextDate.getMonth() !== currentMonth.getMonth() ||\n nextDate.getFullYear() !== currentMonth.getFullYear();\n if (isDifferentMonth) {\n setCurrentMonth(nextDate);\n }\n };\n\n const isDayDisabled = (date: Date) => {\n const dayKey = toCalendarDateKey(date);\n if (dayKey < toCalendarDateKey(todayStart)) {\n return true;\n }\n\n return (slotsByDate[dayKey] ?? []).length === 0;\n };\n\n const goToDate = (date: Date) => {\n setCurrentMonth(date);\n setSelectedDate(date);\n setSelectedTime(null);\n autoSelectedMonthRef.current = `${date.getFullYear()}-${date.getMonth()}`;\n };\n\n const initialLoading = !meta;\n const currentMonthCovered = coveredMonthsRef.current.has(\n buildKey(currentMonth),\n );\n const availabilityLoading = isPending || !currentMonthCovered;\n\n return {\n calendarProps: {\n availabilityLoading,\n disabled: isDayDisabled,\n endMonth: meta?.bookingWindowEnd ?? undefined,\n initialLoading,\n locale,\n month: currentMonth,\n onMonthChange: handleMonthChange,\n onSelect: handleSelectDate,\n selected: selectedDate,\n startMonth,\n today: todayStart,\n },\n durationProps: {\n onValueChange: setSelectedDurationMinutes,\n value: selectedDurationMinutes,\n },\n error,\n loadingState: {\n availability: availabilityLoading,\n busy: initialLoading || availabilityLoading,\n initial: initialLoading,\n },\n meta,\n retry,\n timePickerProps: {\n availabilityLoading,\n currentMonth,\n daySlots,\n goToDate,\n hasAvailabilityInView,\n initialLoading,\n is24Hour,\n locale,\n nextAvailableDate,\n onIs24HourChange: setIs24Hour,\n onSelectTime: setSelectedTime,\n selectedDate,\n },\n timezoneProps: {\n onValueChange: setSelectedTimeZone,\n value: selectedTimeZone,\n },\n };\n}\n", + "content": "\"use client\";\n\nimport {\n useCallback,\n useEffect,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport {\n type FetchRawBookerResult,\n fetchRawBookerDataAction,\n} from \"@/lib/booker/actions\";\nimport {\n type BookerTarget,\n getDynamicContext,\n getOrgSlugFromTarget,\n} from \"@/lib/booker/target\";\nimport {\n type BookerMeta,\n type CalendarViewOptions,\n extractBookingWindowEnd,\n extractBookingWindowStart,\n extractEventTypeInfo,\n extractHost,\n extractHostAvatars,\n extractHostDisplayName,\n extractMinimumBookingNotice,\n extractSlotsByDate,\n filterBookableSlots,\n findNextAvailableDate,\n getMinimumNavigableMonth,\n getTodayInTimeZone,\n getWeekStartsOn,\n hasAvailabilityInVisibleGrid,\n isCalendarMonthBefore,\n pickDefaultSelectedDate,\n prefers24Hour,\n toCalendarDateKey,\n} from \"@/lib/booker/utils\";\n\nconst WINDOW_MONTHS = 3;\n\nexport type BookerErrorKind =\n | \"not-found\"\n | \"unpublished\"\n | \"network\"\n | \"unknown\";\n\nexport type BookerError = {\n kind: BookerErrorKind;\n message: string;\n};\n\nclass BookerDataError extends Error {\n code: string;\n constructor(message: string, code: string) {\n super(message);\n this.code = code;\n }\n}\n\ntype ResolvedEventType = Extract<\n FetchRawBookerResult,\n { ok: true }\n>[\"resolved\"];\n\nexport type BookerInitialData = {\n durationMinutes?: number;\n monthIso: string;\n monthsToFetch?: number;\n result: FetchRawBookerResult;\n timeZone: string;\n};\n\ntype DerivedBookerData = {\n hasMultiDurationOptions: boolean;\n meta: BookerMeta | null;\n minimumBookingNotice: number;\n selectedDurationMinutes: number | null;\n};\n\ntype HydratedBookerData = DerivedBookerData & {\n coveredMonthKeys: Set;\n error: BookerError | null;\n resolved: ResolvedEventType;\n slotsByDate: Record;\n slotsCacheKey: string;\n};\n\nfunction classifyError(caught: unknown): BookerError {\n const code = caught instanceof BookerDataError ? caught.code : \"\";\n const message =\n caught instanceof Error ? caught.message : \"Could not load booker data.\";\n\n if (code === \"UNPUBLISHED\") {\n return { kind: \"unpublished\", message };\n }\n if (code === \"NOT_FOUND\") {\n return { kind: \"not-found\", message };\n }\n if (\n code.startsWith(\"HTTP_4\") ||\n code.startsWith(\"HTTP_5\") ||\n message.includes(\"fetch\") ||\n message.includes(\"network\") ||\n message.includes(\"ETIMEDOUT\") ||\n message.includes(\"ECONNREFUSED\") ||\n /Cal\\.com API [45]\\d\\d/.test(message)\n ) {\n return { kind: \"network\", message };\n }\n return { kind: \"unknown\", message };\n}\n\nfunction getTargetIdentity(target: BookerTarget): string {\n if (target.type === \"user\") {\n return `user:${target.username}:${target.eventSlug}:${target.orgId ?? \"\"}`;\n }\n if (target.type === \"team\") {\n return `team:${target.teamId}:${target.eventSlug}:${target.orgId ?? \"\"}`;\n }\n if (target.type === \"dynamic\") {\n return `dynamic:${target.usernames.join(\",\")}:${target.orgSlug ?? \"\"}:${target.orgId ?? \"\"}`;\n }\n return `link:${target.bookingUrl}`;\n}\n\nfunction getMonthCacheKey(\n identity: string,\n timeZone: string,\n durationMinutes: number,\n month: Date,\n): string {\n return `${identity}|${timeZone}|${durationMinutes}|${month.getFullYear()}-${month.getMonth()}`;\n}\n\nfunction getSlotsCacheKey(\n identity: string,\n timeZone: string,\n durationMinutes: number,\n): string {\n return `${identity}|${timeZone}|${durationMinutes}`;\n}\n\nfunction deriveBookerData(\n result: Extract,\n target: BookerTarget,\n): DerivedBookerData {\n if (!result.raw.me) {\n return {\n hasMultiDurationOptions: false,\n meta: null,\n minimumBookingNotice: 0,\n selectedDurationMinutes: null,\n };\n }\n\n const host = extractHost(result.raw.me);\n const isDynamic = Boolean(getDynamicContext(target));\n const hostAvatars = extractHostAvatars(\n result.raw.selectedEventType,\n result.raw.me,\n { dynamic: isDynamic, orgSlug: getOrgSlugFromTarget(target) },\n );\n const hostName = extractHostDisplayName(\n result.raw.selectedEventType,\n result.raw.me,\n { dynamic: isDynamic },\n );\n const eventType = extractEventTypeInfo(result.raw.selectedEventType);\n const minimumBookingNotice = extractMinimumBookingNotice(\n result.raw.selectedEventType,\n );\n const hasMultiDurationOptions =\n (eventType.eventTypeDurationOptions?.length ?? 0) > 1;\n const selectedDurationMinutes =\n eventType.eventTypeDurationOptions?.[0] ??\n eventType.eventTypeDurationMinutes ??\n null;\n\n return {\n hasMultiDurationOptions,\n meta: {\n bookingWindowEnd: extractBookingWindowEnd(result.raw.selectedEventType),\n bookingWindowStart: extractBookingWindowStart(\n result.raw.selectedEventType,\n ),\n eventTypeDescription: eventType.eventTypeDescription,\n eventTypeDurationMinutes: eventType.eventTypeDurationMinutes,\n eventTypeDurationOptions: eventType.eventTypeDurationOptions,\n eventTypeImageUrl: result.raw.bannerUrl || eventType.eventTypeImageUrl,\n eventTypeLocations: eventType.eventTypeLocations,\n eventTypeTitle: eventType.eventTypeTitle,\n hostAvatarUrl: host.hostAvatarUrl,\n hostAvatars,\n hostName:\n host.hostName === \"Unknown user\" && target.type === \"user\"\n ? target.username\n : result.raw.publicDisplayName || hostName,\n },\n minimumBookingNotice,\n selectedDurationMinutes,\n };\n}\n\nfunction hydrateBookerData({\n initialData,\n selectedTimeZone,\n target,\n}: {\n initialData?: BookerInitialData;\n selectedTimeZone: string;\n target: BookerTarget;\n}): HydratedBookerData | null {\n if (!initialData) {\n return null;\n }\n\n if (initialData.timeZone !== selectedTimeZone) {\n return null;\n }\n\n if (!initialData.result.ok) {\n const error = classifyError(\n new BookerDataError(\n initialData.result.error,\n initialData.result.errorCode ?? \"UNKNOWN\",\n ),\n );\n\n if (error.kind !== \"not-found\" && error.kind !== \"unpublished\") {\n return null;\n }\n\n return {\n coveredMonthKeys: new Set(),\n error,\n hasMultiDurationOptions: false,\n meta: null,\n minimumBookingNotice: 0,\n resolved: { eventTypeId: null, eventTypeSlug: null },\n selectedDurationMinutes: null,\n slotsByDate: {},\n slotsCacheKey: \"\",\n };\n }\n\n const derived = deriveBookerData(initialData.result, target);\n const durationMinutes =\n derived.selectedDurationMinutes ?? initialData.durationMinutes ?? 30;\n const identity = getTargetIdentity(target);\n const anchor = new Date(initialData.monthIso);\n const safeAnchor = Number.isNaN(anchor.getTime()) ? new Date() : anchor;\n const monthsToFetch = Math.max(\n 1,\n Math.floor(initialData.monthsToFetch ?? WINDOW_MONTHS),\n );\n const coveredMonthKeys = new Set();\n\n for (let offset = 0; offset < monthsToFetch; offset += 1) {\n coveredMonthKeys.add(\n getMonthCacheKey(\n identity,\n selectedTimeZone,\n durationMinutes,\n new Date(safeAnchor.getFullYear(), safeAnchor.getMonth() + offset, 1),\n ),\n );\n }\n\n const slotsByDate = filterBookableSlots(\n extractSlotsByDate(initialData.result.raw.slots, selectedTimeZone),\n selectedTimeZone,\n derived.minimumBookingNotice,\n new Date(),\n );\n\n return {\n ...derived,\n coveredMonthKeys,\n error: null,\n resolved: initialData.result.resolved,\n selectedDurationMinutes: durationMinutes,\n slotsByDate,\n slotsCacheKey: getSlotsCacheKey(\n identity,\n selectedTimeZone,\n durationMinutes,\n ),\n };\n}\n\ntype UseBookerParams = {\n initialData?: BookerInitialData;\n target: BookerTarget;\n timezone?: string;\n};\n\ntype BookerLoadingState = {\n availability: boolean;\n busy: boolean;\n initial: boolean;\n};\n\ntype BookerTimezoneState = {\n onValueChange: (timeZone: string) => void;\n value: string;\n};\n\ntype BookerDurationState = {\n onValueChange: (minutes: number) => void;\n value: number;\n};\n\ntype BookerCalendarState = {\n availabilityLoading: boolean;\n disabled: (date: Date) => boolean;\n endMonth?: Date;\n initialLoading: boolean;\n locale: string;\n month: Date;\n onMonthChange: (month: Date) => void;\n onSelect: (nextDate: Date | undefined) => void;\n selected: Date | undefined;\n startMonth: Date;\n today: Date;\n};\n\ntype BookerTimePickerState = {\n availabilityLoading: boolean;\n currentMonth: Date;\n daySlots: string[];\n goToDate: (date: Date) => void;\n hasAvailabilityInView: boolean;\n initialLoading: boolean;\n is24Hour: boolean;\n locale: string;\n nextAvailableDate: Date | null;\n onIs24HourChange: (value: boolean) => void;\n onSelectTime: (time: string) => void;\n selectedDate: Date | undefined;\n};\n\nexport type BookerStep = \"select\" | \"confirm\";\n\nexport type UseBookerResult = {\n calendarProps: BookerCalendarState;\n durationProps: BookerDurationState;\n error: BookerError | null;\n loadingState: BookerLoadingState;\n meta: BookerMeta | null;\n onBack: () => void;\n retry: () => void;\n selectedTime: string | null;\n step: BookerStep;\n timePickerProps: BookerTimePickerState;\n timezoneProps: BookerTimezoneState;\n};\n\n// All booker data + interaction logic: fetching/windowing cal.com availability,\n// derived selection state, and the handlers the UI wires to the calendar.\nexport function useBooker({\n initialData,\n target,\n timezone,\n}: UseBookerParams): UseBookerResult {\n const [timeZone] = useState(\n () => Intl.DateTimeFormat().resolvedOptions().timeZone || \"UTC\",\n );\n const [locale] = useState(\n () => Intl.DateTimeFormat().resolvedOptions().locale || \"en-US\",\n );\n const initialSelectedTimeZone = initialData?.timeZone ?? timezone ?? timeZone;\n const hydratedData = useMemo(\n () =>\n hydrateBookerData({\n initialData,\n selectedTimeZone: initialSelectedTimeZone,\n target,\n }),\n [initialData, initialSelectedTimeZone, target],\n );\n const initialCurrentMonth = useMemo(() => {\n const initialMonth = initialData?.monthIso\n ? new Date(initialData.monthIso)\n : new Date();\n return Number.isNaN(initialMonth.getTime()) ? new Date() : initialMonth;\n }, [initialData?.monthIso]);\n const initialSelectedDate = useMemo(() => {\n if (!hydratedData || hydratedData.error) {\n return undefined;\n }\n\n return (\n pickDefaultSelectedDate(\n initialCurrentMonth,\n hydratedData.slotsByDate,\n initialSelectedTimeZone,\n getTodayInTimeZone(initialSelectedTimeZone),\n {\n endMonth: hydratedData.meta?.bookingWindowEnd ?? undefined,\n shiftWeeks: true,\n startMonth: getMinimumNavigableMonth(\n initialSelectedTimeZone,\n hydratedData.meta?.bookingWindowStart,\n ),\n weekStartsOn: getWeekStartsOn(locale),\n },\n ) ?? undefined\n );\n }, [hydratedData, initialCurrentMonth, initialSelectedTimeZone, locale]);\n const [meta, setMeta] = useState(\n hydratedData?.meta ?? null,\n );\n const [slotsByDate, setSlotsByDate] = useState>(\n hydratedData?.slotsByDate ?? {},\n );\n const [error, setError] = useState(\n hydratedData?.error ?? null,\n );\n const [isPending, setIsPending] = useState(false);\n const [currentMonth, setCurrentMonth] = useState(initialCurrentMonth);\n const [selectedTimeZone, setSelectedTimeZone] = useState(\n initialSelectedTimeZone,\n );\n const [selectedDate, setSelectedDate] = useState(\n initialSelectedDate,\n );\n const [_selectedTime, setSelectedTime] = useState(null);\n const [step, setStep] = useState(\"select\");\n const [selectedDurationMinutes, setSelectedDurationMinutes] = useState(\n hydratedData?.selectedDurationMinutes ?? 30,\n );\n const [is24Hour, setIs24Hour] = useState(() => prefers24Hour(locale));\n const coveredMonthsRef = useRef>(\n hydratedData?.coveredMonthKeys ?? new Set(),\n );\n const resolvedRef = useRef(\n hydratedData?.resolved ?? { eventTypeId: null, eventTypeSlug: null },\n );\n const lastIdentityRef = useRef(null);\n const lastTimeZoneRef = useRef(null);\n const lastDurationRef = useRef(null);\n const autoSelectedMonthRef = useRef(\n initialSelectedDate\n ? `${initialCurrentMonth.getFullYear()}-${initialCurrentMonth.getMonth()}`\n : null,\n );\n const slotsByDateRef = useRef>(\n hydratedData?.slotsByDate ?? {},\n );\n const slotsCacheRef = useRef>>(\n new Map(\n hydratedData?.slotsCacheKey\n ? [[hydratedData.slotsCacheKey, hydratedData.slotsByDate]]\n : [],\n ),\n );\n const minimumBookingNoticeRef = useRef(\n hydratedData?.minimumBookingNotice ?? 0,\n );\n const hasMultiDurationOptionsRef = useRef(\n hydratedData?.hasMultiDurationOptions ?? false,\n );\n const selectedDateRef = useRef(selectedDate);\n selectedDateRef.current = selectedDate;\n const inFlightMonthsRef = useRef>>(new Map());\n const availabilityRequestVersionRef = useRef(0);\n const skipInitialWarmRef = useRef(\n Boolean(hydratedData && !hydratedData.error),\n );\n\n const targetIdentity = useCallback((): string => {\n return getTargetIdentity(target);\n }, [target]);\n\n const buildKey = useCallback(\n (month: Date) =>\n getMonthCacheKey(\n targetIdentity(),\n selectedTimeZone,\n selectedDurationMinutes,\n month,\n ),\n [selectedDurationMinutes, selectedTimeZone, targetIdentity],\n );\n\n const slotsCacheKey = useCallback(\n () =>\n getSlotsCacheKey(\n targetIdentity(),\n selectedTimeZone,\n selectedDurationMinutes,\n ),\n [selectedDurationMinutes, selectedTimeZone, targetIdentity],\n );\n\n const restoreSlotsFromCache = useCallback(() => {\n const cached = slotsCacheRef.current.get(slotsCacheKey()) ?? {};\n slotsByDateRef.current = cached;\n setSlotsByDate(cached);\n }, [slotsCacheKey]);\n\n useLayoutEffect(() => {\n restoreSlotsFromCache();\n }, [restoreSlotsFromCache]);\n\n // Fetch a window of several months in a single request, anchored at `anchor`.\n const fetchWindow = useCallback(\n async (anchor: Date): Promise => {\n const anchorKey = buildKey(anchor);\n\n if (coveredMonthsRef.current.has(anchorKey)) {\n return;\n }\n\n const existingRequest = inFlightMonthsRef.current.get(anchorKey);\n if (existingRequest) {\n await existingRequest;\n return;\n }\n\n const requestVersion = availabilityRequestVersionRef.current;\n const windowMonthKeys = Array.from(\n { length: WINDOW_MONTHS },\n (_, offset) =>\n buildKey(\n new Date(anchor.getFullYear(), anchor.getMonth() + offset, 1),\n ),\n );\n const request = (async () => {\n const durationMinutes =\n getDynamicContext(target) || hasMultiDurationOptionsRef.current\n ? selectedDurationMinutes\n : undefined;\n\n const result = await fetchRawBookerDataAction({\n durationMinutes,\n fetchMeta: resolvedRef.current.eventTypeId == null,\n monthIso: anchor.toISOString(),\n monthsToFetch: WINDOW_MONTHS,\n target,\n timeZone: selectedTimeZone,\n eventTypeId: resolvedRef.current.eventTypeId ?? undefined,\n });\n\n if (!result.ok) {\n throw new BookerDataError(\n result.error,\n result.errorCode ?? \"UNKNOWN\",\n );\n }\n\n if (availabilityRequestVersionRef.current !== requestVersion) {\n return;\n }\n\n resolvedRef.current = result.resolved;\n\n for (let offset = 0; offset < WINDOW_MONTHS; offset += 1) {\n const month = new Date(\n anchor.getFullYear(),\n anchor.getMonth() + offset,\n 1,\n );\n coveredMonthsRef.current.add(buildKey(month));\n }\n\n // The host/event metadata only comes back on the full (non slots-only)\n // fetch, so set it once when present.\n if (result.raw.me) {\n const derived = deriveBookerData(result, target);\n minimumBookingNoticeRef.current = derived.minimumBookingNotice;\n hasMultiDurationOptionsRef.current = derived.hasMultiDurationOptions;\n setMeta((prev) => prev ?? derived.meta);\n\n const defaultDuration = derived.selectedDurationMinutes ?? 30;\n if (derived.meta?.eventTypeDurationOptions?.length) {\n if (\n !derived.meta.eventTypeDurationOptions.includes(\n selectedDurationMinutes,\n )\n ) {\n setSelectedDurationMinutes(defaultDuration);\n }\n } else if (\n derived.meta?.eventTypeDurationMinutes != null &&\n selectedDurationMinutes !== derived.meta.eventTypeDurationMinutes\n ) {\n setSelectedDurationMinutes(derived.meta.eventTypeDurationMinutes);\n }\n }\n\n const windowSlots = filterBookableSlots(\n extractSlotsByDate(result.raw.slots, selectedTimeZone),\n selectedTimeZone,\n minimumBookingNoticeRef.current,\n new Date(),\n );\n const cacheKey = getSlotsCacheKey(\n targetIdentity(),\n selectedTimeZone,\n selectedDurationMinutes,\n );\n const mergedSlots = {\n ...(slotsCacheRef.current.get(cacheKey) ?? {}),\n ...windowSlots,\n };\n slotsCacheRef.current.set(cacheKey, mergedSlots);\n slotsByDateRef.current = mergedSlots;\n setSlotsByDate(mergedSlots);\n })();\n\n for (const monthKey of windowMonthKeys) {\n inFlightMonthsRef.current.set(monthKey, request);\n }\n\n try {\n await request;\n } finally {\n for (const monthKey of windowMonthKeys) {\n if (inFlightMonthsRef.current.get(monthKey) === request) {\n inFlightMonthsRef.current.delete(monthKey);\n }\n }\n }\n },\n [\n buildKey,\n selectedDurationMinutes,\n selectedTimeZone,\n target,\n targetIdentity,\n ],\n );\n\n const todayStart = useMemo(\n () => getTodayInTimeZone(selectedTimeZone),\n [selectedTimeZone],\n );\n const startMonth = useMemo(\n () => getMinimumNavigableMonth(selectedTimeZone, meta?.bookingWindowStart),\n [meta?.bookingWindowStart, selectedTimeZone],\n );\n const calendarViewOptions = useMemo((): CalendarViewOptions => {\n return {\n endMonth: meta?.bookingWindowEnd ?? undefined,\n shiftWeeks: true,\n startMonth,\n weekStartsOn: getWeekStartsOn(locale),\n };\n }, [locale, meta?.bookingWindowEnd, startMonth]);\n\n const hasAvailabilityInView = useMemo(\n () =>\n hasAvailabilityInVisibleGrid(\n currentMonth,\n slotsByDate,\n todayStart,\n calendarViewOptions,\n ),\n [calendarViewOptions, currentMonth, slotsByDate, todayStart],\n );\n\n useEffect(() => {\n let cancelled = false;\n\n const load = async () => {\n const identity = targetIdentity();\n let deferLoadForMonthSync = false;\n\n // A different user/event means a different event type and metadata, so\n // reset everything and force a full fetch.\n if (\n lastIdentityRef.current !== null &&\n lastIdentityRef.current !== identity\n ) {\n coveredMonthsRef.current = new Set();\n inFlightMonthsRef.current.clear();\n availabilityRequestVersionRef.current += 1;\n resolvedRef.current = { eventTypeId: null, eventTypeSlug: null };\n hasMultiDurationOptionsRef.current = false;\n slotsCacheRef.current = new Map();\n slotsByDateRef.current = {};\n autoSelectedMonthRef.current = null;\n setMeta(null);\n setSelectedDurationMinutes(30);\n setSlotsByDate({});\n } else if (\n lastDurationRef.current !== null &&\n lastDurationRef.current !== selectedDurationMinutes\n ) {\n inFlightMonthsRef.current.clear();\n availabilityRequestVersionRef.current += 1;\n restoreSlotsFromCache();\n autoSelectedMonthRef.current = null;\n setSelectedTime(null);\n } else if (\n lastTimeZoneRef.current !== null &&\n lastTimeZoneRef.current !== selectedTimeZone\n ) {\n coveredMonthsRef.current = new Set();\n inFlightMonthsRef.current.clear();\n availabilityRequestVersionRef.current += 1;\n restoreSlotsFromCache();\n autoSelectedMonthRef.current = null;\n setSelectedTime(null);\n\n const minMonth = getMinimumNavigableMonth(\n selectedTimeZone,\n meta?.bookingWindowStart,\n );\n if (isCalendarMonthBefore(currentMonth, minMonth)) {\n setCurrentMonth(minMonth);\n deferLoadForMonthSync = true;\n } else {\n const todayInTz = getTodayInTimeZone(selectedTimeZone);\n if (\n selectedDateRef.current &&\n toCalendarDateKey(selectedDateRef.current) <\n toCalendarDateKey(todayInTz)\n ) {\n setSelectedDate(undefined);\n }\n }\n }\n lastIdentityRef.current = identity;\n lastTimeZoneRef.current = selectedTimeZone;\n lastDurationRef.current = selectedDurationMinutes;\n\n if (deferLoadForMonthSync) {\n return;\n }\n\n if (!coveredMonthsRef.current.has(buildKey(currentMonth))) {\n setIsPending(true);\n try {\n await fetchWindow(currentMonth);\n if (cancelled) {\n return;\n }\n setError(null);\n } catch (caught) {\n if (cancelled) {\n return;\n }\n setError(classifyError(caught));\n return;\n } finally {\n if (!cancelled) {\n setIsPending(false);\n }\n }\n } else {\n setError(null);\n setIsPending(false);\n }\n\n // Auto-select the first available day the first time this month's\n // availability is known (on load and on month navigation).\n const monthKey = `${currentMonth.getFullYear()}-${currentMonth.getMonth()}`;\n if (!cancelled && autoSelectedMonthRef.current !== monthKey) {\n const firstAvailable = pickDefaultSelectedDate(\n currentMonth,\n slotsByDateRef.current,\n selectedTimeZone,\n getTodayInTimeZone(selectedTimeZone),\n calendarViewOptions,\n );\n if (firstAvailable) {\n autoSelectedMonthRef.current = monthKey;\n setSelectedDate(firstAvailable);\n setSelectedTime(null);\n } else {\n autoSelectedMonthRef.current = monthKey;\n setSelectedDate(undefined);\n setSelectedTime(null);\n }\n }\n\n // Warm the next uncovered window boundary, not just the adjacent month.\n // This keeps navigation instant when the user reaches the end of the\n // currently-covered 3-month range.\n const nextWindow = new Date(\n currentMonth.getFullYear(),\n currentMonth.getMonth() + WINDOW_MONTHS,\n 1,\n );\n if (skipInitialWarmRef.current) {\n skipInitialWarmRef.current = false;\n return;\n }\n if (!coveredMonthsRef.current.has(buildKey(nextWindow))) {\n void fetchWindow(nextWindow).catch(() => {});\n }\n };\n\n void load();\n\n return () => {\n cancelled = true;\n };\n }, [\n buildKey,\n currentMonth,\n fetchWindow,\n selectedDurationMinutes,\n selectedTimeZone,\n meta?.bookingWindowStart,\n calendarViewOptions,\n restoreSlotsFromCache,\n targetIdentity,\n ]);\n\n const [retryCounter, setRetryCounter] = useState(0);\n\n const retry = useCallback(() => {\n coveredMonthsRef.current = new Set();\n inFlightMonthsRef.current.clear();\n availabilityRequestVersionRef.current += 1;\n slotsCacheRef.current = new Map();\n slotsByDateRef.current = {};\n autoSelectedMonthRef.current = null;\n setSlotsByDate({});\n setError(null);\n setRetryCounter((c) => c + 1);\n }, []);\n\n // Re-trigger the main load effect when the user retries.\n useEffect(() => {\n // The first render (retryCounter === 0) is handled by the main effect.\n if (retryCounter === 0) return;\n setIsPending(true);\n let cancelled = false;\n\n (async () => {\n try {\n await fetchWindow(currentMonth);\n if (cancelled) return;\n setError(null);\n\n const monthKey = `${currentMonth.getFullYear()}-${currentMonth.getMonth()}`;\n if (autoSelectedMonthRef.current !== monthKey) {\n const firstAvailable = pickDefaultSelectedDate(\n currentMonth,\n slotsByDateRef.current,\n selectedTimeZone,\n getTodayInTimeZone(selectedTimeZone),\n calendarViewOptions,\n );\n if (firstAvailable) {\n autoSelectedMonthRef.current = monthKey;\n setSelectedDate(firstAvailable);\n setSelectedTime(null);\n } else {\n autoSelectedMonthRef.current = monthKey;\n setSelectedDate(undefined);\n setSelectedTime(null);\n }\n }\n } catch (caught) {\n if (cancelled) return;\n setError(classifyError(caught));\n } finally {\n if (!cancelled) setIsPending(false);\n }\n })();\n\n return () => {\n cancelled = true;\n };\n }, [\n retryCounter,\n fetchWindow,\n currentMonth,\n selectedTimeZone,\n calendarViewOptions,\n ]);\n\n useEffect(() => {\n if (!timezone) {\n return;\n }\n setSelectedTimeZone(timezone);\n }, [timezone]);\n\n const selectedDateKey = toCalendarDateKey(selectedDate ?? todayStart);\n const daySlots =\n selectedDate != null ? (slotsByDate[selectedDateKey] ?? []) : [];\n const nextAvailableDate = findNextAvailableDate(\n slotsByDate,\n selectedTimeZone,\n todayStart,\n );\n\n const handleMonthChange = (month: Date) => {\n const monthKey = `${month.getFullYear()}-${month.getMonth()}`;\n const monthCovered = coveredMonthsRef.current.has(buildKey(month));\n setCurrentMonth(month);\n // Flip to pending in the same render as the month change when the target\n // month still needs fetching, so the skeleton shows immediately instead of\n // the empty state flashing for a frame before the async effect runs.\n if (!monthCovered) {\n setIsPending(true);\n }\n // For an already-loaded month, select the first available day in the same\n // update as the month change so the selection doesn't flicker.\n const firstAvailable = pickDefaultSelectedDate(\n month,\n slotsByDateRef.current,\n selectedTimeZone,\n getTodayInTimeZone(selectedTimeZone),\n calendarViewOptions,\n );\n if (firstAvailable) {\n autoSelectedMonthRef.current = monthKey;\n setSelectedDate(firstAvailable);\n setSelectedTime(null);\n } else {\n if (monthCovered) {\n autoSelectedMonthRef.current = monthKey;\n }\n setSelectedDate(undefined);\n setSelectedTime(null);\n }\n };\n\n const handleSelectDate = (nextDate: Date | undefined) => {\n if (!nextDate) {\n return;\n }\n setSelectedDate(nextDate);\n setSelectedTime(null);\n autoSelectedMonthRef.current = `${nextDate.getFullYear()}-${nextDate.getMonth()}`;\n const isDifferentMonth =\n nextDate.getMonth() !== currentMonth.getMonth() ||\n nextDate.getFullYear() !== currentMonth.getFullYear();\n if (isDifferentMonth) {\n setCurrentMonth(nextDate);\n }\n };\n\n const isDayDisabled = (date: Date) => {\n const dayKey = toCalendarDateKey(date);\n if (dayKey < toCalendarDateKey(todayStart)) {\n return true;\n }\n\n return (slotsByDate[dayKey] ?? []).length === 0;\n };\n\n const goToDate = (date: Date) => {\n setCurrentMonth(date);\n setSelectedDate(date);\n setSelectedTime(null);\n autoSelectedMonthRef.current = `${date.getFullYear()}-${date.getMonth()}`;\n };\n\n const handleSelectTime = (time: string) => {\n setSelectedTime(time);\n setStep(\"confirm\");\n };\n\n const handleBack = () => {\n setStep(\"select\");\n };\n\n const initialLoading = !meta;\n const currentMonthCovered = coveredMonthsRef.current.has(\n buildKey(currentMonth),\n );\n const availabilityLoading = isPending || !currentMonthCovered;\n\n return {\n calendarProps: {\n availabilityLoading,\n disabled: isDayDisabled,\n endMonth: meta?.bookingWindowEnd ?? undefined,\n initialLoading,\n locale,\n month: currentMonth,\n onMonthChange: handleMonthChange,\n onSelect: handleSelectDate,\n selected: selectedDate,\n startMonth,\n today: todayStart,\n },\n durationProps: {\n onValueChange: setSelectedDurationMinutes,\n value: selectedDurationMinutes,\n },\n error,\n loadingState: {\n availability: availabilityLoading,\n busy: initialLoading || availabilityLoading,\n initial: initialLoading,\n },\n meta,\n onBack: handleBack,\n retry,\n selectedTime: _selectedTime,\n step,\n timePickerProps: {\n availabilityLoading,\n currentMonth,\n daySlots,\n goToDate,\n hasAvailabilityInView,\n initialLoading,\n is24Hour,\n locale,\n nextAvailableDate,\n onIs24HourChange: setIs24Hour,\n onSelectTime: handleSelectTime,\n selectedDate,\n },\n timezoneProps: {\n onValueChange: setSelectedTimeZone,\n value: selectedTimeZone,\n },\n };\n}\n", "type": "registry:lib", "target": "lib/booker/use-booker.ts" }, diff --git a/apps/ui/public/r/registry.json b/apps/ui/public/r/registry.json index 6d11f0b7c..c05006e6f 100644 --- a/apps/ui/public/r/registry.json +++ b/apps/ui/public/r/registry.json @@ -11329,7 +11329,8 @@ "dependencies": [ "isomorphic-dompurify", "marked", - "lucide-react" + "lucide-react", + "motion" ], "description": "A full scheduling flow powered by Cal.com API v2.", "files": [ @@ -11416,6 +11417,7 @@ "@coss/card", "@coss/combobox", "@coss/empty", + "@coss/input", "@coss/label", "@coss/popover", "@coss/scroll-area", diff --git a/apps/ui/registry.json b/apps/ui/registry.json index 6d11f0b7c..c05006e6f 100644 --- a/apps/ui/registry.json +++ b/apps/ui/registry.json @@ -11329,7 +11329,8 @@ "dependencies": [ "isomorphic-dompurify", "marked", - "lucide-react" + "lucide-react", + "motion" ], "description": "A full scheduling flow powered by Cal.com API v2.", "files": [ @@ -11416,6 +11417,7 @@ "@coss/card", "@coss/combobox", "@coss/empty", + "@coss/input", "@coss/label", "@coss/popover", "@coss/scroll-area", diff --git a/apps/ui/registry/__index__.tsx b/apps/ui/registry/__index__.tsx index 1a53230aa..0f5aa9f7a 100644 --- a/apps/ui/registry/__index__.tsx +++ b/apps/ui/registry/__index__.tsx @@ -9839,7 +9839,7 @@ export const Index: Record = { name: "booker-1", description: "A full scheduling flow powered by Cal.com API v2.", type: "registry:block", - registryDependencies: ["@coss/avatar","@coss/button","@coss/cal-api","@coss/card","@coss/combobox","@coss/empty","@coss/label","@coss/popover","@coss/scroll-area","@coss/select","@coss/skeleton","@coss/switch","@coss/tooltip"], + registryDependencies: ["@coss/avatar","@coss/button","@coss/cal-api","@coss/card","@coss/combobox","@coss/empty","@coss/input","@coss/label","@coss/popover","@coss/scroll-area","@coss/select","@coss/skeleton","@coss/switch","@coss/tooltip"], files: [{ path: "registry/default/atoms/booker-1.tsx", type: "registry:block", diff --git a/apps/ui/registry/default/atoms/booker-1.tsx b/apps/ui/registry/default/atoms/booker-1.tsx index aac1b2813..3b92069da 100644 --- a/apps/ui/registry/default/atoms/booker-1.tsx +++ b/apps/ui/registry/default/atoms/booker-1.tsx @@ -1,6 +1,13 @@ "use client"; import { ArrowLeftIcon, Clock3Icon } from "lucide-react"; +import { + AnimatePresence, + domMax, + LazyMotion, + MotionConfig, + m, +} from "motion/react"; import { Button } from "@/registry/default/ui/button"; import { Card } from "@/registry/default/ui/card"; import { Input } from "@/registry/default/ui/input"; @@ -140,38 +147,64 @@ export function Booker({ initialData, target, timezone, labels }: BookerProps) { )} -
- {booker.step === "select" ? ( - <> - {/* Calendar */} -
- -
- {/* Time picker */} - - - ) : ( -
- - -
- )} -
+ + + + + {booker.step === "select" ? ( + + {/* Calendar */} +
+ +
+ {/* Time picker */} + +
+ ) : ( + + + + + )} +
+
+
+
& { export const atoms: AtomItem[] = [ { - dependencies: ["isomorphic-dompurify", "marked", "lucide-react"], + dependencies: ["isomorphic-dompurify", "marked", "lucide-react", "motion"], description: "A full scheduling flow powered by Cal.com API v2.", files: [ { diff --git a/bun.lock b/bun.lock index 86b831bc1..592586054 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "cosscom", @@ -121,6 +122,7 @@ "jotai": "^2.15.1", "lucide-react": "^0.555.0", "marked": "^18.0.5", + "motion": "12.42.0", "next": "16.2.5", "react": "^19.2.6", "react-day-picker": "^9.13.0", @@ -1363,6 +1365,8 @@ "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + "framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="], + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "4.2.11", "jsonfile": "6.1.0", "universalify": "2.0.1" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], @@ -1777,6 +1781,12 @@ "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "1.2.8" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], + "motion": ["motion@12.42.0", "", { "dependencies": { "framer-motion": "^12.42.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-Qhwvu9sVl5/URSq5CNzwMCpSKK8Uhnrwb6VO977kZyj/wOCS7mWebJUnBoHx5cZU1Zv8a9BD5CSICWKAlrLJgA=="], + + "motion-dom": ["motion-dom@12.42.2", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA=="], + + "motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "msw": ["msw@2.11.2", "", { "dependencies": { "@bundled-es-modules/cookie": "2.0.1", "@bundled-es-modules/statuses": "1.0.1", "@inquirer/confirm": "5.1.18", "@mswjs/interceptors": "0.39.6", "@open-draft/deferred-promise": "2.2.0", "@open-draft/until": "2.1.0", "@types/cookie": "0.6.0", "@types/statuses": "2.0.6", "graphql": "16.11.0", "headers-polyfill": "4.0.3", "is-node-process": "1.2.0", "outvariant": "1.4.3", "path-to-regexp": "6.3.0", "picocolors": "1.1.1", "rettime": "0.7.0", "strict-event-emitter": "0.5.1", "tough-cookie": "6.0.0", "type-fest": "4.41.0", "yargs": "17.7.2" }, "optionalDependencies": { "typescript": "5.9.2" }, "bin": { "msw": "cli/index.js" } }, "sha512-MI54hLCsrMwiflkcqlgYYNJJddY5/+S0SnONvhv1owOplvqohKSQyGejpNdUGyCwgs4IH7PqaNbPw/sKOEze9Q=="], From d0e16cb0a3d46df1fd19800e744c72755cb47243 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:50:06 +0000 Subject: [PATCH 04/24] fix(booker): remove layout=position from steps to stop horizontal slide Co-Authored-By: pasquale --- apps/ui/public/r/booker-1.json | 2 +- apps/ui/registry/default/atoms/booker-1.tsx | 14 ++++++-------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/ui/public/r/booker-1.json b/apps/ui/public/r/booker-1.json index 173b792b4..ef0735f6f 100644 --- a/apps/ui/public/r/booker-1.json +++ b/apps/ui/public/r/booker-1.json @@ -27,7 +27,7 @@ "files": [ { "path": "registry/default/atoms/booker-1.tsx", - "content": "\"use client\";\n\nimport { ArrowLeftIcon, Clock3Icon } from \"lucide-react\";\nimport {\n AnimatePresence,\n domMax,\n LazyMotion,\n MotionConfig,\n m,\n} from \"motion/react\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport { Card } from \"@/registry/default/ui/card\";\nimport { Input } from \"@/registry/default/ui/input\";\nimport { Skeleton } from \"@/registry/default/ui/skeleton\";\nimport { BookerAvatars } from \"./booker/booker-avatars\";\nimport { BookerCalendar } from \"./booker/booker-calendar\";\nimport { BookerErrorState } from \"./booker/booker-error-state\";\nimport { type BookerLabels, getBookerLabels } from \"./booker/booker-labels\";\nimport { DurationPicker } from \"./booker/duration-picker\";\nimport { EventDescription } from \"./booker/event-description\";\nimport { HeaderBanner } from \"./booker/header-banner\";\nimport { Location } from \"./booker/location\";\nimport { TimePicker } from \"./booker/time-picker\";\nimport { TimezonePicker } from \"./booker/timezone-picker\";\nimport type { BookerTarget } from \"@/lib/booker/target\";\nimport { type BookerInitialData, useBooker } from \"@/lib/booker/use-booker\";\n\ntype BookerProps = {\n target: BookerTarget;\n timezone?: string;\n initialData?: BookerInitialData;\n defaultFormValues?: Record;\n onCreateBookingSuccess?: (data: unknown) => void;\n labels?: Partial;\n};\n\nexport function Booker({ initialData, target, timezone, labels }: BookerProps) {\n const t = getBookerLabels(labels);\n const booker = useBooker({ initialData, target, timezone });\n\n if (booker.error) {\n return (\n \n );\n }\n\n const displayMeta = booker.meta;\n const headerImageAlt = displayMeta\n ? t.headerImageAlt(displayMeta.hostName, displayMeta.eventTypeTitle)\n : \"Booker header\";\n const metaContentClassName = [\n displayMeta?.eventTypeImageUrl ? \"relative -mt-11 @5xl:-mt-13\" : \"\",\n \"flex flex-col gap-6 @5xl:p-6 p-4\",\n ]\n .filter(Boolean)\n .join(\" \");\n\n return (\n \n \n {/* Meta */}\n
\n \n
\n
\n
\n \n {displayMeta ? (\n

\n {displayMeta.hostName}\n

\n ) : (\n \n )}\n
\n
\n {displayMeta ? (\n <>\n

\n {displayMeta.eventTypeTitle}\n

\n \n \n ) : (\n <>\n \n \n \n \n )}\n
\n
\n\n {displayMeta ? (\n
\n {displayMeta.eventTypeDurationOptions ? (\n \n ) : (\n
\n \n \n {displayMeta.eventTypeDurationMinutes\n ? t.durationMinutes(\n displayMeta.eventTypeDurationMinutes,\n )\n : t.durationUnknown}\n \n
\n )}\n \n \n
\n ) : (\n
\n \n \n \n
\n )}\n
\n
\n \n \n \n \n {booker.step === \"select\" ? (\n \n {/* Calendar */}\n
\n \n
\n {/* Time picker */}\n \n \n ) : (\n \n \n \n \n )}\n
\n \n \n
\n
\n \n Cal.com\n
\n \n );\n}\n\nexport default Booker;\n", + "content": "\"use client\";\n\nimport { ArrowLeftIcon, Clock3Icon } from \"lucide-react\";\nimport {\n AnimatePresence,\n domMax,\n LazyMotion,\n MotionConfig,\n m,\n} from \"motion/react\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport { Card } from \"@/registry/default/ui/card\";\nimport { Input } from \"@/registry/default/ui/input\";\nimport { Skeleton } from \"@/registry/default/ui/skeleton\";\nimport { BookerAvatars } from \"./booker/booker-avatars\";\nimport { BookerCalendar } from \"./booker/booker-calendar\";\nimport { BookerErrorState } from \"./booker/booker-error-state\";\nimport { type BookerLabels, getBookerLabels } from \"./booker/booker-labels\";\nimport { DurationPicker } from \"./booker/duration-picker\";\nimport { EventDescription } from \"./booker/event-description\";\nimport { HeaderBanner } from \"./booker/header-banner\";\nimport { Location } from \"./booker/location\";\nimport { TimePicker } from \"./booker/time-picker\";\nimport { TimezonePicker } from \"./booker/timezone-picker\";\nimport type { BookerTarget } from \"@/lib/booker/target\";\nimport { type BookerInitialData, useBooker } from \"@/lib/booker/use-booker\";\n\ntype BookerProps = {\n target: BookerTarget;\n timezone?: string;\n initialData?: BookerInitialData;\n defaultFormValues?: Record;\n onCreateBookingSuccess?: (data: unknown) => void;\n labels?: Partial;\n};\n\nexport function Booker({ initialData, target, timezone, labels }: BookerProps) {\n const t = getBookerLabels(labels);\n const booker = useBooker({ initialData, target, timezone });\n\n if (booker.error) {\n return (\n \n );\n }\n\n const displayMeta = booker.meta;\n const headerImageAlt = displayMeta\n ? t.headerImageAlt(displayMeta.hostName, displayMeta.eventTypeTitle)\n : \"Booker header\";\n const metaContentClassName = [\n displayMeta?.eventTypeImageUrl ? \"relative -mt-11 @5xl:-mt-13\" : \"\",\n \"flex flex-col gap-6 @5xl:p-6 p-4\",\n ]\n .filter(Boolean)\n .join(\" \");\n\n return (\n \n \n {/* Meta */}\n
\n \n
\n
\n
\n \n {displayMeta ? (\n

\n {displayMeta.hostName}\n

\n ) : (\n \n )}\n
\n
\n {displayMeta ? (\n <>\n

\n {displayMeta.eventTypeTitle}\n

\n \n \n ) : (\n <>\n \n \n \n \n )}\n
\n
\n\n {displayMeta ? (\n
\n {displayMeta.eventTypeDurationOptions ? (\n \n ) : (\n
\n \n \n {displayMeta.eventTypeDurationMinutes\n ? t.durationMinutes(\n displayMeta.eventTypeDurationMinutes,\n )\n : t.durationUnknown}\n \n
\n )}\n \n \n
\n ) : (\n
\n \n \n \n
\n )}\n
\n
\n \n \n \n \n {booker.step === \"select\" ? (\n \n {/* Calendar */}\n
\n \n
\n {/* Time picker */}\n \n \n ) : (\n \n \n \n \n )}\n
\n \n \n
\n
\n \n Cal.com\n \n \n );\n}\n\nexport default Booker;\n", "type": "registry:block", "target": "components/atoms/booker-1.tsx" }, diff --git a/apps/ui/registry/default/atoms/booker-1.tsx b/apps/ui/registry/default/atoms/booker-1.tsx index 3b92069da..159e01552 100644 --- a/apps/ui/registry/default/atoms/booker-1.tsx +++ b/apps/ui/registry/default/atoms/booker-1.tsx @@ -64,9 +64,9 @@ export function Booker({ initialData, target, timezone, labels }: BookerProps) { className="@container flex flex-col items-center gap-4" aria-busy={booker.loadingState.busy} > - + {/* Meta */} -
+
{booker.step === "select" ? ( {/* Calendar */} -
+
{/* Time picker */} @@ -188,11 +187,10 @@ export function Booker({ initialData, target, timezone, labels }: BookerProps) { ) : ( \n \n \n )}\n \n \n \n \n \n \n Cal.com\n \n
\n );\n}\n\nexport default Booker;\n", + "content": "\"use client\";\n\nimport { ArrowLeftIcon, Clock3Icon } from \"lucide-react\";\nimport {\n AnimatePresence,\n animate,\n domMax,\n LazyMotion,\n MotionConfig,\n m,\n} from \"motion/react\";\nimport { useEffect, useLayoutEffect, useRef } from \"react\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport { Card } from \"@/registry/default/ui/card\";\nimport { Input } from \"@/registry/default/ui/input\";\nimport { Skeleton } from \"@/registry/default/ui/skeleton\";\nimport { BookerAvatars } from \"./booker/booker-avatars\";\nimport { BookerCalendar } from \"./booker/booker-calendar\";\nimport { BookerErrorState } from \"./booker/booker-error-state\";\nimport { type BookerLabels, getBookerLabels } from \"./booker/booker-labels\";\nimport { DurationPicker } from \"./booker/duration-picker\";\nimport { EventDescription } from \"./booker/event-description\";\nimport { HeaderBanner } from \"./booker/header-banner\";\nimport { Location } from \"./booker/location\";\nimport { TimePicker } from \"./booker/time-picker\";\nimport { TimezonePicker } from \"./booker/timezone-picker\";\nimport type { BookerTarget } from \"@/lib/booker/target\";\nimport { type BookerInitialData, useBooker } from \"@/lib/booker/use-booker\";\n\nconst TRANSITION_DURATION = 0.45;\nconst TRANSITION_EASE = [0.32, 0.72, 0, 1] as const;\n\nconst useIsomorphicLayoutEffect =\n typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\ntype BookerProps = {\n target: BookerTarget;\n timezone?: string;\n initialData?: BookerInitialData;\n defaultFormValues?: Record;\n onCreateBookingSuccess?: (data: unknown) => void;\n labels?: Partial;\n};\n\nexport function Booker({ initialData, target, timezone, labels }: BookerProps) {\n const t = getBookerLabels(labels);\n const booker = useBooker({ initialData, target, timezone });\n\n const stepContainerRef = useRef(null);\n const selectStepRef = useRef(null);\n const confirmStepRef = useRef(null);\n const previousStepRef = useRef(booker.step);\n const isFirstRenderRef = useRef(true);\n const transitionTokenRef = useRef(0);\n\n useIsomorphicLayoutEffect(() => {\n const container = stepContainerRef.current;\n if (!container) {\n return;\n }\n if (isFirstRenderRef.current) {\n isFirstRenderRef.current = false;\n previousStepRef.current = booker.step;\n return;\n }\n if (previousStepRef.current === booker.step) {\n return;\n }\n previousStepRef.current = booker.step;\n\n const enteringStep =\n booker.step === \"select\" ? selectStepRef.current : confirmStepRef.current;\n\n // FLIP: capture the current box, release to the entering step's natural\n // size to measure the target, then animate the real width/height between\n // them so the card resizes without transforms or reflowing its content.\n const start = {\n height: container.offsetHeight,\n width: container.offsetWidth,\n };\n container.style.width = \"auto\";\n container.style.height = \"auto\";\n enteringStep?.style.removeProperty(\"width\");\n void container.offsetHeight;\n const end = {\n height: container.offsetHeight,\n width: container.offsetWidth,\n };\n if (enteringStep) {\n enteringStep.style.width = `${end.width}px`;\n }\n container.style.width = `${start.width}px`;\n container.style.height = `${start.height}px`;\n\n const prefersReducedMotion =\n typeof window !== \"undefined\" &&\n window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n const token = ++transitionTokenRef.current;\n const controls = animate(\n container,\n { height: end.height, width: end.width },\n {\n duration: prefersReducedMotion ? 0 : TRANSITION_DURATION,\n ease: TRANSITION_EASE,\n },\n );\n controls\n .then(() => {\n if (token !== transitionTokenRef.current) {\n return;\n }\n container.style.width = \"\";\n container.style.height = \"\";\n enteringStep?.style.removeProperty(\"width\");\n })\n .catch(() => {});\n }, [booker.step]);\n\n if (booker.error) {\n return (\n \n );\n }\n\n const displayMeta = booker.meta;\n const headerImageAlt = displayMeta\n ? t.headerImageAlt(displayMeta.hostName, displayMeta.eventTypeTitle)\n : \"Booker header\";\n const metaContentClassName = [\n displayMeta?.eventTypeImageUrl ? \"relative -mt-11 @5xl:-mt-13\" : \"\",\n \"flex flex-col gap-6 @5xl:p-6 p-4\",\n ]\n .filter(Boolean)\n .join(\" \");\n\n return (\n \n \n {/* Meta */}\n
\n \n
\n
\n
\n \n {displayMeta ? (\n

\n {displayMeta.hostName}\n

\n ) : (\n \n )}\n
\n
\n {displayMeta ? (\n <>\n

\n {displayMeta.eventTypeTitle}\n

\n \n \n ) : (\n <>\n \n \n \n \n )}\n
\n
\n\n {displayMeta ? (\n
\n {displayMeta.eventTypeDurationOptions ? (\n \n ) : (\n
\n \n \n {displayMeta.eventTypeDurationMinutes\n ? t.durationMinutes(\n displayMeta.eventTypeDurationMinutes,\n )\n : t.durationUnknown}\n \n
\n )}\n \n \n
\n ) : (\n
\n \n \n \n
\n )}\n
\n
\n \n \n \n \n {booker.step === \"select\" ? (\n \n {/* Calendar */}\n
\n \n
\n {/* Time picker */}\n \n
\n ) : (\n \n \n \n
\n )}\n \n
\n \n \n \n \n Cal.com\n \n
\n );\n}\n\nexport default Booker;\n", "type": "registry:block", "target": "components/atoms/booker-1.tsx" }, diff --git a/apps/ui/registry/default/atoms/booker-1.tsx b/apps/ui/registry/default/atoms/booker-1.tsx index 159e01552..5053c23b1 100644 --- a/apps/ui/registry/default/atoms/booker-1.tsx +++ b/apps/ui/registry/default/atoms/booker-1.tsx @@ -3,11 +3,13 @@ import { ArrowLeftIcon, Clock3Icon } from "lucide-react"; import { AnimatePresence, + animate, domMax, LazyMotion, MotionConfig, m, } from "motion/react"; +import { useEffect, useLayoutEffect, useRef } from "react"; import { Button } from "@/registry/default/ui/button"; import { Card } from "@/registry/default/ui/card"; import { Input } from "@/registry/default/ui/input"; @@ -25,6 +27,12 @@ import { TimezonePicker } from "./booker/timezone-picker"; import type { BookerTarget } from "@/lib/booker/target"; import { type BookerInitialData, useBooker } from "@/lib/booker/use-booker"; +const TRANSITION_DURATION = 0.45; +const TRANSITION_EASE = [0.32, 0.72, 0, 1] as const; + +const useIsomorphicLayoutEffect = + typeof window === "undefined" ? useEffect : useLayoutEffect; + type BookerProps = { target: BookerTarget; timezone?: string; @@ -38,6 +46,76 @@ export function Booker({ initialData, target, timezone, labels }: BookerProps) { const t = getBookerLabels(labels); const booker = useBooker({ initialData, target, timezone }); + const stepContainerRef = useRef(null); + const selectStepRef = useRef(null); + const confirmStepRef = useRef(null); + const previousStepRef = useRef(booker.step); + const isFirstRenderRef = useRef(true); + const transitionTokenRef = useRef(0); + + useIsomorphicLayoutEffect(() => { + const container = stepContainerRef.current; + if (!container) { + return; + } + if (isFirstRenderRef.current) { + isFirstRenderRef.current = false; + previousStepRef.current = booker.step; + return; + } + if (previousStepRef.current === booker.step) { + return; + } + previousStepRef.current = booker.step; + + const enteringStep = + booker.step === "select" ? selectStepRef.current : confirmStepRef.current; + + // FLIP: capture the current box, release to the entering step's natural + // size to measure the target, then animate the real width/height between + // them so the card resizes without transforms or reflowing its content. + const start = { + height: container.offsetHeight, + width: container.offsetWidth, + }; + container.style.width = "auto"; + container.style.height = "auto"; + enteringStep?.style.removeProperty("width"); + void container.offsetHeight; + const end = { + height: container.offsetHeight, + width: container.offsetWidth, + }; + if (enteringStep) { + enteringStep.style.width = `${end.width}px`; + } + container.style.width = `${start.width}px`; + container.style.height = `${start.height}px`; + + const prefersReducedMotion = + typeof window !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const token = ++transitionTokenRef.current; + const controls = animate( + container, + { height: end.height, width: end.width }, + { + duration: prefersReducedMotion ? 0 : TRANSITION_DURATION, + ease: TRANSITION_EASE, + }, + ); + controls + .then(() => { + if (token !== transitionTokenRef.current) { + return; + } + container.style.width = ""; + container.style.height = ""; + enteringStep?.style.removeProperty("width"); + }) + .catch(() => {}); + }, [booker.step]); + if (booker.error) { return ( - {booker.step === "select" ? ( )} - +
From cfcae4d30d733cd534a68e4d65e3ca9c2b41595d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:29:35 +0000 Subject: [PATCH 06/24] refactor(booker): extract step resize into hook, autofocus confirm input Co-Authored-By: pasquale --- apps/ui/public/r/booker-1.json | 2 +- apps/ui/registry/default/atoms/booker-1.tsx | 93 +++++++++++++-------- 2 files changed, 57 insertions(+), 38 deletions(-) diff --git a/apps/ui/public/r/booker-1.json b/apps/ui/public/r/booker-1.json index 845956899..90c06fee4 100644 --- a/apps/ui/public/r/booker-1.json +++ b/apps/ui/public/r/booker-1.json @@ -27,7 +27,7 @@ "files": [ { "path": "registry/default/atoms/booker-1.tsx", - "content": "\"use client\";\n\nimport { ArrowLeftIcon, Clock3Icon } from \"lucide-react\";\nimport {\n AnimatePresence,\n animate,\n domMax,\n LazyMotion,\n MotionConfig,\n m,\n} from \"motion/react\";\nimport { useEffect, useLayoutEffect, useRef } from \"react\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport { Card } from \"@/registry/default/ui/card\";\nimport { Input } from \"@/registry/default/ui/input\";\nimport { Skeleton } from \"@/registry/default/ui/skeleton\";\nimport { BookerAvatars } from \"./booker/booker-avatars\";\nimport { BookerCalendar } from \"./booker/booker-calendar\";\nimport { BookerErrorState } from \"./booker/booker-error-state\";\nimport { type BookerLabels, getBookerLabels } from \"./booker/booker-labels\";\nimport { DurationPicker } from \"./booker/duration-picker\";\nimport { EventDescription } from \"./booker/event-description\";\nimport { HeaderBanner } from \"./booker/header-banner\";\nimport { Location } from \"./booker/location\";\nimport { TimePicker } from \"./booker/time-picker\";\nimport { TimezonePicker } from \"./booker/timezone-picker\";\nimport type { BookerTarget } from \"@/lib/booker/target\";\nimport { type BookerInitialData, useBooker } from \"@/lib/booker/use-booker\";\n\nconst TRANSITION_DURATION = 0.45;\nconst TRANSITION_EASE = [0.32, 0.72, 0, 1] as const;\n\nconst useIsomorphicLayoutEffect =\n typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\ntype BookerProps = {\n target: BookerTarget;\n timezone?: string;\n initialData?: BookerInitialData;\n defaultFormValues?: Record;\n onCreateBookingSuccess?: (data: unknown) => void;\n labels?: Partial;\n};\n\nexport function Booker({ initialData, target, timezone, labels }: BookerProps) {\n const t = getBookerLabels(labels);\n const booker = useBooker({ initialData, target, timezone });\n\n const stepContainerRef = useRef(null);\n const selectStepRef = useRef(null);\n const confirmStepRef = useRef(null);\n const previousStepRef = useRef(booker.step);\n const isFirstRenderRef = useRef(true);\n const transitionTokenRef = useRef(0);\n\n useIsomorphicLayoutEffect(() => {\n const container = stepContainerRef.current;\n if (!container) {\n return;\n }\n if (isFirstRenderRef.current) {\n isFirstRenderRef.current = false;\n previousStepRef.current = booker.step;\n return;\n }\n if (previousStepRef.current === booker.step) {\n return;\n }\n previousStepRef.current = booker.step;\n\n const enteringStep =\n booker.step === \"select\" ? selectStepRef.current : confirmStepRef.current;\n\n // FLIP: capture the current box, release to the entering step's natural\n // size to measure the target, then animate the real width/height between\n // them so the card resizes without transforms or reflowing its content.\n const start = {\n height: container.offsetHeight,\n width: container.offsetWidth,\n };\n container.style.width = \"auto\";\n container.style.height = \"auto\";\n enteringStep?.style.removeProperty(\"width\");\n void container.offsetHeight;\n const end = {\n height: container.offsetHeight,\n width: container.offsetWidth,\n };\n if (enteringStep) {\n enteringStep.style.width = `${end.width}px`;\n }\n container.style.width = `${start.width}px`;\n container.style.height = `${start.height}px`;\n\n const prefersReducedMotion =\n typeof window !== \"undefined\" &&\n window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n const token = ++transitionTokenRef.current;\n const controls = animate(\n container,\n { height: end.height, width: end.width },\n {\n duration: prefersReducedMotion ? 0 : TRANSITION_DURATION,\n ease: TRANSITION_EASE,\n },\n );\n controls\n .then(() => {\n if (token !== transitionTokenRef.current) {\n return;\n }\n container.style.width = \"\";\n container.style.height = \"\";\n enteringStep?.style.removeProperty(\"width\");\n })\n .catch(() => {});\n }, [booker.step]);\n\n if (booker.error) {\n return (\n \n );\n }\n\n const displayMeta = booker.meta;\n const headerImageAlt = displayMeta\n ? t.headerImageAlt(displayMeta.hostName, displayMeta.eventTypeTitle)\n : \"Booker header\";\n const metaContentClassName = [\n displayMeta?.eventTypeImageUrl ? \"relative -mt-11 @5xl:-mt-13\" : \"\",\n \"flex flex-col gap-6 @5xl:p-6 p-4\",\n ]\n .filter(Boolean)\n .join(\" \");\n\n return (\n \n \n {/* Meta */}\n
\n \n
\n
\n
\n \n {displayMeta ? (\n

\n {displayMeta.hostName}\n

\n ) : (\n \n )}\n
\n
\n {displayMeta ? (\n <>\n

\n {displayMeta.eventTypeTitle}\n

\n \n \n ) : (\n <>\n \n \n \n \n )}\n
\n
\n\n {displayMeta ? (\n
\n {displayMeta.eventTypeDurationOptions ? (\n \n ) : (\n
\n \n \n {displayMeta.eventTypeDurationMinutes\n ? t.durationMinutes(\n displayMeta.eventTypeDurationMinutes,\n )\n : t.durationUnknown}\n \n
\n )}\n \n \n
\n ) : (\n
\n \n \n \n
\n )}\n
\n
\n \n \n \n \n {booker.step === \"select\" ? (\n \n {/* Calendar */}\n
\n \n
\n {/* Time picker */}\n \n \n ) : (\n \n \n \n \n )}\n
\n \n \n
\n
\n \n Cal.com\n \n \n );\n}\n\nexport default Booker;\n", + "content": "\"use client\";\n\nimport { ArrowLeftIcon, Clock3Icon } from \"lucide-react\";\nimport {\n AnimatePresence,\n animate,\n domMax,\n LazyMotion,\n MotionConfig,\n m,\n} from \"motion/react\";\nimport { type RefObject, useEffect, useLayoutEffect, useRef } from \"react\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport { Card } from \"@/registry/default/ui/card\";\nimport { Input } from \"@/registry/default/ui/input\";\nimport { Skeleton } from \"@/registry/default/ui/skeleton\";\nimport { BookerAvatars } from \"./booker/booker-avatars\";\nimport { BookerCalendar } from \"./booker/booker-calendar\";\nimport { BookerErrorState } from \"./booker/booker-error-state\";\nimport { type BookerLabels, getBookerLabels } from \"./booker/booker-labels\";\nimport { DurationPicker } from \"./booker/duration-picker\";\nimport { EventDescription } from \"./booker/event-description\";\nimport { HeaderBanner } from \"./booker/header-banner\";\nimport { Location } from \"./booker/location\";\nimport { TimePicker } from \"./booker/time-picker\";\nimport { TimezonePicker } from \"./booker/timezone-picker\";\nimport type { BookerTarget } from \"@/lib/booker/target\";\nimport {\n type BookerInitialData,\n type BookerStep,\n useBooker,\n} from \"@/lib/booker/use-booker\";\n\nconst TRANSITION_DURATION = 0.45;\nconst TRANSITION_EASE = [0.32, 0.72, 0, 1] as const;\n\nconst useIsomorphicLayoutEffect =\n typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\n// FLIP: on step change, measure the container's current box, release it to the\n// entering step's natural size to read the target, then animate the real\n// width/height between the two. Animating actual dimensions (not `transform`)\n// resizes the card without scaling or reflowing its content mid-transition.\nfunction useStepResizeTransition(\n step: BookerStep,\n containerRef: RefObject,\n selectStepRef: RefObject,\n confirmStepRef: RefObject,\n) {\n const isFirstRenderRef = useRef(true);\n const transitionTokenRef = useRef(0);\n\n useIsomorphicLayoutEffect(() => {\n const container = containerRef.current;\n if (!container) {\n return;\n }\n if (isFirstRenderRef.current) {\n isFirstRenderRef.current = false;\n return;\n }\n\n const enteringStep =\n step === \"select\" ? selectStepRef.current : confirmStepRef.current;\n\n const start = {\n height: container.offsetHeight,\n width: container.offsetWidth,\n };\n container.style.width = \"auto\";\n container.style.height = \"auto\";\n enteringStep?.style.removeProperty(\"width\");\n void container.offsetHeight;\n const end = {\n height: container.offsetHeight,\n width: container.offsetWidth,\n };\n\n // Pin the entering step so it can't reflow while the container is clipped.\n if (enteringStep) {\n enteringStep.style.width = `${end.width}px`;\n }\n container.style.width = `${start.width}px`;\n container.style.height = `${start.height}px`;\n\n const prefersReducedMotion = window.matchMedia(\n \"(prefers-reduced-motion: reduce)\",\n ).matches;\n const token = ++transitionTokenRef.current;\n animate(\n container,\n { height: end.height, width: end.width },\n {\n duration: prefersReducedMotion ? 0 : TRANSITION_DURATION,\n ease: TRANSITION_EASE,\n },\n )\n .then(() => {\n if (token !== transitionTokenRef.current) {\n return;\n }\n container.style.width = \"\";\n container.style.height = \"\";\n enteringStep?.style.removeProperty(\"width\");\n })\n .catch(() => {});\n }, [step]);\n}\n\ntype BookerProps = {\n target: BookerTarget;\n timezone?: string;\n initialData?: BookerInitialData;\n defaultFormValues?: Record;\n onCreateBookingSuccess?: (data: unknown) => void;\n labels?: Partial;\n};\n\nexport function Booker({ initialData, target, timezone, labels }: BookerProps) {\n const t = getBookerLabels(labels);\n const booker = useBooker({ initialData, target, timezone });\n\n const stepContainerRef = useRef(null);\n const selectStepRef = useRef(null);\n const confirmStepRef = useRef(null);\n useStepResizeTransition(\n booker.step,\n stepContainerRef,\n selectStepRef,\n confirmStepRef,\n );\n\n if (booker.error) {\n return (\n \n );\n }\n\n const displayMeta = booker.meta;\n const headerImageAlt = displayMeta\n ? t.headerImageAlt(displayMeta.hostName, displayMeta.eventTypeTitle)\n : \"Booker header\";\n const metaContentClassName = [\n displayMeta?.eventTypeImageUrl ? \"relative -mt-11 @5xl:-mt-13\" : \"\",\n \"flex flex-col gap-6 @5xl:p-6 p-4\",\n ]\n .filter(Boolean)\n .join(\" \");\n\n return (\n \n \n {/* Meta */}\n
\n \n
\n
\n
\n \n {displayMeta ? (\n

\n {displayMeta.hostName}\n

\n ) : (\n \n )}\n
\n
\n {displayMeta ? (\n <>\n

\n {displayMeta.eventTypeTitle}\n

\n \n \n ) : (\n <>\n \n \n \n \n )}\n
\n
\n\n {displayMeta ? (\n
\n {displayMeta.eventTypeDurationOptions ? (\n \n ) : (\n
\n \n \n {displayMeta.eventTypeDurationMinutes\n ? t.durationMinutes(\n displayMeta.eventTypeDurationMinutes,\n )\n : t.durationUnknown}\n \n
\n )}\n \n \n
\n ) : (\n
\n \n \n \n
\n )}\n
\n
\n \n \n \n \n {booker.step === \"select\" ? (\n \n {/* Calendar */}\n
\n \n
\n {/* Time picker */}\n \n \n ) : (\n \n \n \n \n )}\n
\n \n \n
\n
\n \n Cal.com\n \n \n );\n}\n\nexport default Booker;\n", "type": "registry:block", "target": "components/atoms/booker-1.tsx" }, diff --git a/apps/ui/registry/default/atoms/booker-1.tsx b/apps/ui/registry/default/atoms/booker-1.tsx index 5053c23b1..d0941a104 100644 --- a/apps/ui/registry/default/atoms/booker-1.tsx +++ b/apps/ui/registry/default/atoms/booker-1.tsx @@ -9,7 +9,7 @@ import { MotionConfig, m, } from "motion/react"; -import { useEffect, useLayoutEffect, useRef } from "react"; +import { type RefObject, useEffect, useLayoutEffect, useRef } from "react"; import { Button } from "@/registry/default/ui/button"; import { Card } from "@/registry/default/ui/card"; import { Input } from "@/registry/default/ui/input"; @@ -25,7 +25,11 @@ import { Location } from "./booker/location"; import { TimePicker } from "./booker/time-picker"; import { TimezonePicker } from "./booker/timezone-picker"; import type { BookerTarget } from "@/lib/booker/target"; -import { type BookerInitialData, useBooker } from "@/lib/booker/use-booker"; +import { + type BookerInitialData, + type BookerStep, + useBooker, +} from "@/lib/booker/use-booker"; const TRANSITION_DURATION = 0.45; const TRANSITION_EASE = [0.32, 0.72, 0, 1] as const; @@ -33,47 +37,32 @@ const TRANSITION_EASE = [0.32, 0.72, 0, 1] as const; const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; -type BookerProps = { - target: BookerTarget; - timezone?: string; - initialData?: BookerInitialData; - defaultFormValues?: Record; - onCreateBookingSuccess?: (data: unknown) => void; - labels?: Partial; -}; - -export function Booker({ initialData, target, timezone, labels }: BookerProps) { - const t = getBookerLabels(labels); - const booker = useBooker({ initialData, target, timezone }); - - const stepContainerRef = useRef(null); - const selectStepRef = useRef(null); - const confirmStepRef = useRef(null); - const previousStepRef = useRef(booker.step); +// FLIP: on step change, measure the container's current box, release it to the +// entering step's natural size to read the target, then animate the real +// width/height between the two. Animating actual dimensions (not `transform`) +// resizes the card without scaling or reflowing its content mid-transition. +function useStepResizeTransition( + step: BookerStep, + containerRef: RefObject, + selectStepRef: RefObject, + confirmStepRef: RefObject, +) { const isFirstRenderRef = useRef(true); const transitionTokenRef = useRef(0); useIsomorphicLayoutEffect(() => { - const container = stepContainerRef.current; + const container = containerRef.current; if (!container) { return; } if (isFirstRenderRef.current) { isFirstRenderRef.current = false; - previousStepRef.current = booker.step; - return; - } - if (previousStepRef.current === booker.step) { return; } - previousStepRef.current = booker.step; const enteringStep = - booker.step === "select" ? selectStepRef.current : confirmStepRef.current; + step === "select" ? selectStepRef.current : confirmStepRef.current; - // FLIP: capture the current box, release to the entering step's natural - // size to measure the target, then animate the real width/height between - // them so the card resizes without transforms or reflowing its content. const start = { height: container.offsetHeight, width: container.offsetWidth, @@ -86,25 +75,26 @@ export function Booker({ initialData, target, timezone, labels }: BookerProps) { height: container.offsetHeight, width: container.offsetWidth, }; + + // Pin the entering step so it can't reflow while the container is clipped. if (enteringStep) { enteringStep.style.width = `${end.width}px`; } container.style.width = `${start.width}px`; container.style.height = `${start.height}px`; - const prefersReducedMotion = - typeof window !== "undefined" && - window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const prefersReducedMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)", + ).matches; const token = ++transitionTokenRef.current; - const controls = animate( + animate( container, { height: end.height, width: end.width }, { duration: prefersReducedMotion ? 0 : TRANSITION_DURATION, ease: TRANSITION_EASE, }, - ); - controls + ) .then(() => { if (token !== transitionTokenRef.current) { return; @@ -114,7 +104,31 @@ export function Booker({ initialData, target, timezone, labels }: BookerProps) { enteringStep?.style.removeProperty("width"); }) .catch(() => {}); - }, [booker.step]); + }, [step]); +} + +type BookerProps = { + target: BookerTarget; + timezone?: string; + initialData?: BookerInitialData; + defaultFormValues?: Record; + onCreateBookingSuccess?: (data: unknown) => void; + labels?: Partial; +}; + +export function Booker({ initialData, target, timezone, labels }: BookerProps) { + const t = getBookerLabels(labels); + const booker = useBooker({ initialData, target, timezone }); + + const stepContainerRef = useRef(null); + const selectStepRef = useRef(null); + const confirmStepRef = useRef(null); + useStepResizeTransition( + booker.step, + stepContainerRef, + selectStepRef, + confirmStepRef, + ); if (booker.error) { return ( @@ -279,7 +293,12 @@ export function Booker({ initialData, target, timezone, labels }: BookerProps) {
\n \n Cal.com\n \n \n );\n}\n\nexport default Booker;\n", + "content": "\"use client\";\n\nimport { ArrowLeftIcon, Clock3Icon } from \"lucide-react\";\nimport {\n AnimatePresence,\n animate,\n domMax,\n LazyMotion,\n MotionConfig,\n m,\n} from \"motion/react\";\nimport { type RefObject, useEffect, useLayoutEffect, useRef } from \"react\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport { Card } from \"@/registry/default/ui/card\";\nimport { Input } from \"@/registry/default/ui/input\";\nimport { Skeleton } from \"@/registry/default/ui/skeleton\";\nimport { BookerAvatars } from \"./booker/booker-avatars\";\nimport { BookerCalendar } from \"./booker/booker-calendar\";\nimport { BookerErrorState } from \"./booker/booker-error-state\";\nimport { type BookerLabels, getBookerLabels } from \"./booker/booker-labels\";\nimport { DurationPicker } from \"./booker/duration-picker\";\nimport { EventDescription } from \"./booker/event-description\";\nimport { HeaderBanner } from \"./booker/header-banner\";\nimport { Location } from \"./booker/location\";\nimport { TimePicker } from \"./booker/time-picker\";\nimport { TimezonePicker } from \"./booker/timezone-picker\";\nimport type { BookerTarget } from \"@/lib/booker/target\";\nimport {\n type BookerInitialData,\n type BookerStep,\n useBooker,\n} from \"@/lib/booker/use-booker\";\n\nconst TRANSITION_DURATION = 0.45;\nconst TRANSITION_EASE = [0.32, 0.72, 0, 1] as const;\n\nconst useIsomorphicLayoutEffect =\n typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\n// FLIP: on step change, measure the container's current box, release it to the\n// entering step's natural size to read the target, then animate the real\n// width/height between the two. Animating actual dimensions (not `transform`)\n// resizes the card without scaling or reflowing its content mid-transition.\nfunction useStepResizeTransition(\n step: BookerStep,\n containerRef: RefObject,\n selectStepRef: RefObject,\n confirmStepRef: RefObject,\n) {\n const isFirstRenderRef = useRef(true);\n const transitionTokenRef = useRef(0);\n\n useIsomorphicLayoutEffect(() => {\n const container = containerRef.current;\n if (!container) {\n return;\n }\n if (isFirstRenderRef.current) {\n isFirstRenderRef.current = false;\n return;\n }\n\n const enteringStep =\n step === \"select\" ? selectStepRef.current : confirmStepRef.current;\n\n const start = {\n height: container.offsetHeight,\n width: container.offsetWidth,\n };\n container.style.width = \"auto\";\n container.style.height = \"auto\";\n enteringStep?.style.removeProperty(\"width\");\n void container.offsetHeight;\n const end = {\n height: container.offsetHeight,\n width: container.offsetWidth,\n };\n\n // Pin the entering step so it can't reflow while the container is clipped.\n if (enteringStep) {\n enteringStep.style.width = `${end.width}px`;\n }\n container.style.width = `${start.width}px`;\n container.style.height = `${start.height}px`;\n\n const prefersReducedMotion = window.matchMedia(\n \"(prefers-reduced-motion: reduce)\",\n ).matches;\n const token = ++transitionTokenRef.current;\n animate(\n container,\n { height: end.height, width: end.width },\n {\n duration: prefersReducedMotion ? 0 : TRANSITION_DURATION,\n ease: TRANSITION_EASE,\n },\n )\n .then(() => {\n if (token !== transitionTokenRef.current) {\n return;\n }\n container.style.width = \"\";\n container.style.height = \"\";\n enteringStep?.style.removeProperty(\"width\");\n })\n .catch(() => {});\n }, [step]);\n}\n\ntype BookerProps = {\n target: BookerTarget;\n timezone?: string;\n initialData?: BookerInitialData;\n defaultFormValues?: Record;\n onCreateBookingSuccess?: (data: unknown) => void;\n labels?: Partial;\n};\n\nexport function Booker({ initialData, target, timezone, labels }: BookerProps) {\n const t = getBookerLabels(labels);\n const booker = useBooker({ initialData, target, timezone });\n\n const stepContainerRef = useRef(null);\n const selectStepRef = useRef(null);\n const confirmStepRef = useRef(null);\n useStepResizeTransition(\n booker.step,\n stepContainerRef,\n selectStepRef,\n confirmStepRef,\n );\n\n if (booker.error) {\n return (\n \n );\n }\n\n const displayMeta = booker.meta;\n const headerImageAlt = displayMeta\n ? t.headerImageAlt(displayMeta.hostName, displayMeta.eventTypeTitle)\n : \"Booker header\";\n const metaContentClassName = [\n displayMeta?.eventTypeImageUrl ? \"relative -mt-11 @5xl:-mt-13\" : \"\",\n \"flex flex-col gap-6 @5xl:p-6 p-4\",\n ]\n .filter(Boolean)\n .join(\" \");\n\n return (\n \n \n {/* Meta */}\n
\n \n
\n
\n
\n \n {displayMeta ? (\n

\n {displayMeta.hostName}\n

\n ) : (\n \n )}\n
\n
\n {displayMeta ? (\n <>\n

\n {displayMeta.eventTypeTitle}\n

\n \n \n ) : (\n <>\n \n \n \n \n )}\n
\n
\n\n {displayMeta ? (\n
\n {displayMeta.eventTypeDurationOptions ? (\n \n ) : (\n
\n \n \n {displayMeta.eventTypeDurationMinutes\n ? t.durationMinutes(\n displayMeta.eventTypeDurationMinutes,\n )\n : t.durationUnknown}\n \n
\n )}\n \n \n
\n ) : (\n
\n \n \n \n
\n )}\n
\n
\n \n \n \n \n {booker.step === \"select\" ? (\n \n {/* Calendar */}\n
\n \n
\n {/* Time picker */}\n \n \n ) : (\n \n \n \n \n )}\n
\n \n \n
\n
\n \n Cal.com\n \n \n );\n}\n\nexport default Booker;\n", "type": "registry:block", "target": "components/atoms/booker-1.tsx" }, diff --git a/apps/ui/registry/default/atoms/booker-1.tsx b/apps/ui/registry/default/atoms/booker-1.tsx index 9506bd18b..fcf451ed4 100644 --- a/apps/ui/registry/default/atoms/booker-1.tsx +++ b/apps/ui/registry/default/atoms/booker-1.tsx @@ -153,7 +153,7 @@ export function Booker({ initialData, target, timezone, labels }: BookerProps) { return (
From 9ea99bfff876aad43a097da62c6cc4a15f5eefb8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:39:31 +0000 Subject: [PATCH 09/24] fix(booker): trigger step resize on step-value change, not first-render flag Co-Authored-By: pasquale --- apps/ui/public/r/booker-1.json | 2 +- apps/ui/registry/default/atoms/booker-1.tsx | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/apps/ui/public/r/booker-1.json b/apps/ui/public/r/booker-1.json index 7233f26cf..c65bfade0 100644 --- a/apps/ui/public/r/booker-1.json +++ b/apps/ui/public/r/booker-1.json @@ -27,7 +27,7 @@ "files": [ { "path": "registry/default/atoms/booker-1.tsx", - "content": "\"use client\";\n\nimport { ArrowLeftIcon, Clock3Icon } from \"lucide-react\";\nimport {\n AnimatePresence,\n animate,\n domMax,\n LazyMotion,\n MotionConfig,\n m,\n} from \"motion/react\";\nimport { type RefObject, useEffect, useLayoutEffect, useRef } from \"react\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport { Card } from \"@/registry/default/ui/card\";\nimport { Input } from \"@/registry/default/ui/input\";\nimport { Skeleton } from \"@/registry/default/ui/skeleton\";\nimport { BookerAvatars } from \"./booker/booker-avatars\";\nimport { BookerCalendar } from \"./booker/booker-calendar\";\nimport { BookerErrorState } from \"./booker/booker-error-state\";\nimport { type BookerLabels, getBookerLabels } from \"./booker/booker-labels\";\nimport { DurationPicker } from \"./booker/duration-picker\";\nimport { EventDescription } from \"./booker/event-description\";\nimport { HeaderBanner } from \"./booker/header-banner\";\nimport { Location } from \"./booker/location\";\nimport { TimePicker } from \"./booker/time-picker\";\nimport { TimezonePicker } from \"./booker/timezone-picker\";\nimport type { BookerTarget } from \"@/lib/booker/target\";\nimport {\n type BookerInitialData,\n type BookerStep,\n useBooker,\n} from \"@/lib/booker/use-booker\";\n\nconst TRANSITION_DURATION = 0.45;\nconst TRANSITION_EASE = [0.32, 0.72, 0, 1] as const;\n\nconst useIsomorphicLayoutEffect =\n typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\n// FLIP: on step change, measure the container's current box, release it to the\n// entering step's natural size to read the target, then animate the real\n// width/height between the two. Animating actual dimensions (not `transform`)\n// resizes the card without scaling or reflowing its content mid-transition.\nfunction useStepResizeTransition(\n step: BookerStep,\n containerRef: RefObject,\n selectStepRef: RefObject,\n confirmStepRef: RefObject,\n) {\n const isFirstRenderRef = useRef(true);\n const transitionTokenRef = useRef(0);\n\n useIsomorphicLayoutEffect(() => {\n const container = containerRef.current;\n if (!container) {\n return;\n }\n if (isFirstRenderRef.current) {\n isFirstRenderRef.current = false;\n return;\n }\n\n const enteringStep =\n step === \"select\" ? selectStepRef.current : confirmStepRef.current;\n\n const start = {\n height: container.offsetHeight,\n width: container.offsetWidth,\n };\n container.style.width = \"auto\";\n container.style.height = \"auto\";\n enteringStep?.style.removeProperty(\"width\");\n void container.offsetHeight;\n const end = {\n height: container.offsetHeight,\n width: container.offsetWidth,\n };\n\n // Pin the entering step so it can't reflow while the container is clipped.\n if (enteringStep) {\n enteringStep.style.width = `${end.width}px`;\n }\n container.style.width = `${start.width}px`;\n container.style.height = `${start.height}px`;\n\n const prefersReducedMotion = window.matchMedia(\n \"(prefers-reduced-motion: reduce)\",\n ).matches;\n const token = ++transitionTokenRef.current;\n animate(\n container,\n { height: end.height, width: end.width },\n {\n duration: prefersReducedMotion ? 0 : TRANSITION_DURATION,\n ease: TRANSITION_EASE,\n },\n )\n .then(() => {\n if (token !== transitionTokenRef.current) {\n return;\n }\n container.style.width = \"\";\n container.style.height = \"\";\n enteringStep?.style.removeProperty(\"width\");\n })\n .catch(() => {});\n }, [step]);\n}\n\ntype BookerProps = {\n target: BookerTarget;\n timezone?: string;\n initialData?: BookerInitialData;\n defaultFormValues?: Record;\n onCreateBookingSuccess?: (data: unknown) => void;\n labels?: Partial;\n};\n\nexport function Booker({ initialData, target, timezone, labels }: BookerProps) {\n const t = getBookerLabels(labels);\n const booker = useBooker({ initialData, target, timezone });\n\n const stepContainerRef = useRef(null);\n const selectStepRef = useRef(null);\n const confirmStepRef = useRef(null);\n useStepResizeTransition(\n booker.step,\n stepContainerRef,\n selectStepRef,\n confirmStepRef,\n );\n\n if (booker.error) {\n return (\n \n );\n }\n\n const displayMeta = booker.meta;\n const headerImageAlt = displayMeta\n ? t.headerImageAlt(displayMeta.hostName, displayMeta.eventTypeTitle)\n : \"Booker header\";\n const metaContentClassName = [\n displayMeta?.eventTypeImageUrl ? \"relative -mt-11 @5xl:-mt-13\" : \"\",\n \"flex flex-col gap-6 @5xl:p-6 p-4\",\n ]\n .filter(Boolean)\n .join(\" \");\n\n return (\n \n \n {/* Meta */}\n
\n \n
\n
\n
\n \n {displayMeta ? (\n

\n {displayMeta.hostName}\n

\n ) : (\n \n )}\n
\n
\n {displayMeta ? (\n <>\n

\n {displayMeta.eventTypeTitle}\n

\n \n \n ) : (\n <>\n \n \n \n \n )}\n
\n
\n\n {displayMeta ? (\n
\n {displayMeta.eventTypeDurationOptions ? (\n \n ) : (\n
\n \n \n {displayMeta.eventTypeDurationMinutes\n ? t.durationMinutes(\n displayMeta.eventTypeDurationMinutes,\n )\n : t.durationUnknown}\n \n
\n )}\n \n \n
\n ) : (\n
\n \n \n \n
\n )}\n
\n
\n \n \n \n \n {booker.step === \"select\" ? (\n \n {/* Calendar */}\n
\n \n
\n {/* Time picker */}\n \n \n ) : (\n \n \n \n \n )}\n
\n
\n \n \n \n \n Cal.com\n \n \n );\n}\n\nexport default Booker;\n", + "content": "\"use client\";\n\nimport { ArrowLeftIcon, Clock3Icon } from \"lucide-react\";\nimport {\n AnimatePresence,\n animate,\n domMax,\n LazyMotion,\n MotionConfig,\n m,\n} from \"motion/react\";\nimport { type RefObject, useEffect, useLayoutEffect, useRef } from \"react\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport { Card } from \"@/registry/default/ui/card\";\nimport { Input } from \"@/registry/default/ui/input\";\nimport { Skeleton } from \"@/registry/default/ui/skeleton\";\nimport { BookerAvatars } from \"./booker/booker-avatars\";\nimport { BookerCalendar } from \"./booker/booker-calendar\";\nimport { BookerErrorState } from \"./booker/booker-error-state\";\nimport { type BookerLabels, getBookerLabels } from \"./booker/booker-labels\";\nimport { DurationPicker } from \"./booker/duration-picker\";\nimport { EventDescription } from \"./booker/event-description\";\nimport { HeaderBanner } from \"./booker/header-banner\";\nimport { Location } from \"./booker/location\";\nimport { TimePicker } from \"./booker/time-picker\";\nimport { TimezonePicker } from \"./booker/timezone-picker\";\nimport type { BookerTarget } from \"@/lib/booker/target\";\nimport {\n type BookerInitialData,\n type BookerStep,\n useBooker,\n} from \"@/lib/booker/use-booker\";\n\nconst TRANSITION_DURATION = 0.45;\nconst TRANSITION_EASE = [0.32, 0.72, 0, 1] as const;\n\nconst useIsomorphicLayoutEffect =\n typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\n// FLIP: on step change, measure the container's current box, release it to the\n// entering step's natural size to read the target, then animate the real\n// width/height between the two. Animating actual dimensions (not `transform`)\n// resizes the card without scaling or reflowing its content mid-transition.\nfunction useStepResizeTransition(\n step: BookerStep,\n containerRef: RefObject,\n selectStepRef: RefObject,\n confirmStepRef: RefObject,\n) {\n const previousStepRef = useRef(step);\n const transitionTokenRef = useRef(0);\n\n useIsomorphicLayoutEffect(() => {\n const container = containerRef.current;\n if (!container || previousStepRef.current === step) {\n return;\n }\n previousStepRef.current = step;\n\n const enteringStep =\n step === \"select\" ? selectStepRef.current : confirmStepRef.current;\n\n const start = {\n height: container.offsetHeight,\n width: container.offsetWidth,\n };\n container.style.width = \"auto\";\n container.style.height = \"auto\";\n enteringStep?.style.removeProperty(\"width\");\n void container.offsetHeight;\n const end = {\n height: container.offsetHeight,\n width: container.offsetWidth,\n };\n\n // Pin the entering step so it can't reflow while the container is clipped.\n if (enteringStep) {\n enteringStep.style.width = `${end.width}px`;\n }\n container.style.width = `${start.width}px`;\n container.style.height = `${start.height}px`;\n\n const prefersReducedMotion = window.matchMedia(\n \"(prefers-reduced-motion: reduce)\",\n ).matches;\n const token = ++transitionTokenRef.current;\n animate(\n container,\n { height: end.height, width: end.width },\n {\n duration: prefersReducedMotion ? 0 : TRANSITION_DURATION,\n ease: TRANSITION_EASE,\n },\n )\n .then(() => {\n if (token !== transitionTokenRef.current) {\n return;\n }\n container.style.width = \"\";\n container.style.height = \"\";\n enteringStep?.style.removeProperty(\"width\");\n })\n .catch(() => {});\n }, [step]);\n}\n\ntype BookerProps = {\n target: BookerTarget;\n timezone?: string;\n initialData?: BookerInitialData;\n defaultFormValues?: Record;\n onCreateBookingSuccess?: (data: unknown) => void;\n labels?: Partial;\n};\n\nexport function Booker({ initialData, target, timezone, labels }: BookerProps) {\n const t = getBookerLabels(labels);\n const booker = useBooker({ initialData, target, timezone });\n\n const stepContainerRef = useRef(null);\n const selectStepRef = useRef(null);\n const confirmStepRef = useRef(null);\n useStepResizeTransition(\n booker.step,\n stepContainerRef,\n selectStepRef,\n confirmStepRef,\n );\n\n if (booker.error) {\n return (\n \n );\n }\n\n const displayMeta = booker.meta;\n const headerImageAlt = displayMeta\n ? t.headerImageAlt(displayMeta.hostName, displayMeta.eventTypeTitle)\n : \"Booker header\";\n const metaContentClassName = [\n displayMeta?.eventTypeImageUrl ? \"relative -mt-11 @5xl:-mt-13\" : \"\",\n \"flex flex-col gap-6 @5xl:p-6 p-4\",\n ]\n .filter(Boolean)\n .join(\" \");\n\n return (\n \n \n {/* Meta */}\n
\n \n
\n
\n
\n \n {displayMeta ? (\n

\n {displayMeta.hostName}\n

\n ) : (\n \n )}\n
\n
\n {displayMeta ? (\n <>\n

\n {displayMeta.eventTypeTitle}\n

\n \n \n ) : (\n <>\n \n \n \n \n )}\n
\n
\n\n {displayMeta ? (\n
\n {displayMeta.eventTypeDurationOptions ? (\n \n ) : (\n
\n \n \n {displayMeta.eventTypeDurationMinutes\n ? t.durationMinutes(\n displayMeta.eventTypeDurationMinutes,\n )\n : t.durationUnknown}\n \n
\n )}\n \n \n
\n ) : (\n
\n \n \n \n
\n )}\n
\n
\n \n \n \n \n {booker.step === \"select\" ? (\n \n {/* Calendar */}\n
\n \n
\n {/* Time picker */}\n \n \n ) : (\n \n \n \n \n )}\n
\n \n \n
\n
\n \n Cal.com\n \n \n );\n}\n\nexport default Booker;\n", "type": "registry:block", "target": "components/atoms/booker-1.tsx" }, diff --git a/apps/ui/registry/default/atoms/booker-1.tsx b/apps/ui/registry/default/atoms/booker-1.tsx index fcf451ed4..690c1a07d 100644 --- a/apps/ui/registry/default/atoms/booker-1.tsx +++ b/apps/ui/registry/default/atoms/booker-1.tsx @@ -47,18 +47,15 @@ function useStepResizeTransition( selectStepRef: RefObject, confirmStepRef: RefObject, ) { - const isFirstRenderRef = useRef(true); + const previousStepRef = useRef(step); const transitionTokenRef = useRef(0); useIsomorphicLayoutEffect(() => { const container = containerRef.current; - if (!container) { - return; - } - if (isFirstRenderRef.current) { - isFirstRenderRef.current = false; + if (!container || previousStepRef.current === step) { return; } + previousStepRef.current = step; const enteringStep = step === "select" ? selectStepRef.current : confirmStepRef.current; From 08a28d7cac413ecb1ab473c7fe8d2d9fbc18a22c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:21:39 +0000 Subject: [PATCH 10/24] chore(booker): TEMP diagnostic logging for step resize Co-Authored-By: pasquale --- apps/ui/public/r/booker-1.json | 2 +- apps/ui/registry/default/atoms/booker-1.tsx | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/ui/public/r/booker-1.json b/apps/ui/public/r/booker-1.json index c65bfade0..fd7c6724a 100644 --- a/apps/ui/public/r/booker-1.json +++ b/apps/ui/public/r/booker-1.json @@ -27,7 +27,7 @@ "files": [ { "path": "registry/default/atoms/booker-1.tsx", - "content": "\"use client\";\n\nimport { ArrowLeftIcon, Clock3Icon } from \"lucide-react\";\nimport {\n AnimatePresence,\n animate,\n domMax,\n LazyMotion,\n MotionConfig,\n m,\n} from \"motion/react\";\nimport { type RefObject, useEffect, useLayoutEffect, useRef } from \"react\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport { Card } from \"@/registry/default/ui/card\";\nimport { Input } from \"@/registry/default/ui/input\";\nimport { Skeleton } from \"@/registry/default/ui/skeleton\";\nimport { BookerAvatars } from \"./booker/booker-avatars\";\nimport { BookerCalendar } from \"./booker/booker-calendar\";\nimport { BookerErrorState } from \"./booker/booker-error-state\";\nimport { type BookerLabels, getBookerLabels } from \"./booker/booker-labels\";\nimport { DurationPicker } from \"./booker/duration-picker\";\nimport { EventDescription } from \"./booker/event-description\";\nimport { HeaderBanner } from \"./booker/header-banner\";\nimport { Location } from \"./booker/location\";\nimport { TimePicker } from \"./booker/time-picker\";\nimport { TimezonePicker } from \"./booker/timezone-picker\";\nimport type { BookerTarget } from \"@/lib/booker/target\";\nimport {\n type BookerInitialData,\n type BookerStep,\n useBooker,\n} from \"@/lib/booker/use-booker\";\n\nconst TRANSITION_DURATION = 0.45;\nconst TRANSITION_EASE = [0.32, 0.72, 0, 1] as const;\n\nconst useIsomorphicLayoutEffect =\n typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\n// FLIP: on step change, measure the container's current box, release it to the\n// entering step's natural size to read the target, then animate the real\n// width/height between the two. Animating actual dimensions (not `transform`)\n// resizes the card without scaling or reflowing its content mid-transition.\nfunction useStepResizeTransition(\n step: BookerStep,\n containerRef: RefObject,\n selectStepRef: RefObject,\n confirmStepRef: RefObject,\n) {\n const previousStepRef = useRef(step);\n const transitionTokenRef = useRef(0);\n\n useIsomorphicLayoutEffect(() => {\n const container = containerRef.current;\n if (!container || previousStepRef.current === step) {\n return;\n }\n previousStepRef.current = step;\n\n const enteringStep =\n step === \"select\" ? selectStepRef.current : confirmStepRef.current;\n\n const start = {\n height: container.offsetHeight,\n width: container.offsetWidth,\n };\n container.style.width = \"auto\";\n container.style.height = \"auto\";\n enteringStep?.style.removeProperty(\"width\");\n void container.offsetHeight;\n const end = {\n height: container.offsetHeight,\n width: container.offsetWidth,\n };\n\n // Pin the entering step so it can't reflow while the container is clipped.\n if (enteringStep) {\n enteringStep.style.width = `${end.width}px`;\n }\n container.style.width = `${start.width}px`;\n container.style.height = `${start.height}px`;\n\n const prefersReducedMotion = window.matchMedia(\n \"(prefers-reduced-motion: reduce)\",\n ).matches;\n const token = ++transitionTokenRef.current;\n animate(\n container,\n { height: end.height, width: end.width },\n {\n duration: prefersReducedMotion ? 0 : TRANSITION_DURATION,\n ease: TRANSITION_EASE,\n },\n )\n .then(() => {\n if (token !== transitionTokenRef.current) {\n return;\n }\n container.style.width = \"\";\n container.style.height = \"\";\n enteringStep?.style.removeProperty(\"width\");\n })\n .catch(() => {});\n }, [step]);\n}\n\ntype BookerProps = {\n target: BookerTarget;\n timezone?: string;\n initialData?: BookerInitialData;\n defaultFormValues?: Record;\n onCreateBookingSuccess?: (data: unknown) => void;\n labels?: Partial;\n};\n\nexport function Booker({ initialData, target, timezone, labels }: BookerProps) {\n const t = getBookerLabels(labels);\n const booker = useBooker({ initialData, target, timezone });\n\n const stepContainerRef = useRef(null);\n const selectStepRef = useRef(null);\n const confirmStepRef = useRef(null);\n useStepResizeTransition(\n booker.step,\n stepContainerRef,\n selectStepRef,\n confirmStepRef,\n );\n\n if (booker.error) {\n return (\n \n );\n }\n\n const displayMeta = booker.meta;\n const headerImageAlt = displayMeta\n ? t.headerImageAlt(displayMeta.hostName, displayMeta.eventTypeTitle)\n : \"Booker header\";\n const metaContentClassName = [\n displayMeta?.eventTypeImageUrl ? \"relative -mt-11 @5xl:-mt-13\" : \"\",\n \"flex flex-col gap-6 @5xl:p-6 p-4\",\n ]\n .filter(Boolean)\n .join(\" \");\n\n return (\n \n \n {/* Meta */}\n
\n \n
\n
\n
\n \n {displayMeta ? (\n

\n {displayMeta.hostName}\n

\n ) : (\n \n )}\n
\n
\n {displayMeta ? (\n <>\n

\n {displayMeta.eventTypeTitle}\n

\n \n \n ) : (\n <>\n \n \n \n \n )}\n
\n
\n\n {displayMeta ? (\n
\n {displayMeta.eventTypeDurationOptions ? (\n \n ) : (\n
\n \n \n {displayMeta.eventTypeDurationMinutes\n ? t.durationMinutes(\n displayMeta.eventTypeDurationMinutes,\n )\n : t.durationUnknown}\n \n
\n )}\n \n \n
\n ) : (\n
\n \n \n \n
\n )}\n
\n
\n \n \n \n \n {booker.step === \"select\" ? (\n \n {/* Calendar */}\n
\n \n
\n {/* Time picker */}\n \n \n ) : (\n \n \n \n \n )}\n
\n \n \n
\n
\n \n Cal.com\n \n \n );\n}\n\nexport default Booker;\n", + "content": "\"use client\";\n\nimport { ArrowLeftIcon, Clock3Icon } from \"lucide-react\";\nimport {\n AnimatePresence,\n animate,\n domMax,\n LazyMotion,\n MotionConfig,\n m,\n} from \"motion/react\";\nimport { type RefObject, useEffect, useLayoutEffect, useRef } from \"react\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport { Card } from \"@/registry/default/ui/card\";\nimport { Input } from \"@/registry/default/ui/input\";\nimport { Skeleton } from \"@/registry/default/ui/skeleton\";\nimport { BookerAvatars } from \"./booker/booker-avatars\";\nimport { BookerCalendar } from \"./booker/booker-calendar\";\nimport { BookerErrorState } from \"./booker/booker-error-state\";\nimport { type BookerLabels, getBookerLabels } from \"./booker/booker-labels\";\nimport { DurationPicker } from \"./booker/duration-picker\";\nimport { EventDescription } from \"./booker/event-description\";\nimport { HeaderBanner } from \"./booker/header-banner\";\nimport { Location } from \"./booker/location\";\nimport { TimePicker } from \"./booker/time-picker\";\nimport { TimezonePicker } from \"./booker/timezone-picker\";\nimport type { BookerTarget } from \"@/lib/booker/target\";\nimport {\n type BookerInitialData,\n type BookerStep,\n useBooker,\n} from \"@/lib/booker/use-booker\";\n\nconst TRANSITION_DURATION = 0.45;\nconst TRANSITION_EASE = [0.32, 0.72, 0, 1] as const;\n\nconst useIsomorphicLayoutEffect =\n typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\n// FLIP: on step change, measure the container's current box, release it to the\n// entering step's natural size to read the target, then animate the real\n// width/height between the two. Animating actual dimensions (not `transform`)\n// resizes the card without scaling or reflowing its content mid-transition.\nfunction useStepResizeTransition(\n step: BookerStep,\n containerRef: RefObject,\n selectStepRef: RefObject,\n confirmStepRef: RefObject,\n) {\n const previousStepRef = useRef(step);\n const transitionTokenRef = useRef(0);\n\n useIsomorphicLayoutEffect(() => {\n const container = containerRef.current;\n // eslint-disable-next-line no-console\n console.log(\"[BOOKER_RESIZE_DIAG rev=marker-A]\", {\n prev: previousStepRef.current,\n step,\n hasContainer: !!container,\n });\n if (!container || previousStepRef.current === step) {\n return;\n }\n previousStepRef.current = step;\n\n const enteringStep =\n step === \"select\" ? selectStepRef.current : confirmStepRef.current;\n\n const start = {\n height: container.offsetHeight,\n width: container.offsetWidth,\n };\n container.style.width = \"auto\";\n container.style.height = \"auto\";\n enteringStep?.style.removeProperty(\"width\");\n void container.offsetHeight;\n const end = {\n height: container.offsetHeight,\n width: container.offsetWidth,\n };\n // eslint-disable-next-line no-console\n console.log(\"[BOOKER_RESIZE_DIAG rev=marker-A] measured\", {\n startW: start.width,\n startH: start.height,\n endW: end.width,\n endH: end.height,\n hasEntering: !!enteringStep,\n });\n\n // Pin the entering step so it can't reflow while the container is clipped.\n if (enteringStep) {\n enteringStep.style.width = `${end.width}px`;\n }\n container.style.width = `${start.width}px`;\n container.style.height = `${start.height}px`;\n\n const prefersReducedMotion = window.matchMedia(\n \"(prefers-reduced-motion: reduce)\",\n ).matches;\n const token = ++transitionTokenRef.current;\n animate(\n container,\n { height: end.height, width: end.width },\n {\n duration: prefersReducedMotion ? 0 : TRANSITION_DURATION,\n ease: TRANSITION_EASE,\n },\n )\n .then(() => {\n if (token !== transitionTokenRef.current) {\n return;\n }\n container.style.width = \"\";\n container.style.height = \"\";\n enteringStep?.style.removeProperty(\"width\");\n })\n .catch(() => {});\n }, [step]);\n}\n\ntype BookerProps = {\n target: BookerTarget;\n timezone?: string;\n initialData?: BookerInitialData;\n defaultFormValues?: Record;\n onCreateBookingSuccess?: (data: unknown) => void;\n labels?: Partial;\n};\n\nexport function Booker({ initialData, target, timezone, labels }: BookerProps) {\n const t = getBookerLabels(labels);\n const booker = useBooker({ initialData, target, timezone });\n\n const stepContainerRef = useRef(null);\n const selectStepRef = useRef(null);\n const confirmStepRef = useRef(null);\n useStepResizeTransition(\n booker.step,\n stepContainerRef,\n selectStepRef,\n confirmStepRef,\n );\n\n if (booker.error) {\n return (\n \n );\n }\n\n const displayMeta = booker.meta;\n const headerImageAlt = displayMeta\n ? t.headerImageAlt(displayMeta.hostName, displayMeta.eventTypeTitle)\n : \"Booker header\";\n const metaContentClassName = [\n displayMeta?.eventTypeImageUrl ? \"relative -mt-11 @5xl:-mt-13\" : \"\",\n \"flex flex-col gap-6 @5xl:p-6 p-4\",\n ]\n .filter(Boolean)\n .join(\" \");\n\n return (\n \n \n {/* Meta */}\n
\n \n
\n
\n
\n \n {displayMeta ? (\n

\n {displayMeta.hostName}\n

\n ) : (\n \n )}\n
\n
\n {displayMeta ? (\n <>\n

\n {displayMeta.eventTypeTitle}\n

\n \n \n ) : (\n <>\n \n \n \n \n )}\n
\n
\n\n {displayMeta ? (\n
\n {displayMeta.eventTypeDurationOptions ? (\n \n ) : (\n
\n \n \n {displayMeta.eventTypeDurationMinutes\n ? t.durationMinutes(\n displayMeta.eventTypeDurationMinutes,\n )\n : t.durationUnknown}\n \n
\n )}\n \n \n
\n ) : (\n
\n \n \n \n
\n )}\n
\n
\n \n \n \n \n {booker.step === \"select\" ? (\n \n {/* Calendar */}\n
\n \n
\n {/* Time picker */}\n \n \n ) : (\n \n \n \n \n )}\n
\n \n \n
\n
\n \n Cal.com\n \n \n );\n}\n\nexport default Booker;\n", "type": "registry:block", "target": "components/atoms/booker-1.tsx" }, diff --git a/apps/ui/registry/default/atoms/booker-1.tsx b/apps/ui/registry/default/atoms/booker-1.tsx index 690c1a07d..d13a1cd72 100644 --- a/apps/ui/registry/default/atoms/booker-1.tsx +++ b/apps/ui/registry/default/atoms/booker-1.tsx @@ -52,6 +52,12 @@ function useStepResizeTransition( useIsomorphicLayoutEffect(() => { const container = containerRef.current; + // eslint-disable-next-line no-console + console.log("[BOOKER_RESIZE_DIAG rev=marker-A]", { + prev: previousStepRef.current, + step, + hasContainer: !!container, + }); if (!container || previousStepRef.current === step) { return; } @@ -72,6 +78,14 @@ function useStepResizeTransition( height: container.offsetHeight, width: container.offsetWidth, }; + // eslint-disable-next-line no-console + console.log("[BOOKER_RESIZE_DIAG rev=marker-A] measured", { + startW: start.width, + startH: start.height, + endW: end.width, + endH: end.height, + hasEntering: !!enteringStep, + }); // Pin the entering step so it can't reflow while the container is clipped. if (enteringStep) { From 9b6b00a4c34deb258715f27a37cd574cc6f5bd3b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:28:09 +0000 Subject: [PATCH 11/24] fix(booker): FLIP from resting size so step resize animates every time Co-Authored-By: pasquale --- apps/ui/public/r/booker-1.json | 2 +- apps/ui/registry/default/atoms/booker-1.tsx | 61 ++++++++++++--------- 2 files changed, 37 insertions(+), 26 deletions(-) diff --git a/apps/ui/public/r/booker-1.json b/apps/ui/public/r/booker-1.json index fd7c6724a..c70864987 100644 --- a/apps/ui/public/r/booker-1.json +++ b/apps/ui/public/r/booker-1.json @@ -27,7 +27,7 @@ "files": [ { "path": "registry/default/atoms/booker-1.tsx", - "content": "\"use client\";\n\nimport { ArrowLeftIcon, Clock3Icon } from \"lucide-react\";\nimport {\n AnimatePresence,\n animate,\n domMax,\n LazyMotion,\n MotionConfig,\n m,\n} from \"motion/react\";\nimport { type RefObject, useEffect, useLayoutEffect, useRef } from \"react\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport { Card } from \"@/registry/default/ui/card\";\nimport { Input } from \"@/registry/default/ui/input\";\nimport { Skeleton } from \"@/registry/default/ui/skeleton\";\nimport { BookerAvatars } from \"./booker/booker-avatars\";\nimport { BookerCalendar } from \"./booker/booker-calendar\";\nimport { BookerErrorState } from \"./booker/booker-error-state\";\nimport { type BookerLabels, getBookerLabels } from \"./booker/booker-labels\";\nimport { DurationPicker } from \"./booker/duration-picker\";\nimport { EventDescription } from \"./booker/event-description\";\nimport { HeaderBanner } from \"./booker/header-banner\";\nimport { Location } from \"./booker/location\";\nimport { TimePicker } from \"./booker/time-picker\";\nimport { TimezonePicker } from \"./booker/timezone-picker\";\nimport type { BookerTarget } from \"@/lib/booker/target\";\nimport {\n type BookerInitialData,\n type BookerStep,\n useBooker,\n} from \"@/lib/booker/use-booker\";\n\nconst TRANSITION_DURATION = 0.45;\nconst TRANSITION_EASE = [0.32, 0.72, 0, 1] as const;\n\nconst useIsomorphicLayoutEffect =\n typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\n// FLIP: on step change, measure the container's current box, release it to the\n// entering step's natural size to read the target, then animate the real\n// width/height between the two. Animating actual dimensions (not `transform`)\n// resizes the card without scaling or reflowing its content mid-transition.\nfunction useStepResizeTransition(\n step: BookerStep,\n containerRef: RefObject,\n selectStepRef: RefObject,\n confirmStepRef: RefObject,\n) {\n const previousStepRef = useRef(step);\n const transitionTokenRef = useRef(0);\n\n useIsomorphicLayoutEffect(() => {\n const container = containerRef.current;\n // eslint-disable-next-line no-console\n console.log(\"[BOOKER_RESIZE_DIAG rev=marker-A]\", {\n prev: previousStepRef.current,\n step,\n hasContainer: !!container,\n });\n if (!container || previousStepRef.current === step) {\n return;\n }\n previousStepRef.current = step;\n\n const enteringStep =\n step === \"select\" ? selectStepRef.current : confirmStepRef.current;\n\n const start = {\n height: container.offsetHeight,\n width: container.offsetWidth,\n };\n container.style.width = \"auto\";\n container.style.height = \"auto\";\n enteringStep?.style.removeProperty(\"width\");\n void container.offsetHeight;\n const end = {\n height: container.offsetHeight,\n width: container.offsetWidth,\n };\n // eslint-disable-next-line no-console\n console.log(\"[BOOKER_RESIZE_DIAG rev=marker-A] measured\", {\n startW: start.width,\n startH: start.height,\n endW: end.width,\n endH: end.height,\n hasEntering: !!enteringStep,\n });\n\n // Pin the entering step so it can't reflow while the container is clipped.\n if (enteringStep) {\n enteringStep.style.width = `${end.width}px`;\n }\n container.style.width = `${start.width}px`;\n container.style.height = `${start.height}px`;\n\n const prefersReducedMotion = window.matchMedia(\n \"(prefers-reduced-motion: reduce)\",\n ).matches;\n const token = ++transitionTokenRef.current;\n animate(\n container,\n { height: end.height, width: end.width },\n {\n duration: prefersReducedMotion ? 0 : TRANSITION_DURATION,\n ease: TRANSITION_EASE,\n },\n )\n .then(() => {\n if (token !== transitionTokenRef.current) {\n return;\n }\n container.style.width = \"\";\n container.style.height = \"\";\n enteringStep?.style.removeProperty(\"width\");\n })\n .catch(() => {});\n }, [step]);\n}\n\ntype BookerProps = {\n target: BookerTarget;\n timezone?: string;\n initialData?: BookerInitialData;\n defaultFormValues?: Record;\n onCreateBookingSuccess?: (data: unknown) => void;\n labels?: Partial;\n};\n\nexport function Booker({ initialData, target, timezone, labels }: BookerProps) {\n const t = getBookerLabels(labels);\n const booker = useBooker({ initialData, target, timezone });\n\n const stepContainerRef = useRef(null);\n const selectStepRef = useRef(null);\n const confirmStepRef = useRef(null);\n useStepResizeTransition(\n booker.step,\n stepContainerRef,\n selectStepRef,\n confirmStepRef,\n );\n\n if (booker.error) {\n return (\n \n );\n }\n\n const displayMeta = booker.meta;\n const headerImageAlt = displayMeta\n ? t.headerImageAlt(displayMeta.hostName, displayMeta.eventTypeTitle)\n : \"Booker header\";\n const metaContentClassName = [\n displayMeta?.eventTypeImageUrl ? \"relative -mt-11 @5xl:-mt-13\" : \"\",\n \"flex flex-col gap-6 @5xl:p-6 p-4\",\n ]\n .filter(Boolean)\n .join(\" \");\n\n return (\n \n \n {/* Meta */}\n
\n \n
\n
\n
\n \n {displayMeta ? (\n

\n {displayMeta.hostName}\n

\n ) : (\n \n )}\n
\n
\n {displayMeta ? (\n <>\n

\n {displayMeta.eventTypeTitle}\n

\n \n \n ) : (\n <>\n \n \n \n \n )}\n
\n
\n\n {displayMeta ? (\n
\n {displayMeta.eventTypeDurationOptions ? (\n \n ) : (\n
\n \n \n {displayMeta.eventTypeDurationMinutes\n ? t.durationMinutes(\n displayMeta.eventTypeDurationMinutes,\n )\n : t.durationUnknown}\n \n
\n )}\n \n \n
\n ) : (\n
\n \n \n \n
\n )}\n
\n
\n \n \n \n \n {booker.step === \"select\" ? (\n \n {/* Calendar */}\n
\n \n
\n {/* Time picker */}\n \n \n ) : (\n \n \n \n \n )}\n
\n \n \n
\n
\n \n Cal.com\n \n \n );\n}\n\nexport default Booker;\n", + "content": "\"use client\";\n\nimport { ArrowLeftIcon, Clock3Icon } from \"lucide-react\";\nimport {\n AnimatePresence,\n animate,\n domMax,\n LazyMotion,\n MotionConfig,\n m,\n} from \"motion/react\";\nimport { type RefObject, useEffect, useLayoutEffect, useRef } from \"react\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport { Card } from \"@/registry/default/ui/card\";\nimport { Input } from \"@/registry/default/ui/input\";\nimport { Skeleton } from \"@/registry/default/ui/skeleton\";\nimport { BookerAvatars } from \"./booker/booker-avatars\";\nimport { BookerCalendar } from \"./booker/booker-calendar\";\nimport { BookerErrorState } from \"./booker/booker-error-state\";\nimport { type BookerLabels, getBookerLabels } from \"./booker/booker-labels\";\nimport { DurationPicker } from \"./booker/duration-picker\";\nimport { EventDescription } from \"./booker/event-description\";\nimport { HeaderBanner } from \"./booker/header-banner\";\nimport { Location } from \"./booker/location\";\nimport { TimePicker } from \"./booker/time-picker\";\nimport { TimezonePicker } from \"./booker/timezone-picker\";\nimport type { BookerTarget } from \"@/lib/booker/target\";\nimport {\n type BookerInitialData,\n type BookerStep,\n useBooker,\n} from \"@/lib/booker/use-booker\";\n\nconst TRANSITION_DURATION = 0.45;\nconst TRANSITION_EASE = [0.32, 0.72, 0, 1] as const;\n\nconst useIsomorphicLayoutEffect =\n typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\ntype Size = { width: number; height: number };\n\nconst measure = (element: HTMLElement): Size => ({\n height: element.offsetHeight,\n width: element.offsetWidth,\n});\n\n// FLIP: on step change, animate the container's real width/height from its\n// previous box to the entering step's natural box. Animating actual dimensions\n// (not `transform`) resizes the card without scaling or reflowing its content.\n//\n// `popLayout` pulls the outgoing step out of flow before this layout effect\n// runs, so by now the container already reports the *new* size — too late to\n// read the old one from the DOM. A `ResizeObserver` therefore tracks the\n// container's resting size continuously (skipping frames driven by our own\n// animation) so the pre-change size is always available as the FLIP origin.\nfunction useStepResizeTransition(\n step: BookerStep,\n containerRef: RefObject,\n selectStepRef: RefObject,\n confirmStepRef: RefObject,\n) {\n const previousStepRef = useRef(step);\n const restingSizeRef = useRef(null);\n const isAnimatingRef = useRef(false);\n const transitionTokenRef = useRef(0);\n\n useIsomorphicLayoutEffect(() => {\n const container = containerRef.current;\n if (!container) {\n return;\n }\n const observer = new ResizeObserver(() => {\n if (!isAnimatingRef.current) {\n restingSizeRef.current = measure(container);\n }\n });\n observer.observe(container);\n return () => observer.disconnect();\n }, [containerRef]);\n\n useIsomorphicLayoutEffect(() => {\n const container = containerRef.current;\n if (!container || previousStepRef.current === step) {\n return;\n }\n previousStepRef.current = step;\n\n const enteringStep =\n step === \"select\" ? selectStepRef.current : confirmStepRef.current;\n\n const start = restingSizeRef.current ?? measure(container);\n container.style.width = \"auto\";\n container.style.height = \"auto\";\n enteringStep?.style.removeProperty(\"width\");\n void container.offsetHeight;\n const end = measure(container);\n\n // Pin the entering step so it can't reflow while the container is clipped.\n if (enteringStep) {\n enteringStep.style.width = `${end.width}px`;\n }\n container.style.width = `${start.width}px`;\n container.style.height = `${start.height}px`;\n\n const prefersReducedMotion = window.matchMedia(\n \"(prefers-reduced-motion: reduce)\",\n ).matches;\n const token = ++transitionTokenRef.current;\n isAnimatingRef.current = true;\n animate(\n container,\n { height: end.height, width: end.width },\n {\n duration: prefersReducedMotion ? 0 : TRANSITION_DURATION,\n ease: TRANSITION_EASE,\n },\n )\n .then(() => {\n if (token !== transitionTokenRef.current) {\n return;\n }\n container.style.width = \"\";\n container.style.height = \"\";\n enteringStep?.style.removeProperty(\"width\");\n restingSizeRef.current = measure(container);\n isAnimatingRef.current = false;\n })\n .catch(() => {});\n }, [step]);\n}\n\ntype BookerProps = {\n target: BookerTarget;\n timezone?: string;\n initialData?: BookerInitialData;\n defaultFormValues?: Record;\n onCreateBookingSuccess?: (data: unknown) => void;\n labels?: Partial;\n};\n\nexport function Booker({ initialData, target, timezone, labels }: BookerProps) {\n const t = getBookerLabels(labels);\n const booker = useBooker({ initialData, target, timezone });\n\n const stepContainerRef = useRef(null);\n const selectStepRef = useRef(null);\n const confirmStepRef = useRef(null);\n useStepResizeTransition(\n booker.step,\n stepContainerRef,\n selectStepRef,\n confirmStepRef,\n );\n\n if (booker.error) {\n return (\n \n );\n }\n\n const displayMeta = booker.meta;\n const headerImageAlt = displayMeta\n ? t.headerImageAlt(displayMeta.hostName, displayMeta.eventTypeTitle)\n : \"Booker header\";\n const metaContentClassName = [\n displayMeta?.eventTypeImageUrl ? \"relative -mt-11 @5xl:-mt-13\" : \"\",\n \"flex flex-col gap-6 @5xl:p-6 p-4\",\n ]\n .filter(Boolean)\n .join(\" \");\n\n return (\n \n \n {/* Meta */}\n
\n \n
\n
\n
\n \n {displayMeta ? (\n

\n {displayMeta.hostName}\n

\n ) : (\n \n )}\n
\n
\n {displayMeta ? (\n <>\n

\n {displayMeta.eventTypeTitle}\n

\n \n \n ) : (\n <>\n \n \n \n \n )}\n
\n
\n\n {displayMeta ? (\n
\n {displayMeta.eventTypeDurationOptions ? (\n \n ) : (\n
\n \n \n {displayMeta.eventTypeDurationMinutes\n ? t.durationMinutes(\n displayMeta.eventTypeDurationMinutes,\n )\n : t.durationUnknown}\n \n
\n )}\n \n \n
\n ) : (\n
\n \n \n \n
\n )}\n
\n
\n \n \n \n \n {booker.step === \"select\" ? (\n \n {/* Calendar */}\n
\n \n
\n {/* Time picker */}\n \n \n ) : (\n \n \n \n \n )}\n
\n \n \n
\n
\n \n Cal.com\n \n \n );\n}\n\nexport default Booker;\n", "type": "registry:block", "target": "components/atoms/booker-1.tsx" }, diff --git a/apps/ui/registry/default/atoms/booker-1.tsx b/apps/ui/registry/default/atoms/booker-1.tsx index d13a1cd72..1a12e129f 100644 --- a/apps/ui/registry/default/atoms/booker-1.tsx +++ b/apps/ui/registry/default/atoms/booker-1.tsx @@ -37,10 +37,22 @@ const TRANSITION_EASE = [0.32, 0.72, 0, 1] as const; const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; -// FLIP: on step change, measure the container's current box, release it to the -// entering step's natural size to read the target, then animate the real -// width/height between the two. Animating actual dimensions (not `transform`) -// resizes the card without scaling or reflowing its content mid-transition. +type Size = { width: number; height: number }; + +const measure = (element: HTMLElement): Size => ({ + height: element.offsetHeight, + width: element.offsetWidth, +}); + +// FLIP: on step change, animate the container's real width/height from its +// previous box to the entering step's natural box. Animating actual dimensions +// (not `transform`) resizes the card without scaling or reflowing its content. +// +// `popLayout` pulls the outgoing step out of flow before this layout effect +// runs, so by now the container already reports the *new* size — too late to +// read the old one from the DOM. A `ResizeObserver` therefore tracks the +// container's resting size continuously (skipping frames driven by our own +// animation) so the pre-change size is always available as the FLIP origin. function useStepResizeTransition( step: BookerStep, containerRef: RefObject, @@ -48,16 +60,26 @@ function useStepResizeTransition( confirmStepRef: RefObject, ) { const previousStepRef = useRef(step); + const restingSizeRef = useRef(null); + const isAnimatingRef = useRef(false); const transitionTokenRef = useRef(0); useIsomorphicLayoutEffect(() => { const container = containerRef.current; - // eslint-disable-next-line no-console - console.log("[BOOKER_RESIZE_DIAG rev=marker-A]", { - prev: previousStepRef.current, - step, - hasContainer: !!container, + if (!container) { + return; + } + const observer = new ResizeObserver(() => { + if (!isAnimatingRef.current) { + restingSizeRef.current = measure(container); + } }); + observer.observe(container); + return () => observer.disconnect(); + }, [containerRef]); + + useIsomorphicLayoutEffect(() => { + const container = containerRef.current; if (!container || previousStepRef.current === step) { return; } @@ -66,26 +88,12 @@ function useStepResizeTransition( const enteringStep = step === "select" ? selectStepRef.current : confirmStepRef.current; - const start = { - height: container.offsetHeight, - width: container.offsetWidth, - }; + const start = restingSizeRef.current ?? measure(container); container.style.width = "auto"; container.style.height = "auto"; enteringStep?.style.removeProperty("width"); void container.offsetHeight; - const end = { - height: container.offsetHeight, - width: container.offsetWidth, - }; - // eslint-disable-next-line no-console - console.log("[BOOKER_RESIZE_DIAG rev=marker-A] measured", { - startW: start.width, - startH: start.height, - endW: end.width, - endH: end.height, - hasEntering: !!enteringStep, - }); + const end = measure(container); // Pin the entering step so it can't reflow while the container is clipped. if (enteringStep) { @@ -98,6 +106,7 @@ function useStepResizeTransition( "(prefers-reduced-motion: reduce)", ).matches; const token = ++transitionTokenRef.current; + isAnimatingRef.current = true; animate( container, { height: end.height, width: end.width }, @@ -113,6 +122,8 @@ function useStepResizeTransition( container.style.width = ""; container.style.height = ""; enteringStep?.style.removeProperty("width"); + restingSizeRef.current = measure(container); + isAnimatingRef.current = false; }) .catch(() => {}); }, [step]); From f2ae67f0bdafe7846e06e5c9c7af8dda2533771d Mon Sep 17 00:00:00 2001 From: pasqualevitiello Date: Mon, 6 Jul 2026 21:54:00 +0200 Subject: [PATCH 12/24] simplify motion --- apps/ui/registry/default/atoms/booker-1.tsx | 235 +++++++++----------- 1 file changed, 107 insertions(+), 128 deletions(-) diff --git a/apps/ui/registry/default/atoms/booker-1.tsx b/apps/ui/registry/default/atoms/booker-1.tsx index 1a12e129f..c58bdc22d 100644 --- a/apps/ui/registry/default/atoms/booker-1.tsx +++ b/apps/ui/registry/default/atoms/booker-1.tsx @@ -2,14 +2,13 @@ import { ArrowLeftIcon, Clock3Icon } from "lucide-react"; import { - AnimatePresence, animate, - domMax, + domAnimation, LazyMotion, MotionConfig, - m, + useReducedMotion, } from "motion/react"; -import { type RefObject, useEffect, useLayoutEffect, useRef } from "react"; +import { type ReactNode, useEffect, useLayoutEffect, useRef } from "react"; import { Button } from "@/registry/default/ui/button"; import { Card } from "@/registry/default/ui/card"; import { Input } from "@/registry/default/ui/input"; @@ -31,8 +30,9 @@ import { useBooker, } from "@/lib/booker/use-booker"; -const TRANSITION_DURATION = 0.45; -const TRANSITION_EASE = [0.32, 0.72, 0, 1] as const; +const EASE = [0.32, 0.72, 0, 1] as const; +const SIZE_TRANSITION = { duration: 0.45, ease: EASE } as const; +const ENTER_TRANSITION = { delay: 0.1, duration: 0.45, ease: EASE } as const; const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; @@ -40,35 +40,23 @@ const useIsomorphicLayoutEffect = type Size = { width: number; height: number }; const measure = (element: HTMLElement): Size => ({ - height: element.offsetHeight, width: element.offsetWidth, + height: element.offsetHeight, }); -// FLIP: on step change, animate the container's real width/height from its -// previous box to the entering step's natural box. Animating actual dimensions -// (not `transform`) resizes the card without scaling or reflowing its content. -// -// `popLayout` pulls the outgoing step out of flow before this layout effect -// runs, so by now the container already reports the *new* size — too late to -// read the old one from the DOM. A `ResizeObserver` therefore tracks the -// container's resting size continuously (skipping frames driven by our own -// animation) so the pre-change size is always available as the FLIP origin. -function useStepResizeTransition( - step: BookerStep, - containerRef: RefObject, - selectStepRef: RefObject, - confirmStepRef: RefObject, -) { +// FLIP resize + opacity enter on step change. A ResizeObserver tracks the +// container's resting size because the new step is already laid out when we run. +function useStepTransition(step: BookerStep) { + const containerRef = useRef(null); const previousStepRef = useRef(step); const restingSizeRef = useRef(null); const isAnimatingRef = useRef(false); - const transitionTokenRef = useRef(0); + const reducedMotion = useReducedMotion(); useIsomorphicLayoutEffect(() => { const container = containerRef.current; - if (!container) { - return; - } + if (!container) return; + const observer = new ResizeObserver(() => { if (!isAnimatingRef.current) { restingSizeRef.current = measure(container); @@ -76,17 +64,18 @@ function useStepResizeTransition( }); observer.observe(container); return () => observer.disconnect(); - }, [containerRef]); + }, []); useIsomorphicLayoutEffect(() => { const container = containerRef.current; - if (!container || previousStepRef.current === step) { - return; - } + if (!container || previousStepRef.current === step) return; previousStepRef.current = step; - const enteringStep = - step === "select" ? selectStepRef.current : confirmStepRef.current; + const enteringStep = container.firstElementChild as HTMLElement | null; + + if (enteringStep && !reducedMotion) { + enteringStep.style.opacity = "0"; + } const start = restingSizeRef.current ?? measure(container); container.style.width = "auto"; @@ -95,38 +84,59 @@ function useStepResizeTransition( void container.offsetHeight; const end = measure(container); - // Pin the entering step so it can't reflow while the container is clipped. - if (enteringStep) { - enteringStep.style.width = `${end.width}px`; - } + if (enteringStep) enteringStep.style.width = `${end.width}px`; container.style.width = `${start.width}px`; container.style.height = `${start.height}px`; - const prefersReducedMotion = window.matchMedia( - "(prefers-reduced-motion: reduce)", - ).matches; - const token = ++transitionTokenRef.current; isAnimatingRef.current = true; - animate( + const sizeAnimation = animate( container, - { height: end.height, width: end.width }, - { - duration: prefersReducedMotion ? 0 : TRANSITION_DURATION, - ease: TRANSITION_EASE, - }, - ) - .then(() => { - if (token !== transitionTokenRef.current) { - return; - } + { width: end.width, height: end.height }, + reducedMotion ? { duration: 0 } : SIZE_TRANSITION, + ); + const enterAnimation = + enteringStep && !reducedMotion + ? animate(enteringStep, { opacity: [0, 1] }, ENTER_TRANSITION) + : null; + + void Promise.all([sizeAnimation, enterAnimation ?? Promise.resolve()]).then( + () => { container.style.width = ""; container.style.height = ""; enteringStep?.style.removeProperty("width"); + enteringStep?.style.removeProperty("opacity"); restingSizeRef.current = measure(container); isAnimatingRef.current = false; - }) - .catch(() => {}); - }, [step]); + }, + ); + + return () => { + sizeAnimation.stop(); + enterAnimation?.stop(); + isAnimatingRef.current = false; + }; + }, [step, reducedMotion]); + + return containerRef; +} + +function BookerSteps({ + step, + children, +}: { + step: BookerStep; + children: ReactNode; +}) { + const containerRef = useStepTransition(step); + + return ( +
+ {children} +
+ ); } type BookerProps = { @@ -142,16 +152,6 @@ export function Booker({ initialData, target, timezone, labels }: BookerProps) { const t = getBookerLabels(labels); const booker = useBooker({ initialData, target, timezone }); - const stepContainerRef = useRef(null); - const selectStepRef = useRef(null); - const confirmStepRef = useRef(null); - useStepResizeTransition( - booker.step, - stepContainerRef, - selectStepRef, - confirmStepRef, - ); - if (booker.error) { return ( - - -
- - {booker.step === "select" ? ( - - {/* Calendar */} -
- -
- {/* Time picker */} - -
- ) : ( - - - - - )} -
-
+ + + + {booker.step === "select" ? ( +
+
+ +
+ +
+ ) : ( +
+ + +
+ )} +
From 275237f48cdcc7635e436db7d894ed1cece22abf Mon Sep 17 00:00:00 2001 From: pasqualevitiello Date: Mon, 6 Jul 2026 21:55:12 +0200 Subject: [PATCH 13/24] remove comments --- apps/ui/registry/default/atoms/booker-1.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/ui/registry/default/atoms/booker-1.tsx b/apps/ui/registry/default/atoms/booker-1.tsx index c58bdc22d..ffb93cd26 100644 --- a/apps/ui/registry/default/atoms/booker-1.tsx +++ b/apps/ui/registry/default/atoms/booker-1.tsx @@ -44,8 +44,6 @@ const measure = (element: HTMLElement): Size => ({ height: element.offsetHeight, }); -// FLIP resize + opacity enter on step change. A ResizeObserver tracks the -// container's resting size because the new step is already laid out when we run. function useStepTransition(step: BookerStep) { const containerRef = useRef(null); const previousStepRef = useRef(step); From bc310f789b020b82edbfab0c24c58e0f77df45e6 Mon Sep 17 00:00:00 2001 From: pasqualevitiello Date: Mon, 6 Jul 2026 21:57:24 +0200 Subject: [PATCH 14/24] more optimisations --- apps/ui/registry/default/atoms/booker-1.tsx | 219 ++++-------------- .../default/atoms/booker/booker-steps.tsx | 122 ++++++++++ apps/ui/registry/registry-atoms.ts | 5 + 3 files changed, 174 insertions(+), 172 deletions(-) create mode 100644 apps/ui/registry/default/atoms/booker/booker-steps.tsx diff --git a/apps/ui/registry/default/atoms/booker-1.tsx b/apps/ui/registry/default/atoms/booker-1.tsx index ffb93cd26..ae6da83ab 100644 --- a/apps/ui/registry/default/atoms/booker-1.tsx +++ b/apps/ui/registry/default/atoms/booker-1.tsx @@ -1,14 +1,7 @@ "use client"; import { ArrowLeftIcon, Clock3Icon } from "lucide-react"; -import { - animate, - domAnimation, - LazyMotion, - MotionConfig, - useReducedMotion, -} from "motion/react"; -import { type ReactNode, useEffect, useLayoutEffect, useRef } from "react"; +import { cn } from "@/registry/default/lib/utils"; import { Button } from "@/registry/default/ui/button"; import { Card } from "@/registry/default/ui/card"; import { Input } from "@/registry/default/ui/input"; @@ -17,6 +10,7 @@ import { BookerAvatars } from "./booker/booker-avatars"; import { BookerCalendar } from "./booker/booker-calendar"; import { BookerErrorState } from "./booker/booker-error-state"; import { type BookerLabels, getBookerLabels } from "./booker/booker-labels"; +import { BookerSteps } from "./booker/booker-steps"; import { DurationPicker } from "./booker/duration-picker"; import { EventDescription } from "./booker/event-description"; import { HeaderBanner } from "./booker/header-banner"; @@ -24,118 +18,7 @@ import { Location } from "./booker/location"; import { TimePicker } from "./booker/time-picker"; import { TimezonePicker } from "./booker/timezone-picker"; import type { BookerTarget } from "@/lib/booker/target"; -import { - type BookerInitialData, - type BookerStep, - useBooker, -} from "@/lib/booker/use-booker"; - -const EASE = [0.32, 0.72, 0, 1] as const; -const SIZE_TRANSITION = { duration: 0.45, ease: EASE } as const; -const ENTER_TRANSITION = { delay: 0.1, duration: 0.45, ease: EASE } as const; - -const useIsomorphicLayoutEffect = - typeof window === "undefined" ? useEffect : useLayoutEffect; - -type Size = { width: number; height: number }; - -const measure = (element: HTMLElement): Size => ({ - width: element.offsetWidth, - height: element.offsetHeight, -}); - -function useStepTransition(step: BookerStep) { - const containerRef = useRef(null); - const previousStepRef = useRef(step); - const restingSizeRef = useRef(null); - const isAnimatingRef = useRef(false); - const reducedMotion = useReducedMotion(); - - useIsomorphicLayoutEffect(() => { - const container = containerRef.current; - if (!container) return; - - const observer = new ResizeObserver(() => { - if (!isAnimatingRef.current) { - restingSizeRef.current = measure(container); - } - }); - observer.observe(container); - return () => observer.disconnect(); - }, []); - - useIsomorphicLayoutEffect(() => { - const container = containerRef.current; - if (!container || previousStepRef.current === step) return; - previousStepRef.current = step; - - const enteringStep = container.firstElementChild as HTMLElement | null; - - if (enteringStep && !reducedMotion) { - enteringStep.style.opacity = "0"; - } - - const start = restingSizeRef.current ?? measure(container); - container.style.width = "auto"; - container.style.height = "auto"; - enteringStep?.style.removeProperty("width"); - void container.offsetHeight; - const end = measure(container); - - if (enteringStep) enteringStep.style.width = `${end.width}px`; - container.style.width = `${start.width}px`; - container.style.height = `${start.height}px`; - - isAnimatingRef.current = true; - const sizeAnimation = animate( - container, - { width: end.width, height: end.height }, - reducedMotion ? { duration: 0 } : SIZE_TRANSITION, - ); - const enterAnimation = - enteringStep && !reducedMotion - ? animate(enteringStep, { opacity: [0, 1] }, ENTER_TRANSITION) - : null; - - void Promise.all([sizeAnimation, enterAnimation ?? Promise.resolve()]).then( - () => { - container.style.width = ""; - container.style.height = ""; - enteringStep?.style.removeProperty("width"); - enteringStep?.style.removeProperty("opacity"); - restingSizeRef.current = measure(container); - isAnimatingRef.current = false; - }, - ); - - return () => { - sizeAnimation.stop(); - enterAnimation?.stop(); - isAnimatingRef.current = false; - }; - }, [step, reducedMotion]); - - return containerRef; -} - -function BookerSteps({ - step, - children, -}: { - step: BookerStep; - children: ReactNode; -}) { - const containerRef = useStepTransition(step); - - return ( -
- {children} -
- ); -} +import { type BookerInitialData, useBooker } from "@/lib/booker/use-booker"; type BookerProps = { target: BookerTarget; @@ -164,12 +47,6 @@ export function Booker({ initialData, target, timezone, labels }: BookerProps) { const headerImageAlt = displayMeta ? t.headerImageAlt(displayMeta.hostName, displayMeta.eventTypeTitle) : "Booker header"; - const metaContentClassName = [ - displayMeta?.eventTypeImageUrl ? "relative -mt-11 @5xl:-mt-13" : "", - "flex flex-col gap-6 @5xl:p-6 p-4", - ] - .filter(Boolean) - .join(" "); return (
-
+
- - - - {booker.step === "select" ? ( -
-
- -
- -
- ) : ( -
- - -
- )} -
-
-
+ + {booker.step === "select" ? ( +
+
+ +
+ +
+ ) : ( +
+ + +
+ )} +
({ + width: element.offsetWidth, + height: element.offsetHeight, +}); + +function useStepTransition(step: BookerStep) { + const containerRef = useRef(null); + const previousStepRef = useRef(step); + const restingSizeRef = useRef(null); + const isAnimatingRef = useRef(false); + const reducedMotion = useReducedMotion(); + + useIsomorphicLayoutEffect(() => { + const container = containerRef.current; + if (!container) return; + + const observer = new ResizeObserver(() => { + if (!isAnimatingRef.current) { + restingSizeRef.current = measure(container); + } + }); + observer.observe(container); + return () => observer.disconnect(); + }, []); + + useIsomorphicLayoutEffect(() => { + const container = containerRef.current; + if (!container || previousStepRef.current === step) return; + previousStepRef.current = step; + + const enteringStep = container.firstElementChild as HTMLElement | null; + + if (enteringStep && !reducedMotion) { + enteringStep.style.opacity = "0"; + } + + const start = restingSizeRef.current ?? measure(container); + container.style.width = "auto"; + container.style.height = "auto"; + enteringStep?.style.removeProperty("width"); + void container.offsetHeight; + const end = measure(container); + + if (enteringStep) enteringStep.style.width = `${end.width}px`; + container.style.width = `${start.width}px`; + container.style.height = `${start.height}px`; + + isAnimatingRef.current = true; + const sizeAnimation = animate( + container, + { width: end.width, height: end.height }, + reducedMotion ? { duration: 0 } : SIZE_TRANSITION, + ); + const enterAnimation = + enteringStep && !reducedMotion + ? animate(enteringStep, { opacity: [0, 1] }, ENTER_TRANSITION) + : null; + + void Promise.all([sizeAnimation, enterAnimation ?? Promise.resolve()]).then( + () => { + container.style.width = ""; + container.style.height = ""; + enteringStep?.style.removeProperty("width"); + enteringStep?.style.removeProperty("opacity"); + restingSizeRef.current = measure(container); + isAnimatingRef.current = false; + }, + ); + + return () => { + sizeAnimation.stop(); + enterAnimation?.stop(); + isAnimatingRef.current = false; + }; + }, [step, reducedMotion]); + + return containerRef; +} + +export function BookerSteps({ + step, + children, +}: { + step: BookerStep; + children: ReactNode; +}) { + const containerRef = useStepTransition(step); + + return ( + + +
+ {children} +
+
+
+ ); +} diff --git a/apps/ui/registry/registry-atoms.ts b/apps/ui/registry/registry-atoms.ts index 5689acf61..cebcdefb1 100644 --- a/apps/ui/registry/registry-atoms.ts +++ b/apps/ui/registry/registry-atoms.ts @@ -24,6 +24,11 @@ export const atoms: AtomItem[] = [ target: "components/atoms/booker-1.tsx", type: "registry:block", }, + { + path: "atoms/booker/booker-steps.tsx", + target: "components/atoms/booker/booker-steps.tsx", + type: "registry:component", + }, { path: "atoms/booker/booker-calendar.tsx", target: "components/atoms/booker/booker-calendar.tsx", From 7fed84a8b6de346d0cb3e5474c530c5245da0492 Mon Sep 17 00:00:00 2001 From: pasqualevitiello Date: Mon, 6 Jul 2026 21:58:06 +0200 Subject: [PATCH 15/24] mc --- apps/ui/registry/default/atoms/booker/booker-steps.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ui/registry/default/atoms/booker/booker-steps.tsx b/apps/ui/registry/default/atoms/booker/booker-steps.tsx index cd2018d0f..28ce3be1b 100644 --- a/apps/ui/registry/default/atoms/booker/booker-steps.tsx +++ b/apps/ui/registry/default/atoms/booker/booker-steps.tsx @@ -12,7 +12,7 @@ import type { BookerStep } from "@/lib/booker/use-booker"; const EASE = [0.32, 0.72, 0, 1] as const; const SIZE_TRANSITION = { duration: 0.45, ease: EASE } as const; -const ENTER_TRANSITION = { delay: 0.1, duration: 0.45, ease: EASE } as const; +const ENTER_TRANSITION = { delay: 0.05, duration: 0.55, ease: EASE } as const; const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; From 96723c3b385542627896ba1a6902e52ae8e48f62 Mon Sep 17 00:00:00 2001 From: pasqualevitiello Date: Mon, 6 Jul 2026 21:58:20 +0200 Subject: [PATCH 16/24] build reg --- apps/ui/public/r/booker-1.json | 8 +++++++- apps/ui/public/r/registry.json | 5 +++++ apps/ui/registry.json | 5 +++++ apps/ui/registry/__index__.tsx | 4 ++++ 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/ui/public/r/booker-1.json b/apps/ui/public/r/booker-1.json index c70864987..0ac484286 100644 --- a/apps/ui/public/r/booker-1.json +++ b/apps/ui/public/r/booker-1.json @@ -27,10 +27,16 @@ "files": [ { "path": "registry/default/atoms/booker-1.tsx", - "content": "\"use client\";\n\nimport { ArrowLeftIcon, Clock3Icon } from \"lucide-react\";\nimport {\n AnimatePresence,\n animate,\n domMax,\n LazyMotion,\n MotionConfig,\n m,\n} from \"motion/react\";\nimport { type RefObject, useEffect, useLayoutEffect, useRef } from \"react\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport { Card } from \"@/registry/default/ui/card\";\nimport { Input } from \"@/registry/default/ui/input\";\nimport { Skeleton } from \"@/registry/default/ui/skeleton\";\nimport { BookerAvatars } from \"./booker/booker-avatars\";\nimport { BookerCalendar } from \"./booker/booker-calendar\";\nimport { BookerErrorState } from \"./booker/booker-error-state\";\nimport { type BookerLabels, getBookerLabels } from \"./booker/booker-labels\";\nimport { DurationPicker } from \"./booker/duration-picker\";\nimport { EventDescription } from \"./booker/event-description\";\nimport { HeaderBanner } from \"./booker/header-banner\";\nimport { Location } from \"./booker/location\";\nimport { TimePicker } from \"./booker/time-picker\";\nimport { TimezonePicker } from \"./booker/timezone-picker\";\nimport type { BookerTarget } from \"@/lib/booker/target\";\nimport {\n type BookerInitialData,\n type BookerStep,\n useBooker,\n} from \"@/lib/booker/use-booker\";\n\nconst TRANSITION_DURATION = 0.45;\nconst TRANSITION_EASE = [0.32, 0.72, 0, 1] as const;\n\nconst useIsomorphicLayoutEffect =\n typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\ntype Size = { width: number; height: number };\n\nconst measure = (element: HTMLElement): Size => ({\n height: element.offsetHeight,\n width: element.offsetWidth,\n});\n\n// FLIP: on step change, animate the container's real width/height from its\n// previous box to the entering step's natural box. Animating actual dimensions\n// (not `transform`) resizes the card without scaling or reflowing its content.\n//\n// `popLayout` pulls the outgoing step out of flow before this layout effect\n// runs, so by now the container already reports the *new* size — too late to\n// read the old one from the DOM. A `ResizeObserver` therefore tracks the\n// container's resting size continuously (skipping frames driven by our own\n// animation) so the pre-change size is always available as the FLIP origin.\nfunction useStepResizeTransition(\n step: BookerStep,\n containerRef: RefObject,\n selectStepRef: RefObject,\n confirmStepRef: RefObject,\n) {\n const previousStepRef = useRef(step);\n const restingSizeRef = useRef(null);\n const isAnimatingRef = useRef(false);\n const transitionTokenRef = useRef(0);\n\n useIsomorphicLayoutEffect(() => {\n const container = containerRef.current;\n if (!container) {\n return;\n }\n const observer = new ResizeObserver(() => {\n if (!isAnimatingRef.current) {\n restingSizeRef.current = measure(container);\n }\n });\n observer.observe(container);\n return () => observer.disconnect();\n }, [containerRef]);\n\n useIsomorphicLayoutEffect(() => {\n const container = containerRef.current;\n if (!container || previousStepRef.current === step) {\n return;\n }\n previousStepRef.current = step;\n\n const enteringStep =\n step === \"select\" ? selectStepRef.current : confirmStepRef.current;\n\n const start = restingSizeRef.current ?? measure(container);\n container.style.width = \"auto\";\n container.style.height = \"auto\";\n enteringStep?.style.removeProperty(\"width\");\n void container.offsetHeight;\n const end = measure(container);\n\n // Pin the entering step so it can't reflow while the container is clipped.\n if (enteringStep) {\n enteringStep.style.width = `${end.width}px`;\n }\n container.style.width = `${start.width}px`;\n container.style.height = `${start.height}px`;\n\n const prefersReducedMotion = window.matchMedia(\n \"(prefers-reduced-motion: reduce)\",\n ).matches;\n const token = ++transitionTokenRef.current;\n isAnimatingRef.current = true;\n animate(\n container,\n { height: end.height, width: end.width },\n {\n duration: prefersReducedMotion ? 0 : TRANSITION_DURATION,\n ease: TRANSITION_EASE,\n },\n )\n .then(() => {\n if (token !== transitionTokenRef.current) {\n return;\n }\n container.style.width = \"\";\n container.style.height = \"\";\n enteringStep?.style.removeProperty(\"width\");\n restingSizeRef.current = measure(container);\n isAnimatingRef.current = false;\n })\n .catch(() => {});\n }, [step]);\n}\n\ntype BookerProps = {\n target: BookerTarget;\n timezone?: string;\n initialData?: BookerInitialData;\n defaultFormValues?: Record;\n onCreateBookingSuccess?: (data: unknown) => void;\n labels?: Partial;\n};\n\nexport function Booker({ initialData, target, timezone, labels }: BookerProps) {\n const t = getBookerLabels(labels);\n const booker = useBooker({ initialData, target, timezone });\n\n const stepContainerRef = useRef(null);\n const selectStepRef = useRef(null);\n const confirmStepRef = useRef(null);\n useStepResizeTransition(\n booker.step,\n stepContainerRef,\n selectStepRef,\n confirmStepRef,\n );\n\n if (booker.error) {\n return (\n \n );\n }\n\n const displayMeta = booker.meta;\n const headerImageAlt = displayMeta\n ? t.headerImageAlt(displayMeta.hostName, displayMeta.eventTypeTitle)\n : \"Booker header\";\n const metaContentClassName = [\n displayMeta?.eventTypeImageUrl ? \"relative -mt-11 @5xl:-mt-13\" : \"\",\n \"flex flex-col gap-6 @5xl:p-6 p-4\",\n ]\n .filter(Boolean)\n .join(\" \");\n\n return (\n \n \n {/* Meta */}\n
\n \n
\n
\n
\n \n {displayMeta ? (\n

\n {displayMeta.hostName}\n

\n ) : (\n \n )}\n
\n
\n {displayMeta ? (\n <>\n

\n {displayMeta.eventTypeTitle}\n

\n \n \n ) : (\n <>\n \n \n \n \n )}\n
\n
\n\n {displayMeta ? (\n
\n {displayMeta.eventTypeDurationOptions ? (\n \n ) : (\n
\n \n \n {displayMeta.eventTypeDurationMinutes\n ? t.durationMinutes(\n displayMeta.eventTypeDurationMinutes,\n )\n : t.durationUnknown}\n \n
\n )}\n \n \n
\n ) : (\n
\n \n \n \n
\n )}\n
\n
\n \n \n \n \n {booker.step === \"select\" ? (\n \n {/* Calendar */}\n
\n \n
\n {/* Time picker */}\n \n \n ) : (\n \n \n \n \n )}\n
\n
\n \n \n \n \n Cal.com\n \n
\n );\n}\n\nexport default Booker;\n", + "content": "\"use client\";\n\nimport { ArrowLeftIcon, Clock3Icon } from \"lucide-react\";\nimport { cn } from \"@/registry/default/lib/utils\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport { Card } from \"@/registry/default/ui/card\";\nimport { Input } from \"@/registry/default/ui/input\";\nimport { Skeleton } from \"@/registry/default/ui/skeleton\";\nimport { BookerAvatars } from \"./booker/booker-avatars\";\nimport { BookerCalendar } from \"./booker/booker-calendar\";\nimport { BookerErrorState } from \"./booker/booker-error-state\";\nimport { type BookerLabels, getBookerLabels } from \"./booker/booker-labels\";\nimport { BookerSteps } from \"./booker/booker-steps\";\nimport { DurationPicker } from \"./booker/duration-picker\";\nimport { EventDescription } from \"./booker/event-description\";\nimport { HeaderBanner } from \"./booker/header-banner\";\nimport { Location } from \"./booker/location\";\nimport { TimePicker } from \"./booker/time-picker\";\nimport { TimezonePicker } from \"./booker/timezone-picker\";\nimport type { BookerTarget } from \"@/lib/booker/target\";\nimport { type BookerInitialData, useBooker } from \"@/lib/booker/use-booker\";\n\ntype BookerProps = {\n target: BookerTarget;\n timezone?: string;\n initialData?: BookerInitialData;\n defaultFormValues?: Record;\n onCreateBookingSuccess?: (data: unknown) => void;\n labels?: Partial;\n};\n\nexport function Booker({ initialData, target, timezone, labels }: BookerProps) {\n const t = getBookerLabels(labels);\n const booker = useBooker({ initialData, target, timezone });\n\n if (booker.error) {\n return (\n \n );\n }\n\n const displayMeta = booker.meta;\n const headerImageAlt = displayMeta\n ? t.headerImageAlt(displayMeta.hostName, displayMeta.eventTypeTitle)\n : \"Booker header\";\n\n return (\n \n \n {/* Meta */}\n
\n \n \n
\n
\n \n {displayMeta ? (\n

\n {displayMeta.hostName}\n

\n ) : (\n \n )}\n
\n
\n {displayMeta ? (\n <>\n

\n {displayMeta.eventTypeTitle}\n

\n \n \n ) : (\n <>\n \n \n \n \n )}\n
\n
\n\n {displayMeta ? (\n
\n {displayMeta.eventTypeDurationOptions ? (\n \n ) : (\n
\n \n \n {displayMeta.eventTypeDurationMinutes\n ? t.durationMinutes(\n displayMeta.eventTypeDurationMinutes,\n )\n : t.durationUnknown}\n \n
\n )}\n \n \n
\n ) : (\n
\n \n \n \n
\n )}\n
\n
\n \n {booker.step === \"select\" ? (\n
\n
\n \n
\n \n
\n ) : (\n \n \n \n
\n )}\n \n \n \n Cal.com\n \n \n );\n}\n\nexport default Booker;\n", "type": "registry:block", "target": "components/atoms/booker-1.tsx" }, + { + "path": "registry/default/atoms/booker/booker-steps.tsx", + "content": "\"use client\";\n\nimport {\n animate,\n domAnimation,\n LazyMotion,\n MotionConfig,\n useReducedMotion,\n} from \"motion/react\";\nimport { type ReactNode, useEffect, useLayoutEffect, useRef } from \"react\";\nimport type { BookerStep } from \"@/lib/booker/use-booker\";\n\nconst EASE = [0.32, 0.72, 0, 1] as const;\nconst SIZE_TRANSITION = { duration: 0.45, ease: EASE } as const;\nconst ENTER_TRANSITION = { delay: 0.05, duration: 0.55, ease: EASE } as const;\n\nconst useIsomorphicLayoutEffect =\n typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\ntype Size = { width: number; height: number };\n\nconst measure = (element: HTMLElement): Size => ({\n width: element.offsetWidth,\n height: element.offsetHeight,\n});\n\nfunction useStepTransition(step: BookerStep) {\n const containerRef = useRef(null);\n const previousStepRef = useRef(step);\n const restingSizeRef = useRef(null);\n const isAnimatingRef = useRef(false);\n const reducedMotion = useReducedMotion();\n\n useIsomorphicLayoutEffect(() => {\n const container = containerRef.current;\n if (!container) return;\n\n const observer = new ResizeObserver(() => {\n if (!isAnimatingRef.current) {\n restingSizeRef.current = measure(container);\n }\n });\n observer.observe(container);\n return () => observer.disconnect();\n }, []);\n\n useIsomorphicLayoutEffect(() => {\n const container = containerRef.current;\n if (!container || previousStepRef.current === step) return;\n previousStepRef.current = step;\n\n const enteringStep = container.firstElementChild as HTMLElement | null;\n\n if (enteringStep && !reducedMotion) {\n enteringStep.style.opacity = \"0\";\n }\n\n const start = restingSizeRef.current ?? measure(container);\n container.style.width = \"auto\";\n container.style.height = \"auto\";\n enteringStep?.style.removeProperty(\"width\");\n void container.offsetHeight;\n const end = measure(container);\n\n if (enteringStep) enteringStep.style.width = `${end.width}px`;\n container.style.width = `${start.width}px`;\n container.style.height = `${start.height}px`;\n\n isAnimatingRef.current = true;\n const sizeAnimation = animate(\n container,\n { width: end.width, height: end.height },\n reducedMotion ? { duration: 0 } : SIZE_TRANSITION,\n );\n const enterAnimation =\n enteringStep && !reducedMotion\n ? animate(enteringStep, { opacity: [0, 1] }, ENTER_TRANSITION)\n : null;\n\n void Promise.all([sizeAnimation, enterAnimation ?? Promise.resolve()]).then(\n () => {\n container.style.width = \"\";\n container.style.height = \"\";\n enteringStep?.style.removeProperty(\"width\");\n enteringStep?.style.removeProperty(\"opacity\");\n restingSizeRef.current = measure(container);\n isAnimatingRef.current = false;\n },\n );\n\n return () => {\n sizeAnimation.stop();\n enterAnimation?.stop();\n isAnimatingRef.current = false;\n };\n }, [step, reducedMotion]);\n\n return containerRef;\n}\n\nexport function BookerSteps({\n step,\n children,\n}: {\n step: BookerStep;\n children: ReactNode;\n}) {\n const containerRef = useStepTransition(step);\n\n return (\n \n \n \n {children}\n \n \n \n );\n}\n", + "type": "registry:component", + "target": "components/atoms/booker/booker-steps.tsx" + }, { "path": "registry/default/atoms/booker/booker-calendar.tsx", "content": "\"use client\";\n\nimport { ChevronLeftIcon, ChevronRightIcon } from \"lucide-react\";\nimport {\n type ComponentProps,\n type KeyboardEvent,\n type MouseEvent,\n type ReactElement,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport { cn } from \"@/registry/default/lib/utils\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport { Skeleton } from \"@/registry/default/ui/skeleton\";\nimport {\n Tooltip,\n TooltipCreateHandle,\n TooltipPopup,\n TooltipProvider,\n TooltipTrigger,\n} from \"@/registry/default/ui/tooltip\";\n\n// A single shared tooltip reused across every day cell (via the handle/payload\n// pattern). Reusing one instance lets it gracefully move/scale between days\n// instead of mounting a separate tooltip per cell.\nconst monthTooltipHandle = TooltipCreateHandle();\n\n// Falls back to English (US) when no `locale` prop is provided.\nconst DEFAULT_LOCALE = \"en-US\";\n\ntype CalendarLabels = {\n /** ARIA label for the previous-month navigation button. */\n previousMonth: string;\n /** ARIA label for the next-month navigation button. */\n nextMonth: string;\n /** ARIA label for the navigation toolbar. */\n nav: string;\n /** Prefix for today's day button label, e.g. \"Today, Monday, …\". */\n today: string;\n /** Suffix for the selected day button label, e.g. \"…, selected\". */\n selected: string;\n};\n\n// English defaults. Localize by passing translated strings via the `labels`\n// prop (wire it up to your app's own i18n layer); date/weekday/month names are\n// still localized automatically from `locale` via `Intl`.\nconst DEFAULT_LABELS: CalendarLabels = {\n previousMonth: \"Go to the Previous Month\",\n nextMonth: \"Go to the Next Month\",\n nav: \"Navigation bar\",\n today: \"Today\",\n selected: \"selected\",\n};\n\n// Resolves the text direction for the locale (e.g. \"rtl\" for Arabic/Hebrew).\nfunction getTextDirection(locale: string): \"ltr\" | \"rtl\" {\n try {\n const localeObj = new Intl.Locale(locale) as Intl.Locale & {\n textInfo?: { direction?: string };\n getTextInfo?: () => { direction?: string };\n };\n const info =\n typeof localeObj.getTextInfo === \"function\"\n ? localeObj.getTextInfo()\n : localeObj.textInfo;\n if (info?.direction === \"rtl\") {\n return \"rtl\";\n }\n } catch {\n // Fall through to the ltr default below.\n }\n return \"ltr\";\n}\n\n// Locale-aware formatting/ordering, derived from a BCP-47 locale string.\ntype Localization = {\n weekStartsOn: number;\n monthYearLabel: (date: Date) => string;\n fullDateLabel: (date: Date) => string;\n monthShortLabel: (date: Date) => string;\n monthLongLabel: (date: Date) => string;\n weekdayLong: (date: Date) => string;\n weekdayShort: (date: Date) => string;\n dayNumber: (date: Date) => string;\n captionFormatter: Intl.DateTimeFormat;\n};\n\n// 1 = Monday … 7 = Sunday (ISO) → JS getDay() index (0 = Sunday … 6 = Saturday).\nfunction getWeekStartsOn(locale: string): number {\n try {\n const localeObj = new Intl.Locale(locale) as Intl.Locale & {\n weekInfo?: { firstDay?: number };\n getWeekInfo?: () => { firstDay?: number };\n };\n const info =\n typeof localeObj.getWeekInfo === \"function\"\n ? localeObj.getWeekInfo()\n : localeObj.weekInfo;\n const firstDay = info?.firstDay;\n if (typeof firstDay === \"number\") {\n return firstDay % 7;\n }\n } catch {\n // Fall through to the Sunday default below.\n }\n return 0;\n}\n\nfunction buildLocalization(\n locale: string,\n weekStartsOnOverride?: number,\n): Localization {\n const longDateFormatter = new Intl.DateTimeFormat(locale, {\n weekday: \"long\",\n year: \"numeric\",\n month: \"long\",\n day: \"numeric\",\n });\n const monthYearFormatter = new Intl.DateTimeFormat(locale, {\n month: \"long\",\n year: \"numeric\",\n });\n const monthShortFormatter = new Intl.DateTimeFormat(locale, {\n month: \"short\",\n });\n const monthLongFormatter = new Intl.DateTimeFormat(locale, {\n month: \"long\",\n });\n const weekdayLongFormatter = new Intl.DateTimeFormat(locale, {\n weekday: \"long\",\n });\n const weekdayShortFormatter = new Intl.DateTimeFormat(locale, {\n weekday: \"short\",\n });\n const dayFormatter = new Intl.DateTimeFormat(locale, { day: \"numeric\" });\n\n return {\n weekStartsOn: weekStartsOnOverride ?? getWeekStartsOn(locale),\n monthYearLabel: (date) => monthYearFormatter.format(date),\n fullDateLabel: (date) => longDateFormatter.format(date),\n monthShortLabel: (date) => monthShortFormatter.format(date),\n monthLongLabel: (date) => monthLongFormatter.format(date),\n weekdayLong: (date) => weekdayLongFormatter.format(date),\n weekdayShort: (date) => weekdayShortFormatter.format(date),\n dayNumber: (date) => dayFormatter.format(date),\n captionFormatter: monthYearFormatter,\n };\n}\n\nfunction pad2(value: number): string {\n return value < 10 ? `0${value}` : String(value);\n}\n\nfunction isoDate(date: Date): string {\n return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;\n}\n\nfunction isoMonth(date: Date): string {\n return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}`;\n}\n\nfunction startOfDay(date: Date): Date {\n return new Date(date.getFullYear(), date.getMonth(), date.getDate());\n}\n\nfunction startOfMonth(date: Date): Date {\n return new Date(date.getFullYear(), date.getMonth(), 1);\n}\n\nfunction endOfMonth(date: Date): Date {\n return new Date(date.getFullYear(), date.getMonth() + 1, 0);\n}\n\nfunction addDays(date: Date, amount: number): Date {\n return new Date(date.getFullYear(), date.getMonth(), date.getDate() + amount);\n}\n\nfunction addMonths(date: Date, amount: number): Date {\n return new Date(date.getFullYear(), date.getMonth() + amount, date.getDate());\n}\n\nfunction startOfWeek(date: Date, weekStartsOn = 0): Date {\n const day = startOfDay(date);\n const diff = (day.getDay() - weekStartsOn + 7) % 7;\n return addDays(day, -diff);\n}\n\nfunction endOfWeek(date: Date, weekStartsOn = 0): Date {\n return addDays(startOfWeek(date, weekStartsOn), 6);\n}\n\nfunction isSameDay(a: Date, b: Date): boolean {\n return (\n a.getFullYear() === b.getFullYear() &&\n a.getMonth() === b.getMonth() &&\n a.getDate() === b.getDate()\n );\n}\n\nfunction isSameMonth(a: Date, b: Date): boolean {\n return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();\n}\n\nfunction differenceInCalendarMonths(a: Date, b: Date): number {\n return (\n (a.getFullYear() - b.getFullYear()) * 12 + (a.getMonth() - b.getMonth())\n );\n}\n\ntype CalendarDay = {\n date: Date;\n outside: boolean;\n isoDate: string;\n dateMonthId: string;\n};\n\ntype DayModifiers = {\n focused: boolean;\n disabled: boolean;\n hidden: boolean;\n outside: boolean;\n today: boolean;\n selected: boolean;\n};\n\n// Builds the month grid as full weeks covering the month.\n//\n// `shiftRows` slides the window forward by that many weeks, dropping the\n// month's earliest week(s). When `fillToSix` is set the grid is then padded to\n// a full 6 rows with days from the next month. Together they keep today's week\n// plus the next two visible without exceeding 6 rows; with both at their\n// defaults the month spans only the weeks it naturally needs.\nfunction buildCalendarDays(\n month: Date,\n weekStartsOn: number,\n shiftRows: number,\n fillToSix: boolean,\n): CalendarDay[] {\n const monthStart = startOfMonth(month);\n const offset = shiftRows * 7;\n const gridStart = addDays(startOfWeek(monthStart, weekStartsOn), offset);\n const gridEnd = addDays(endOfWeek(endOfMonth(month), weekStartsOn), offset);\n\n const dates: Date[] = [];\n for (let cursor = gridStart; cursor <= gridEnd; cursor = addDays(cursor, 1)) {\n dates.push(cursor);\n }\n\n if (fillToSix) {\n while (dates.length < 42) {\n const lastDate = dates.at(-1);\n if (!lastDate) {\n break;\n }\n dates.push(addDays(lastDate, 1));\n }\n }\n\n return dates.map((date) => ({\n date,\n outside: !isSameMonth(date, month),\n isoDate: isoDate(date),\n dateMonthId: isoMonth(date),\n }));\n}\n\nfunction chunkWeeks(days: CalendarDay[]): CalendarDay[][] {\n const weeks: CalendarDay[][] = [];\n for (let i = 0; i < days.length; i += 7) {\n weeks.push(days.slice(i, i + 7));\n }\n return weeks;\n}\n\nfunction isFocusableDay(modifiers: DayModifiers): boolean {\n return !modifiers.disabled && !modifiers.hidden && !modifiers.outside;\n}\n\n// A single day cell's button. Focuses itself when it becomes the roving-tabindex\n// target so keyboard navigation moves the actual DOM focus.\nfunction DayButton({\n focused,\n nextMonthLabel,\n tooltip,\n children,\n ...buttonProps\n}: ComponentProps<\"button\"> & {\n focused: boolean;\n nextMonthLabel?: string;\n tooltip?: string;\n}): ReactElement {\n const ref = useRef(null);\n\n useEffect(() => {\n if (focused) ref.current?.focus();\n }, [focused]);\n\n const button = (\n \n );\n\n if (!tooltip) return button;\n\n return (\n \n );\n}\n\n// Month + year heading; the year is muted via formatToParts so it stays\n// locale-correct (some locales render \"2026 June\").\nfunction MonthCaption({\n date,\n formatter,\n className,\n}: {\n date: Date;\n formatter: Intl.DateTimeFormat;\n className?: string;\n}): ReactElement {\n return (\n \n {formatter.formatToParts(date).map((part) =>\n part.type === \"year\" ? (\n \n {part.value}\n \n ) : (\n part.value\n ),\n )}\n \n );\n}\n\ntype MoveBy = \"day\" | \"week\" | \"month\" | \"year\" | \"startOfWeek\" | \"endOfWeek\";\ntype MoveDir = \"before\" | \"after\";\n\nexport type BookerCalendarProps = {\n className?: string;\n /**\n * For the current month, once today reaches the 4th row, keep its week plus\n * the next two visible within the 6-row grid: extend into next-month days,\n * and slide the window down (dropping early weeks) only when today sits too\n * low for those two weeks to fit. Off renders a plain month grid. On by\n * default.\n */\n shiftWeeks?: boolean;\n availabilityLoading?: boolean;\n initialLoading?: boolean;\n month?: Date;\n defaultMonth?: Date;\n startMonth?: Date;\n endMonth?: Date;\n selected?: Date;\n onSelect?: (date: Date | undefined, triggerDate: Date) => void;\n onMonthChange?: (month: Date) => void;\n disabled?: (date: Date) => boolean;\n /** BCP-47 locale tag used to localize day/month/weekday names and ARIA labels. */\n locale?: string;\n /** Override the locale's first day of the week (0 = Sunday … 6 = Saturday). */\n weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;\n /** Text direction; defaults to the locale's direction (\"rtl\" for ar/he/…). */\n dir?: \"ltr\" | \"rtl\";\n /** Translatable strings for the navigation buttons and day button states. */\n labels?: Partial;\n /** Reference \"today\" for highlighting and week shifting; defaults to now. */\n today?: Date;\n};\n\nexport function BookerCalendar({\n className,\n shiftWeeks = true,\n availabilityLoading = false,\n initialLoading = false,\n month,\n defaultMonth,\n startMonth,\n endMonth,\n selected,\n onSelect,\n onMonthChange,\n disabled,\n locale = DEFAULT_LOCALE,\n weekStartsOn,\n dir,\n labels,\n today: todayProp,\n}: BookerCalendarProps): ReactElement {\n const dayCellsLoading = initialLoading || availabilityLoading;\n const localization = useMemo(\n () => buildLocalization(locale, weekStartsOn),\n [locale, weekStartsOn],\n );\n const resolvedLabels = { ...DEFAULT_LABELS, ...labels };\n const resolvedDir = dir ?? getTextDirection(locale);\n const weekStart = localization.weekStartsOn;\n\n const today = todayProp ?? new Date();\n\n // The month is controlled by the parent (via `month`/`onMonthChange`), but we\n // keep an internal fallback so the calendar works uncontrolled too.\n const [internalMonth, setInternalMonth] = useState(() =>\n startOfMonth(month ?? defaultMonth ?? today),\n );\n const monthTime = month ? startOfMonth(month).getTime() : undefined;\n useEffect(() => {\n if (monthTime !== undefined) {\n setInternalMonth(new Date(monthTime));\n }\n }, [monthTime]);\n\n const firstMonth = month ? startOfMonth(month) : internalMonth;\n\n const [focusedDate, setFocusedDate] = useState(undefined);\n const [lastFocusedDate, setLastFocusedDate] = useState(\n undefined,\n );\n\n const navStart = startMonth ? startOfMonth(startMonth) : undefined;\n const navEnd = endMonth ? startOfMonth(endMonth) : undefined;\n\n const previousMonth =\n navStart && differenceInCalendarMonths(firstMonth, navStart) <= 0\n ? undefined\n : addMonths(firstMonth, -1);\n const nextMonth =\n navEnd && differenceInCalendarMonths(navEnd, firstMonth) <= 0\n ? undefined\n : addMonths(firstMonth, 1);\n\n const goToMonth = useCallback(\n (date: Date) => {\n let newMonth = startOfMonth(date);\n if (navStart && newMonth < navStart) {\n newMonth = navStart;\n }\n if (navEnd && newMonth > navEnd) {\n newMonth = navEnd;\n }\n setInternalMonth(newMonth);\n onMonthChange?.(newMonth);\n },\n [navEnd, navStart, onMonthChange],\n );\n\n // For the live current month, once today reaches the 4th row, guarantee its\n // week plus the next two are visible. todayRow is today's 0-based row in the\n // natural (unshifted) grid.\n const naturalGridStart = startOfWeek(startOfMonth(firstMonth), weekStart);\n const todayRow = Math.floor(\n Math.round(\n (startOfDay(today).getTime() - naturalGridStart.getTime()) / 86_400_000,\n ) / 7,\n );\n const expand = shiftWeeks && isSameMonth(firstMonth, today) && todayRow >= 3;\n // Extend downward first; only drop early weeks (shift) when today sits so low\n // that the two trailing weeks wouldn't fit within the 6-row cap.\n const shiftRows = expand ? Math.max(0, todayRow - 3) : 0;\n\n const days = buildCalendarDays(firstMonth, weekStart, shiftRows, expand);\n const weeks = chunkWeeks(days);\n\n // Reserve any missing rows up to 6 (the most any month needs) with empty\n // placeholders, so the grid keeps a constant height across navigation. This\n // is 0 when expanding, since those months already fill all 6 rows with days.\n const placeholderRows = Math.max(0, 6 - weeks.length);\n\n const isSelected = (date: Date): boolean =>\n selected ? isSameDay(selected, date) : false;\n\n const getModifiers = (day: CalendarDay): DayModifiers => {\n const { date, outside } = day;\n const isBeforeNavStart = Boolean(navStart && date < navStart);\n const isAfterNavEnd = Boolean(navEnd && date > endOfMonth(navEnd));\n const isDisabled = disabled ? disabled(date) : false;\n // Leading previous-month days are never shown (past days aren't relevant).\n // Next-month days only belong in the grid while expanding; otherwise the\n // empty placeholder rows reserve their space instead of spilling over.\n const isPrevMonthDay = date < startOfMonth(firstMonth);\n const isNextMonthDay = date > endOfMonth(firstMonth);\n const isHidden =\n isBeforeNavStart ||\n isAfterNavEnd ||\n isPrevMonthDay ||\n (!expand && isNextMonthDay);\n const isToday = isSameDay(date, today);\n const isFocused =\n !isHidden &&\n !outside &&\n focusedDate !== undefined &&\n isSameDay(date, focusedDate);\n\n return {\n focused: isFocused,\n disabled: isDisabled,\n hidden: isHidden,\n outside,\n today: isToday,\n selected: isSelected(date),\n };\n };\n\n // Roving tabindex target. Priority: focused > last focused > selected > today\n // > first focusable day.\n const focusTarget = ((): CalendarDay | undefined => {\n let target: CalendarDay | undefined;\n let priority = -1;\n for (const day of days) {\n const modifiers = getModifiers(day);\n if (!isFocusableDay(modifiers)) continue;\n if (modifiers.focused && priority < 3) {\n target = day;\n priority = 3;\n } else if (\n lastFocusedDate &&\n isSameDay(day.date, lastFocusedDate) &&\n priority < 2\n ) {\n target = day;\n priority = 2;\n } else if (isSelected(day.date) && priority < 1) {\n target = day;\n priority = 1;\n } else if (modifiers.today && priority < 0) {\n target = day;\n priority = 0;\n }\n }\n if (!target) {\n target = days.find((day) => isFocusableDay(getModifiers(day)));\n }\n return target;\n })();\n\n // While expanding, mark the 1st of the next month (the first visible day past\n // the current month's end) with the month label.\n const monthEnd = endOfMonth(firstMonth);\n const nextMonthLabelIso = expand\n ? days.find((day) => {\n const modifiers = getModifiers(day);\n return day.date > monthEnd && !modifiers.hidden;\n })?.isoDate\n : undefined;\n\n const getFocusableDate = (\n moveBy: MoveBy,\n moveDir: MoveDir,\n refDate: Date,\n ): Date => {\n const delta = moveDir === \"after\" ? 1 : -1;\n let result: Date;\n switch (moveBy) {\n case \"day\":\n result = addDays(refDate, delta);\n break;\n case \"week\":\n result = addDays(refDate, delta * 7);\n break;\n case \"month\":\n result = addMonths(refDate, delta);\n break;\n case \"year\":\n result = addMonths(refDate, delta * 12);\n break;\n case \"startOfWeek\":\n result = startOfWeek(refDate, weekStart);\n break;\n case \"endOfWeek\":\n result = endOfWeek(refDate, weekStart);\n break;\n }\n if (moveDir === \"before\" && navStart && result < navStart) {\n result = navStart;\n }\n if (moveDir === \"after\" && navEnd && result > endOfMonth(navEnd)) {\n result = endOfMonth(navEnd);\n }\n return result;\n };\n\n const getNextFocus = (\n moveBy: MoveBy,\n moveDir: MoveDir,\n refDate: Date,\n attempt = 0,\n ): Date | undefined => {\n if (attempt > 365) return undefined;\n const next = getFocusableDate(moveBy, moveDir, refDate);\n const isDisabled = disabled ? disabled(next) : false;\n if (!isDisabled) return next;\n return getNextFocus(moveBy, moveDir, next, attempt + 1);\n };\n\n const moveFocus = (moveBy: MoveBy, moveDir: MoveDir) => {\n if (!focusedDate) return;\n const next = getNextFocus(moveBy, moveDir, focusedDate);\n if (!next) return;\n if (!isSameMonth(next, firstMonth)) {\n goToMonth(next);\n }\n setFocusedDate(next);\n };\n\n const handleDayClick =\n (day: CalendarDay, modifiers: DayModifiers) => (event: MouseEvent) => {\n event.preventDefault();\n event.stopPropagation();\n setFocusedDate(day.date);\n if (modifiers.disabled) return;\n const newDate =\n selected && isSameDay(day.date, selected) ? undefined : day.date;\n onSelect?.(newDate, day.date);\n };\n\n const handleDayFocus = (day: CalendarDay) => () => {\n setFocusedDate(day.date);\n };\n\n const handleDayBlur = () => {\n setLastFocusedDate(focusedDate);\n setFocusedDate(undefined);\n };\n\n const handleDayKeyDown = (event: KeyboardEvent) => {\n const keyMap: Record = {\n ArrowLeft: [event.shiftKey ? \"month\" : \"day\", \"before\"],\n ArrowRight: [event.shiftKey ? \"month\" : \"day\", \"after\"],\n ArrowDown: [event.shiftKey ? \"year\" : \"week\", \"after\"],\n ArrowUp: [event.shiftKey ? \"year\" : \"week\", \"before\"],\n PageUp: [event.shiftKey ? \"year\" : \"month\", \"before\"],\n PageDown: [event.shiftKey ? \"year\" : \"month\", \"after\"],\n Home: [\"startOfWeek\", \"before\"],\n End: [\"endOfWeek\", \"after\"],\n };\n const move = keyMap[event.key];\n if (move) {\n event.preventDefault();\n event.stopPropagation();\n const [moveBy, moveDir] = move;\n moveFocus(moveBy, moveDir);\n }\n };\n\n const weekdayHeaderStart = startOfWeek(today, weekStart);\n const weekdays = Array.from({ length: 7 }, (_, index) => {\n const date = addDays(weekdayHeaderStart, index);\n return {\n long: localization.weekdayLong(date),\n short: localization.weekdayShort(date),\n };\n });\n\n return (\n \n
\n
\n {localization.monthYearLabel(firstMonth)}\n
\n
\n {initialLoading ? (\n \n ) : (\n <>\n \n \n {\n if (previousMonth) goToMonth(previousMonth);\n }}\n >\n \n \n {\n if (nextMonth) goToMonth(nextMonth);\n }}\n >\n \n \n \n \n )}\n
\n \n \n \n \n {weekdays.map((weekday) => (\n \n {initialLoading ? (\n \n ) : (\n weekday.short\n )}\n \n ))}\n \n \n \n {weeks.map((week) => (\n \n {week.map((day) => {\n const modifiers = getModifiers(day);\n\n // The day button's own styling is driven by the `data-*`\n // attributes below; these cell classes only cover the\n // layout and the outside/hidden/today states.\n const cellClassName = cn(\n \"@max-3xl:max-h-9 flex-1 p-0\",\n modifiers.hidden && \"invisible\",\n modifiers.outside && \"text-muted-foreground/50\",\n modifiers.today &&\n !dayCellsLoading &&\n \"*:before:pointer-events-none *:before:absolute *:before:start-1/2 *:before:bottom-1/7 *:before:z-1 *:before:size-1 *:before:-translate-x-1/2 *:before:rounded-full *:before:bg-primary *:before:transition-colors [&[data-selected]>*]:before:bg-primary-foreground\",\n );\n\n // Label (e.g. \"Aug\") sits on the 1st of the next month.\n const nextMonthLabel =\n day.isoDate === nextMonthLabelIso\n ? localization.monthShortLabel(day.date)\n : undefined;\n\n // Next-month days only show in the shifted current-month\n // view; a month-name tooltip keeps them from being mistaken\n // for the current month.\n const monthTooltip =\n day.outside && !modifiers.disabled && !modifiers.hidden\n ? localization.monthLongLabel(day.date)\n : undefined;\n\n return (\n \n {modifiers.hidden ? null : dayCellsLoading ? (\n \n \n \n ) : (\n {\n let label = localization.fullDateLabel(day.date);\n if (modifiers.today) {\n label = `${resolvedLabels.today}, ${label}`;\n }\n if (modifiers.selected) {\n label = `${label}, ${resolvedLabels.selected}`;\n }\n return label;\n })()}\n onClick={handleDayClick(day, modifiers)}\n onBlur={handleDayBlur}\n onFocus={handleDayFocus(day)}\n onKeyDown={handleDayKeyDown}\n >\n {localization.dayNumber(day.date)}\n \n )}\n \n );\n })}\n \n ))}\n {Array.from(\n { length: placeholderRows },\n (_, rowIndex) => `placeholder-row-${rowIndex}`,\n ).map((rowKey) => (\n \n {weekdays.map((weekday) => (\n \n
\n \n ))}\n \n ))}\n \n \n \n {({ payload }) => (\n \n {payload}\n \n )}\n \n \n
\n
\n );\n}\n", diff --git a/apps/ui/public/r/registry.json b/apps/ui/public/r/registry.json index c05006e6f..d9c209f8d 100644 --- a/apps/ui/public/r/registry.json +++ b/apps/ui/public/r/registry.json @@ -11339,6 +11339,11 @@ "target": "components/atoms/booker-1.tsx", "type": "registry:block" }, + { + "path": "registry/default/atoms/booker/booker-steps.tsx", + "target": "components/atoms/booker/booker-steps.tsx", + "type": "registry:component" + }, { "path": "registry/default/atoms/booker/booker-calendar.tsx", "target": "components/atoms/booker/booker-calendar.tsx", diff --git a/apps/ui/registry.json b/apps/ui/registry.json index c05006e6f..d9c209f8d 100644 --- a/apps/ui/registry.json +++ b/apps/ui/registry.json @@ -11339,6 +11339,11 @@ "target": "components/atoms/booker-1.tsx", "type": "registry:block" }, + { + "path": "registry/default/atoms/booker/booker-steps.tsx", + "target": "components/atoms/booker/booker-steps.tsx", + "type": "registry:component" + }, { "path": "registry/default/atoms/booker/booker-calendar.tsx", "target": "components/atoms/booker/booker-calendar.tsx", diff --git a/apps/ui/registry/__index__.tsx b/apps/ui/registry/__index__.tsx index 0f5aa9f7a..ab01e9b37 100644 --- a/apps/ui/registry/__index__.tsx +++ b/apps/ui/registry/__index__.tsx @@ -9844,6 +9844,10 @@ export const Index: Record = { path: "registry/default/atoms/booker-1.tsx", type: "registry:block", target: "components/atoms/booker-1.tsx" + },{ + path: "registry/default/atoms/booker/booker-steps.tsx", + type: "registry:component", + target: "components/atoms/booker/booker-steps.tsx" },{ path: "registry/default/atoms/booker/booker-calendar.tsx", type: "registry:component", From c43e62383820fcc78c2f3dbcbba0ea79d3c5fc43 Mon Sep 17 00:00:00 2001 From: pasqualevitiello Date: Mon, 6 Jul 2026 22:04:01 +0200 Subject: [PATCH 17/24] complete form --- apps/ui/registry/default/atoms/booker-1.tsx | 37 +++--- .../atoms/booker/booker-confirm-form.tsx | 105 ++++++++++++++++++ .../default/atoms/booker/booker-labels.ts | 21 ++++ apps/ui/registry/registry-atoms.ts | 8 ++ 4 files changed, 157 insertions(+), 14 deletions(-) create mode 100644 apps/ui/registry/default/atoms/booker/booker-confirm-form.tsx diff --git a/apps/ui/registry/default/atoms/booker-1.tsx b/apps/ui/registry/default/atoms/booker-1.tsx index ae6da83ab..8f6bdf8fd 100644 --- a/apps/ui/registry/default/atoms/booker-1.tsx +++ b/apps/ui/registry/default/atoms/booker-1.tsx @@ -1,13 +1,12 @@ "use client"; -import { ArrowLeftIcon, Clock3Icon } from "lucide-react"; +import { Clock3Icon } from "lucide-react"; import { cn } from "@/registry/default/lib/utils"; -import { Button } from "@/registry/default/ui/button"; import { Card } from "@/registry/default/ui/card"; -import { Input } from "@/registry/default/ui/input"; import { Skeleton } from "@/registry/default/ui/skeleton"; import { BookerAvatars } from "./booker/booker-avatars"; import { BookerCalendar } from "./booker/booker-calendar"; +import { BookerConfirmForm } from "./booker/booker-confirm-form"; import { BookerErrorState } from "./booker/booker-error-state"; import { type BookerLabels, getBookerLabels } from "./booker/booker-labels"; import { BookerSteps } from "./booker/booker-steps"; @@ -29,7 +28,13 @@ type BookerProps = { labels?: Partial; }; -export function Booker({ initialData, target, timezone, labels }: BookerProps) { +export function Booker({ + initialData, + target, + timezone, + labels, + defaultFormValues, +}: BookerProps) { const t = getBookerLabels(labels); const booker = useBooker({ initialData, target, timezone }); @@ -164,17 +169,21 @@ export function Booker({ initialData, target, timezone, labels }: BookerProps) { ) : (
- -
)} diff --git a/apps/ui/registry/default/atoms/booker/booker-confirm-form.tsx b/apps/ui/registry/default/atoms/booker/booker-confirm-form.tsx new file mode 100644 index 000000000..e164cc648 --- /dev/null +++ b/apps/ui/registry/default/atoms/booker/booker-confirm-form.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { UserPlusIcon } from "lucide-react"; +import { Button } from "@/registry/default/ui/button"; +import { Field, FieldLabel } from "@/registry/default/ui/field"; +import { Form } from "@/registry/default/ui/form"; +import { Input } from "@/registry/default/ui/input"; +import { Textarea } from "@/registry/default/ui/textarea"; +import type { BookerLabels } from "./booker-labels"; + +type BookerConfirmFormProps = { + defaultEmail?: string; + defaultName?: string; + labels: BookerLabels; + onBack: () => void; +}; + +function RequiredMark() { + return *; +} + +export function BookerConfirmForm({ + defaultEmail, + defaultName, + labels, + onBack, +}: BookerConfirmFormProps) { + return ( +
{ + event.preventDefault(); + }} + > + + + {labels.confirmYourName} + + + + + + + {labels.confirmEmail} + + + + + + {labels.confirmNotes} +