"use client"; import type { RouterOutputs } from "@openstatus/api"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Activity, PanelTop, Rocket } from "lucide-react"; import { usePathname } from "next/navigation"; import { useQueryStates } from "nuqs"; import { generateSlug } from "random-word-slugs"; import { useEffect, useMemo, useRef } from "react"; import { OnboardingLayout, OnboardingShell, } from "@/components/layout/onboarding-layout"; import { useStreamChecks } from "@/components/onboarding/use-stream-checks"; import { getCompanyDomainFromEmail } from "@/lib/onboarding/email-domain"; import { useTRPC } from "@/lib/trpc/client"; import { Step1 } from "./_steps/step-1"; import { Step2 } from "./_steps/step-2"; import { Step3 } from "./_steps/step-3"; import { searchParamsParsers } from "./search-params"; export type OnboardingMonitor = Pick< RouterOutputs["monitor"]["list"][number], "id" | "url" >; export type OnboardingPage = Pick< RouterOutputs["page"]["list"][number], "id" | "slug" | "title" >; const STEPS = [ { id: "1", label: "Monitor", icon: }, { id: "2", label: "Status page", icon: }, { id: "3", label: "Launch", icon: }, ] as const; // Fallback URL used when the user's email domain is generic (gmail, yahoo, …). // Keeps the magic-moment frictionless: hit submit on an empty form and a real // probe runs against an openstatus-owned demo target. const DEMO_FALLBACK_URL = "https://openstat.us"; // Convert a hostname to a slug that satisfies the page schema // (`[A-Za-z0-9-]`, min length 3). Caller must pass a hostname — strip URL // scheme/`www` upstream. // // Picks the registrable label (penultimate segment) when present so // `api.mycompany.com` → `mycompany` rather than the generic `api`. function slugifyHostname(hostname: string): string { const labels = hostname .toLowerCase() .replace(/^www\./, "") .split("."); const base = labels.length > 1 ? labels[labels.length - 2] : labels[0]; return base .replace(/[^a-z0-9-]/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, "") .slice(0, 32); } function safeHostname(url: string): string | null { try { return new URL(url).hostname; } catch { return null; } } export function Client() { const [{ step, monitor, page }, setSearchParams] = useQueryStates( searchParamsParsers, { history: "push" }, ); const pathname = usePathname(); const trpc = useTRPC(); const queryClient = useQueryClient(); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const { data: user } = useQuery(trpc.user.get.queryOptions()); // Ground truth for refresh + returning-user state. const { data: monitors } = useQuery(trpc.monitor.list.queryOptions()); const { data: pages } = useQuery(trpc.page.list.queryOptions()); // Invalidate so workspace counts (sidebar quota, plan gates) stay fresh. const createMonitorMutation = useMutation( trpc.monitor.new.mutationOptions({ onSuccess: async () => { await setSearchParams({ monitor: "completed" }); await Promise.all([ queryClient.invalidateQueries({ queryKey: trpc.workspace.get.queryKey(), }), queryClient.invalidateQueries({ queryKey: trpc.monitor.list.queryKey(), }), ]); }, }), ); const createPageMutation = useMutation( trpc.page.create.mutationOptions({ onSuccess: async () => { await setSearchParams({ page: "completed" }); await Promise.all([ queryClient.invalidateQueries({ queryKey: trpc.workspace.get.queryKey(), }), queryClient.invalidateQueries({ queryKey: trpc.page.list.queryKey(), }), ]); }, }), ); const updateComponentsMutation = useMutation( trpc.pageComponent.updateOrder.mutationOptions({}), ); const createFeedbackMutation = useMutation( trpc.feedback.submit.mutationOptions({}), ); const { results: checkResults, isStreaming, start: startChecks, stop: stopChecks, } = useStreamChecks(); const checksStartedRef = useRef(false); // Fresh mutation data wins; fall back to the most recent server row. const monitorData: OnboardingMonitor | undefined = createMonitorMutation.data ?? (monitors?.[0] ? { id: monitors[0].id, url: monitors[0].url } : undefined); const pageData: OnboardingPage | undefined = createPageMutation.data ?? (pages?.[0] ? { id: pages[0].id, slug: pages[0].slug, title: pages[0].title, } : undefined); const companyDomain = useMemo( () => getCompanyDomainFromEmail(user?.email), [user?.email], ); const defaultUrl = companyDomain ? `https://${companyDomain}` : DEMO_FALLBACK_URL; const slugFallback = useMemo(() => { const candidates: string[] = []; if (monitorData?.url) { try { candidates.push(slugifyHostname(new URL(monitorData.url).hostname)); } catch { // Malformed URL — fall through to other candidates. } } if (companyDomain) candidates.push(slugifyHostname(companyDomain)); const valid = candidates.find((c) => c.length >= 3); if (valid) return valid; return generateSlug(2, { format: "kebab" }); }, [monitorData?.url, companyDomain]); // Auto-fire on step-1 fresh-create + refresh-of-locked. Skip on stale // hydration so returning users don't accidentally re-probe. useEffect(() => { if (!monitorData?.id || step !== "1" || monitor !== "completed") return; if (checksStartedRef.current) return; checksStartedRef.current = true; startChecks(monitorData.id); return () => { stopChecks(); }; }, [monitorData?.id, step, monitor, startChecks, stopChecks]); const currentStepIndex = STEPS.findIndex((s) => s.id === step); const stepperSteps = STEPS.map((s, i) => ({ id: s.id, label: s.label, icon: s.icon, status: step === s.id ? ("current" as const) : currentStepIndex > i ? ("completed" as const) : ("upcoming" as const), })); return ( {step === "1" ? ( { await createMonitorMutation.mutateAsync({ url: values.url, name: safeHostname(values.url) ?? values.url, method: "GET", headers: [], assertions: [], jobType: "http", active: true, }); }} onSkip={() => setSearchParams({ monitor: "skipped", step: "2" })} onContinue={() => { setSearchParams({ step: "2" }); stopChecks(); }} onRetryChecks={() => { if (monitorData?.id) startChecks(monitorData.id); }} /> ) : null} {step === "2" ? ( { if (!workspace?.id) return; const newPage = await createPageMutation.mutateAsync({ slug: values.slug, title: values.slug.replace(/-/g, " "), description: "", monitors: monitorData?.id ? [{ monitorId: monitorData.id, order: 0 }] : [], workspaceId: workspace.id, legacyPage: false, forceTheme: values.forceTheme, configuration: { theme: values.theme }, }); const staticComponents = values.components?.filter( (c) => c.name.trim() !== "", ); if (staticComponents?.length && newPage?.id) { const offset = monitorData?.id ? 1 : 0; await updateComponentsMutation.mutateAsync({ pageId: newPage.id, components: staticComponents.map((c, index) => ({ name: c.name, type: "static" as const, monitorId: null, order: index + offset, })), groups: [], }); } }} onSkip={() => setSearchParams({ page: "skipped", step: "3" })} onContinue={() => setSearchParams({ step: "3" })} /> ) : null} {step === "3" ? ( { await createFeedbackMutation.mutateAsync({ source: "onboarding-source", message: `Heard about us via ${values.source}${ values.other ? `: ${values.other}` : "" }`, path: pathname, }); }} /> ) : null} ); }