'use client' /** * `` — the embeddable scheduling DIRECTORY * block: clickable row cards (host avatars, title/description, For- * chip, next available time, boxed chevron) + the house * `PersistentPaginationWrapper`, fed by a host proxy's `GET * {apiBaseUrl}/api/meetings` (see `docs/EMBEDDING_HUBSPOT_MEETINGS.md`). * Rows navigate to `{bookingBasePath}/{slug}` — pair with * `` on that page for the full white-label flow. * * Layout stability is engineered, not incidental: the rows area reserves one * FULL page (pageSize × 80px rows + gaps), the pagination slot holds its * measured 72px even when hidden, and the loading state renders same-shell * skeleton rows — so nothing below the block ever jumps across * loading ⇄ loaded ⇄ page changes ⇄ short last pages. * * SSR/host mode: pass `initialData` (host-fetched) to skip the client fetch. * Client/embed mode: omit it — skeleton page + self-fetch via `contentFetch` * (embed-auth adapters inherited for free). */ import { useEffect, useMemo, useState } from 'react' import { ChevronRight } from 'lucide-react' import Link from '../../embed-shims/next-link' import { contentFetch } from '../../utils/embed-content-fetch' import { formatDurationCompact } from '../../utils/format' import { cn } from '../../utils/cn' import { AvatarStack, StatusBadge, Skeleton } from '../ui' import { EmptyState } from '../empty-state' import { PersistentPaginationWrapper } from '../persistent-pagination' import type { SchedulingLink, SchedulingLinksPayload } from '../../schemas/meeting-booking-schema' export interface MeetingSchedulerDirectoryProps { /** Endpoints prefix, default '' (same-origin `/api/meetings`). */ apiBaseUrl?: string /** Host-fetched seed — skips the client fetch when provided. */ initialData?: SchedulingLinksPayload | null /** `scope=all` — full-portal view (non-conforming links under "other"). */ includeAll?: boolean /** Rows per page; also the skeleton row count and the reserved rows-area height. */ pageSize?: number /** Rows link to `${bookingBasePath}/${slug}`. */ bookingBasePath?: string /** EmptyState CTA target for error/empty states (omit → no CTA). */ contactHref?: string className?: string } /** Zone-aware "next available" label — resolved POST-mount (SSR renders the * static caption only, so server and client first paint agree). */ function useNextAvailableLabel(ms: number | null): string | null { const [label, setLabel] = useState(null) useEffect(() => { if (ms == null) return try { setLabel( new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', }).format(new Date(ms)), ) } catch { setLabel(null) } }, [ms]) return label } function DirectoryRow({ link, audienceLabel, bookingBasePath, }: { link: SchedulingLink audienceLabel: string bookingBasePath: string }) { const nextLabel = useNextAvailableLabel(link.nextAvailableMs) const subtitle = [ link.description, link.durationsMinutes.length > 0 ? link.durationsMinutes.map((m) => formatDurationCompact(m * 60)).join(' / ') : null, link.kind === 'team' ? 'Team' : null, ] .filter(Boolean) .join(' • ') return (
{/* Title leads at a fixed X; hosts live in the right meta cluster. */}

{link.title}

{subtitle &&

{subtitle}

}
{/* No audience entity (scope=all "other" group) → no chip. */} {audienceLabel && ( )} {/* Hosts stay visible on mobile too — the facepile is the row's "who you're booking with" signal; only the chip and next-available meta collapse on narrow widths. */}

Next available

{link.nextAvailableMs == null ? 'No times published' : (nextLabel ?? ' ')}

{/* House boxed chevron (ChatTicketItem). */}
) } /** Same-shell loading row (co-located-skeleton convention). */ export function MeetingSchedulerDirectoryRowSkeleton() { return (
) } /** Measured pagination-slot height (PersistentPaginationWrapper = 72px). */ const PAGINATION_SLOT_H = 'h-[4.5rem]' export function MeetingSchedulerDirectory({ apiBaseUrl = '', initialData = null, includeAll = false, pageSize = 6, bookingBasePath = '/schedule-a-call', contactHref, className, }: MeetingSchedulerDirectoryProps) { const [data, setData] = useState(initialData) const [isLoading, setIsLoading] = useState(initialData === null) const [error, setError] = useState(null) const [page, setPage] = useState(1) useEffect(() => { if (initialData !== null) return // host-seeded — skip the client fetch const controller = new AbortController() let active = true const load = async () => { setIsLoading(true) setError(null) try { const qs = includeAll ? '?scope=all' : '' const res = await contentFetch(`${apiBaseUrl}/api/meetings${qs}`, { signal: controller.signal }) if (!res.ok) throw new Error(`meetings ${res.status}`) const payload = (await res.json()) as SchedulingLinksPayload if (active) setData(payload) } catch (err) { if (!active || (err instanceof DOMException && err.name === 'AbortError')) return setError(err instanceof Error ? err.message : 'Failed to fetch scheduling links') } finally { if (active) setIsLoading(false) } } void load() return () => { active = false controller.abort() } }, [initialData, includeAll, apiBaseUrl]) const rows = useMemo(() => { const purposes = data?.purposes ?? [] return purposes.flatMap((p) => p.links.map((link) => ({ link, audienceLabel: p.label }))) }, [data]) const totalPages = Math.max(1, Math.ceil(rows.length / pageSize)) const pageRows = rows.slice((page - 1) * pageSize, page * pageSize) // Reserved rows-area height: pageSize × 80px rows + (pageSize−1) × 16px gaps. const rowsMinHeight = pageSize * 80 + (pageSize - 1) * 16 if (isLoading && !data) { return (
{Array.from({ length: pageSize }, (_, i) => ( ))}
) } if (error) { return (
) } if (rows.length === 0) { return (
) } return (
{pageRows.map(({ link, audienceLabel }) => ( ))}
{/* House pagination block — dims in place during refetches; the slot holds its measured height even at ≤1 page (where the inner UnifiedPagination returns null). */}
{totalPages > 1 && ( )}
) }