import { useT } from "@agent-native/core/client/i18n"; import { ShareButton } from "@agent-native/core/client/sharing"; import { BookingLinkCreateDialog, CustomFieldsEditor as SharedCustomFieldsEditor, SlugEditor, } from "@agent-native/scheduling/react/components"; import { VisibilityBadge } from "@agent-native/toolkit/sharing"; import type { AvailabilityConfig, BookingHost, BookingLink, ConferencingConfig, CustomField, DaySchedule, } from "@shared/api"; import { IconBrandGoogle, IconBrandZoom, IconCalendar, IconChevronLeft, IconChevronRight, IconCircleCheck, IconCopy, IconExternalLink, IconLink, IconDotsVertical, IconPlus, IconTrash, IconUsers, IconVideo, IconVideoOff, IconX, } from "@tabler/icons-react"; import { startOfMonth, endOfMonth, startOfWeek, endOfWeek, eachDayOfInterval, isSameMonth, isToday, isBefore, addDays, addMonths, subMonths, format, startOfDay, getDay, } from "date-fns"; import { nanoid } from "nanoid"; import { useEffect, useMemo, useRef, useState } from "react"; import { Link, useNavigate, useSearchParams } from "react-router"; import { toast } from "sonner"; import { CloudUpgrade } from "@/components/CloudUpgrade"; import { useAppHeaderControls } from "@/components/layout/AppLayout"; import { TimezoneCombobox } from "@/components/TimezoneCombobox"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { useAvailability, useUpdateAvailability, } from "@/hooks/use-availability"; import { useBookingLinks, useCreateBookingLink, useDeleteBookingLink, useUpdateBookingLink, OPTIMISTIC_PREFIX, } from "@/hooks/use-booking-links"; import { useGoogleAuthStatus } from "@/hooks/use-google-auth"; import { useZoomStatus, useConnectZoom } from "@/hooks/use-zoom-auth"; import { copyTextToClipboard } from "@/lib/clipboard"; import { cn } from "@/lib/utils"; import BookingsList from "./BookingsList"; const DURATION_PRESETS = [15, 30, 45, 60]; function slugify(value: string) { return value .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); } const PRODUCTION_DOMAIN = "calendar.agent-native.com"; const PREVIEW_COLLAPSED_STORAGE_KEY = "calendar.bookingLinks.previewCollapsed"; const BRAND_LINK_CLASS = "font-semibold text-[#00B5FF] hover:text-[#33C4FF]"; const BRAND_ICON_LINK_CLASS = "text-[#00B5FF] hover:bg-[#00B5FF]/10 hover:text-[#33C4FF]"; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const BOOKING_SLOT_STEP_MINUTES = 30; type DraftLink = { id?: string; title: string; slug: string; description: string; duration: number; durations: number[]; hosts: BookingHost[]; customFields: CustomField[]; conferencing: ConferencingConfig; isActive: boolean; /** Whether the user has manually edited the slug (vs auto-generated) */ slugManuallyEdited: boolean; }; type DayName = keyof AvailabilityConfig["weeklySchedule"]; type BookingPreviewStep = "duration" | "date" | "time" | "info" | "confirmed"; type BookingPreviewFormValue = { name: string; email: string; notes: string; fieldResponses: Record; }; const DAYS: { key: DayName }[] = [ { key: "monday" }, { key: "tuesday" }, { key: "wednesday" }, { key: "thursday" }, { key: "friday" }, { key: "saturday" }, { key: "sunday" }, ]; const DEFAULT_SCHEDULE: DaySchedule = { enabled: false, slots: [{ start: "09:00", end: "17:00" }], }; type Tab = "links" | "availability" | "bookings"; function createEmptyDraft(): DraftLink { return { title: "", slug: "", description: "", duration: 30, durations: [30], hosts: [], customFields: [], conferencing: { type: "none" }, isActive: true, slugManuallyEdited: false, }; } function draftFromBookingLink(link: BookingLink): DraftLink { const durations = link.durations && link.durations.length > 0 ? link.durations : [link.duration]; const primaryDuration = durations[0] ?? link.duration; return { id: link.id, title: link.title, slug: link.slug, description: link.description || "", duration: primaryDuration, durations, hosts: link.hosts || [], customFields: link.customFields || [], conferencing: link.conferencing || { type: "none" }, isActive: link.isActive, // Always lock the slug for saved links — changing a saved URL would // break existing shared links. Users can still edit the slug manually. slugManuallyEdited: true, }; } function getDraftSignature(draft: DraftLink) { return JSON.stringify({ title: draft.title.trim(), slug: slugify(draft.slug), description: draft.description.trim(), duration: draft.duration, durations: draft.durations, hosts: draft.hosts, customFields: draft.customFields, conferencing: draft.conferencing, isActive: draft.isActive, }); } function normalizeHostEmail(value: string) { const email = value.trim().toLowerCase(); return EMAIL_RE.test(email) ? email : null; } /** Format "09:00" → "9 am", "17:00" → "5 pm" */ function formatTime12(time: string) { const [h, m] = time.split(":").map(Number); const suffix = h >= 12 ? "pm" : "am"; const hour = h % 12 || 12; return m ? `${hour}:${String(m).padStart(2, "0")} ${suffix}` : `${hour} ${suffix}`; } /** Summarize availability, e.g. "Weekdays, 9 am - 5 pm" */ function formatAvailabilitySummary( config: AvailabilityConfig, t: ReturnType, ) { const ws = config.weeklySchedule; const weekdayKeys: DayName[] = [ "monday", "tuesday", "wednesday", "thursday", "friday", ]; const weekendKeys: DayName[] = ["saturday", "sunday"]; const allDays: DayName[] = [...weekdayKeys, ...weekendKeys]; const enabledDays = allDays.filter((d) => ws[d].enabled); if (enabledDays.length === 0) return t("bookingLinks.noAvailabilitySet"); // Determine day label const weekdaysOn = weekdayKeys.every((d) => ws[d].enabled); const weekendsOn = weekendKeys.every((d) => ws[d].enabled); const weekdaysOff = weekdayKeys.every((d) => !ws[d].enabled); const weekendsOff = weekendKeys.every((d) => !ws[d].enabled); let dayLabel: string; if (weekdaysOn && weekendsOn) dayLabel = t("bookingLinks.everyDay"); else if (weekdaysOn && weekendsOff) dayLabel = t("bookingLinks.weekdays"); else if (weekdaysOff && weekendsOn) dayLabel = t("bookingLinks.weekends"); else { const shortNames: Record = { monday: t("bookingLinks.days.mondayShort"), tuesday: t("bookingLinks.days.tuesdayShort"), wednesday: t("bookingLinks.days.wednesdayShort"), thursday: t("bookingLinks.days.thursdayShort"), friday: t("bookingLinks.days.fridayShort"), saturday: t("bookingLinks.days.saturdayShort"), sunday: t("bookingLinks.days.sundayShort"), }; dayLabel = enabledDays.map((d) => shortNames[d]).join(", "); } // Find common time range const slot = ws[enabledDays[0]].slots[0]; if (!slot) return dayLabel; return `${dayLabel}, ${formatTime12(slot.start)} - ${formatTime12(slot.end)}`; } function BookingLinksListSkeleton() { const t = useT(); return (
{Array.from({ length: 4 }).map((_, index) => (
))}
); } type ProviderStatus = "connected" | "disconnected" | "not-configured"; const CONFERENCING_OPTIONS = [ { type: "none", labelKey: "bookingLinks.noConferencing", descriptionKey: "bookingLinks.noConferencingDescription", Icon: IconVideoOff, }, { type: "google_meet", labelKey: "bookingLinks.googleMeet", descriptionKey: "bookingLinks.googleMeetDescription", Icon: IconBrandGoogle, }, { type: "zoom", labelKey: "bookingLinks.zoom", descriptionKey: "bookingLinks.zoomDescription", Icon: IconBrandZoom, }, { type: "custom", labelKey: "bookingLinks.customLink", descriptionKey: "bookingLinks.customLinkDescription", Icon: IconLink, }, ] satisfies Array<{ type: ConferencingConfig["type"]; labelKey: string; descriptionKey: string; Icon: typeof IconVideo; }>; function BookingConferencingSelect({ value, onChange, zoomStatus, googleStatus, onConnectZoom, zoomPending, }: { value: ConferencingConfig; onChange: (next: ConferencingConfig) => void; zoomStatus: ProviderStatus; googleStatus: ProviderStatus; onConnectZoom: () => void; zoomPending: boolean; }) { const t = useT(); const selected = CONFERENCING_OPTIONS.find((option) => option.type === value.type) ?? CONFERENCING_OPTIONS[0]; const SelectedIcon = selected.Icon; return (
{value.type === "zoom" && zoomStatus !== "connected" && (

{t("bookingLinks.connectZoom")}

{zoomStatus === "not-configured" ? t("bookingLinks.zoomMissingCredentials") : t("bookingLinks.zoomConnectAccount")}

)} {value.type === "custom" && (
onChange({ type: "custom", url: e.currentTarget.value }) } placeholder="https://meet.example.com/room" />
)}
); } function BookingHostsEditor({ hosts, onChange, }: { hosts: BookingHost[]; onChange: (hosts: BookingHost[]) => void; }) { const t = useT(); const [input, setInput] = useState(""); function addHosts() { const entries = input .split(/[\s,;]+/) .map((entry) => entry.trim()) .filter(Boolean); if (entries.length === 0) return; const existing = new Set(hosts.map((host) => host.email.toLowerCase())); const next = [...hosts]; const invalid: string[] = []; for (const entry of entries) { const email = normalizeHostEmail(entry); if (!email) { invalid.push(entry); continue; } if (existing.has(email)) continue; existing.add(email); next.push({ email }); } if (invalid.length > 0) { toast.error(t("bookingLinks.invalidEmail", { email: invalid[0] })); } if (next.length !== hosts.length) { onChange(next); setInput(""); } } function removeHost(email: string) { onChange(hosts.filter((host) => host.email !== email)); } return (

{t("bookingLinks.requiredHostsDescription")}

setInput(event.currentTarget.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); addHosts(); } }} placeholder="teammate@example.com" />
{hosts.length > 0 ? (
{hosts.map((host) => ( {host.displayName || host.email} ))}
) : (

{t("bookingLinks.onlyYouRequired")}

)}
); } export default function BookingLinksPage({ selectedId = null, }: { selectedId?: string | null; }) { const t = useT(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const initialTab = (searchParams.get("tab") as Tab) || "links"; const bookingLinksQuery = useBookingLinks(); const { data: bookingLinks = [], isLoading, isError: bookingLinksError, isFetching: bookingLinksFetching, refetch: refetchBookingLinks, } = bookingLinksQuery; const createBookingLink = useCreateBookingLink(); const updateBookingLink = useUpdateBookingLink(); const deleteBookingLink = useDeleteBookingLink(); const [activeTab, setActiveTab] = useState(initialTab); const [draft, setDraft] = useState(() => createEmptyDraft()); const [savedDraftSignature, setSavedDraftSignature] = useState( null, ); const [isPreviewCollapsed, setIsPreviewCollapsed] = useState(() => { if (typeof window === "undefined") return false; return ( window.localStorage.getItem(PREVIEW_COLLAPSED_STORAGE_KEY) === "true" ); }); const [customDurationInput, setCustomDurationInput] = useState(""); const [showCustomDurationInput, setShowCustomDurationInput] = useState(false); const [createDialogOpen, setCreateDialogOpen] = useState(false); // Availability state const { data: availability } = useAvailability(); const updateAvailability = useUpdateAvailability(); const [schedule, setSchedule] = useState< AvailabilityConfig["weeklySchedule"] >({ monday: { ...DEFAULT_SCHEDULE, enabled: true }, tuesday: { ...DEFAULT_SCHEDULE, enabled: true }, wednesday: { ...DEFAULT_SCHEDULE, enabled: true }, thursday: { ...DEFAULT_SCHEDULE, enabled: true }, friday: { ...DEFAULT_SCHEDULE, enabled: true }, saturday: { ...DEFAULT_SCHEDULE }, sunday: { ...DEFAULT_SCHEDULE }, }); const [bufferMinutes, setBufferMinutes] = useState(15); const [minNoticeHours, setMinNoticeHours] = useState(1); const [maxAdvanceDays, setMaxAdvanceDays] = useState(60); const [slotDuration, setSlotDuration] = useState(30); const [bookingSlug, setBookingSlug] = useState("meeting"); const [timezone, setTimezone] = useState("America/New_York"); const [usernameInput, setUsernameInput] = useState(""); const [showCloudUpgrade, setShowCloudUpgrade] = useState(false); const googleStatus = useGoogleAuthStatus(); const zoomStatus = useZoomStatus(); const connectZoom = useConnectZoom(); // Derive a default username from the Google email (e.g. "steve" from "steve@builder.io") const suggestedUsername = useMemo(() => { const email = googleStatus.data?.accounts?.[0]?.email; if (!email) return ""; const local = email.split("@")[0]; // Convert "sewell.steve" → "sewell-steve" return local.replace(/[^a-z0-9]/gi, "-").toLowerCase(); }, [googleStatus.data]); useEffect(() => { if (availability) { setSchedule(availability.weeklySchedule); setBufferMinutes(availability.bufferMinutes); setMinNoticeHours(availability.minNoticeHours); setMaxAdvanceDays(availability.maxAdvanceDays); setSlotDuration(availability.slotDurationMinutes); setBookingSlug(availability.bookingPageSlug); setTimezone(availability.timezone); setUsernameInput(availability.bookingUsername ?? ""); } }, [availability]); useEffect(() => { if (typeof window === "undefined") return; window.localStorage.setItem( PREVIEW_COLLAPSED_STORAGE_KEY, String(isPreviewCollapsed), ); }, [isPreviewCollapsed]); function updateDay(day: DayName, updates: Partial) { setSchedule((prev) => ({ ...prev, [day]: { ...prev[day], ...updates }, })); } function updateDaySlot(day: DayName, field: "start" | "end", value: string) { setSchedule((prev) => ({ ...prev, [day]: { ...prev[day], slots: [{ ...prev[day].slots[0], [field]: value }], }, })); } function handleSaveAvailability() { updateAvailability.mutate( { timezone, weeklySchedule: schedule, bufferMinutes, minNoticeHours, maxAdvanceDays, slotDurationMinutes: slotDuration, bookingPageSlug: bookingSlug, bookingUsername: usernameInput.trim() || undefined, }, { onSuccess: () => toast.success(t("bookingLinks.availabilitySaved")), onError: (error) => toast.error( error instanceof Error ? error.message : t("bookingLinks.availabilitySaveFailed"), ), }, ); } // Navigate back to list if the selected link was deleted useEffect(() => { if ( selectedId && !isLoading && !bookingLinks.some((link) => link.id === selectedId) ) { navigate("/booking-links", { replace: true }); } }, [bookingLinks, selectedId, isLoading, navigate]); const selectedLink = useMemo( () => bookingLinks.find((link) => link.id === selectedId) ?? null, [bookingLinks, selectedId], ); useEffect(() => { if (!selectedLink) { setDraft(createEmptyDraft()); setSavedDraftSignature(null); return; } const nextDraft = draftFromBookingLink(selectedLink); setDraft(nextDraft); setSavedDraftSignature(getDraftSignature(nextDraft)); setCustomDurationInput(""); setShowCustomDurationInput(false); }, [selectedLink?.id, selectedLink?.updatedAt]); const bookingUsername = availability?.bookingUsername; function getBookingUrl(slug: string) { if (bookingUsername) { const host = typeof window !== "undefined" && window.location.hostname !== "localhost" ? window.location.origin : `https://${PRODUCTION_DOMAIN}`; return `${host}/book/${bookingUsername}/${slug}`; } // Fallback for no username set if (typeof window === "undefined") return `/book/${slug}`; return `${window.location.origin}/book/${slug}`; } const previewUrl = getBookingUrl(draft.slug); const createSlugPrefix = useMemo(() => { const host = typeof window !== "undefined" && window.location.hostname !== "localhost" ? window.location.host : PRODUCTION_DOMAIN; const username = bookingUsername || usernameInput || suggestedUsername || "your-name"; return `${host}/book/${username}/`; }, [bookingUsername, usernameInput, suggestedUsername]); const draftSignature = useMemo(() => getDraftSignature(draft), [draft]); const hasUnsavedChanges = !!selectedLink && savedDraftSignature !== null && draftSignature !== savedDraftSignature; function handleCreate() { setCreateDialogOpen(true); } function handleCreateSubmit(input: { title: string; slug: string; length: number; description: string; }) { const title = input.title.trim(); const slug = slugify(input.slug); const duration = input.length; if (!title || !slug || !Number.isFinite(duration)) return; if (duration < 5) { toast.error(t("bookingLinks.durationMinError")); return; } // Pre-generate an optimistic id so we can navigate instantly; the mutation // inserts the row into the list cache synchronously via onMutate. const optimisticId = `optimistic_${nanoid()}`; createBookingLink.mutate( { title, slug, duration, description: input.description.trim() || undefined, isActive: true, optimisticId, }, { onSuccess: (created) => { // Swap URL from optimistic id to the real one without a back-stack entry. navigate(`/booking-links/${created.id}`, { replace: true }); toast.success(t("bookingLinks.bookingLinkCreated")); }, onError: (error) => { // Cache was rolled back by the hook's onError. Bring the user back. navigate("/booking-links", { replace: true }); toast.error( error instanceof Error ? error.message : t("bookingLinks.bookingLinkCreateFailed"), ); }, }, ); // Navigate *immediately* — the optimistic row is already in the list cache. navigate(`/booking-links/${optimisticId}`); setCreateDialogOpen(false); } async function handleSave() { if (!draft.id) return; if (!hasUnsavedChanges) return; // Optimistic row hasn't resolved to a real ID yet — wait for it if (draft.id.startsWith(OPTIMISTIC_PREFIX)) { toast.error(t("bookingLinks.stillCreating")); return; } try { const updated = await updateBookingLink.mutateAsync({ id: draft.id, title: draft.title.trim(), slug: slugify(draft.slug), description: draft.description.trim() || undefined, duration: draft.durations[0] ?? draft.duration, durations: draft.durations.length > 1 ? draft.durations : undefined, hosts: draft.hosts.length > 0 ? draft.hosts : undefined, customFields: draft.customFields.length > 0 ? draft.customFields : undefined, conferencing: draft.conferencing, isActive: draft.isActive, }); const nextDraft = draftFromBookingLink(updated); setDraft(nextDraft); setSavedDraftSignature(getDraftSignature(nextDraft)); toast.success(t("bookingLinks.bookingLinkUpdated")); } catch { toast.error(t("bookingLinks.bookingLinkUpdateFailed")); } } async function handleDelete() { if (!draft.id) return; try { await deleteBookingLink.mutateAsync(draft.id); navigate("/booking-links"); toast.success(t("bookingLinks.bookingLinkDeleted")); } catch { toast.error(t("bookingLinks.bookingLinkDeleteFailed")); } } function addCustomDuration() { const minutes = Number.parseInt(customDurationInput, 10); if (!Number.isFinite(minutes) || minutes < 5 || minutes > 480) { toast.error(t("bookingLinks.durationRangeError")); return; } setDraft((prev) => { const next = Array.from(new Set([...prev.durations, minutes])).sort( (a, b) => a - b, ); return { ...prev, durations: next, duration: next[0] }; }); setCustomDurationInput(""); setShowCustomDurationInput(false); } async function copyPreviewUrl(slug: string) { if (await copyTextToClipboard(getBookingUrl(slug))) { toast.success(t("bookingLinks.bookingLinkCopied")); return; } toast.error(t("common.clipboardUnavailable")); } function bookingPreviewPath(slug: string) { return bookingUsername ? `/book/${bookingUsername}/${slug}` : `/book/${slug}`; } const handleSaveRef = useRef(handleSave); useEffect(() => { handleSaveRef.current = handleSave; }); const detailHeaderControls = useMemo(() => { if (!selectedId) { return { left: (

{t("bookingLinks.title")}

), right: activeTab === "links" ? ( ) : null, }; } return { left: ( {t("bookingLinks.back")} ), right: selectedLink ? (
{!selectedLink.id.startsWith(OPTIMISTIC_PREFIX) && ( )} {t("bookingLinks.copyLink")} {t("bookingLinks.openLink")}
) : null, }; }, [ selectedId, selectedLink, draft.title, draft.slug, previewUrl, updateBookingLink.isPending, hasUnsavedChanges, navigate, activeTab, t, ]); useAppHeaderControls(detailHeaderControls); const hasLinks = bookingLinks.length > 0; // If a link is selected, show the detail/edit view if (selectedId) { if (bookingLinksError && !isLoading) { return (

{t("common.loadFailed")}

); } return (
{/* Two-column layout: form left, preview right */}
{/* Left — Edit form */}
{isLoading ? (
) : selectedLink ? ( <> {/* Title */}
{ const title = e.target.value; setDraft((prev) => ({ ...prev, title, slug: prev.slugManuallyEdited ? prev.slug : slugify(title), })); }} placeholder={t("bookingLinks.quickChat")} />
{/* Description */}