'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. */}
{/* House pagination block — dims in place during refetches; the slot
holds its measured height even at ≤1 page (where the inner
UnifiedPagination returns null). */}