Web InnoventixFreeCode

Onboarding Setup Wizard

Original · free

multi-step setup wizard

byWeb InnoventixReact + Tailwind
onboardsetupwizardonboarding
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/onboard-setup-wizard.json
onboard-setup-wizard.tsx
"use client";

import {
  useCallback,
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type StepId = "workspace" | "focus" | "install" | "invite";
type RegionId = "us-east" | "eu-west" | "ap-south";
type FrameworkId = "next" | "vite" | "node";
type TeamSizeId = "solo" | "small" | "mid" | "large";

type StepMeta = {
  id: StepId;
  label: string;
  title: string;
  blurb: string;
};

const STEPS: StepMeta[] = [
  {
    id: "workspace",
    label: "Workspace",
    title: "Name your workspace",
    blurb:
      "This is what teammates see in the sidebar switcher and in every shared link. You can rename it later without breaking anything.",
  },
  {
    id: "focus",
    label: "Sources",
    title: "Choose what Arcline watches",
    blurb:
      "Pick the surfaces you want traced first. Each one turns on a default dashboard — adding more later takes one toggle.",
  },
  {
    id: "install",
    label: "Install",
    title: "Drop in the SDK",
    blurb:
      "One provider, one key. The first events usually land in under thirty seconds of your next deploy.",
  },
  {
    id: "invite",
    label: "Invite",
    title: "Bring in your team",
    blurb:
      "Invitations expire after seven days. Everyone starts as an Editor and you can change roles from Settings → People.",
  },
];

const REGIONS: { id: RegionId; name: string; detail: string; latency: string }[] = [
  { id: "us-east", name: "US East", detail: "Ashburn, Virginia", latency: "~18 ms" },
  { id: "eu-west", name: "EU West", detail: "Dublin, Ireland", latency: "~24 ms" },
  { id: "ap-south", name: "AP South", detail: "Mumbai, India", latency: "~31 ms" },
];

const SOURCES: { id: string; name: string; detail: string }[] = [
  { id: "web", name: "Web app", detail: "Page views, route changes, client errors" },
  { id: "mobile", name: "Mobile", detail: "iOS and Android session traces" },
  { id: "api", name: "Backend API", detail: "Request spans, latency, status codes" },
  { id: "jobs", name: "Background jobs", detail: "Queue depth, retries, failures" },
  { id: "warehouse", name: "Warehouse sync", detail: "Nightly Postgres or BigQuery pull" },
];

const TEAM_SIZES: { id: TeamSizeId; label: string }[] = [
  { id: "solo", label: "Just me" },
  { id: "small", label: "2–9" },
  { id: "mid", label: "10–49" },
  { id: "large", label: "50+" },
];

const FRAMEWORKS: { id: FrameworkId; label: string }[] = [
  { id: "next", label: "Next.js" },
  { id: "vite", label: "Vite" },
  { id: "node", label: "Node" },
];

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;

function slugify(value: string): string {
  return value
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "")
    .slice(0, 32);
}

function snippetFor(framework: FrameworkId, region: RegionId): string {
  if (framework === "next") {
    return [
      "// app/providers.tsx",
      '"use client";',
      'import { ArclineProvider } from "@arcline/next";',
      "",
      "export function Providers({ children }: { children: React.ReactNode }) {",
      "  return (",
      "    <ArclineProvider",
      "      apiKey={process.env.NEXT_PUBLIC_ARCLINE_KEY}",
      '      region="' + region + '"',
      "    >",
      "      {children}",
      "    </ArclineProvider>",
      "  );",
      "}",
    ].join("\n");
  }
  if (framework === "vite") {
    return [
      "// src/main.tsx",
      'import { createArcline } from "@arcline/browser";',
      "",
      "createArcline({",
      "  key: import.meta.env.VITE_ARCLINE_KEY,",
      '  region: "' + region + '",',
      "  flushEvery: 2000,",
      "});",
    ].join("\n");
  }
  return [
    "// server/instrument.ts",
    'import { Arcline } from "@arcline/node";',
    "",
    "export const arcline = new Arcline({",
    "  key: process.env.ARCLINE_KEY,",
    '  region: "' + region + '",',
    '  service: "checkout-api",',
    "});",
  ].join("\n");
}

function CheckIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
      <path
        d="M4.5 10.5 8 14l7.5-8"
        stroke="currentColor"
        strokeWidth="2"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function ArrowIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
      <path
        d="M4 10h12m0 0-4.5-4.5M16 10l-4.5 4.5"
        stroke="currentColor"
        strokeWidth="1.75"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function CopyIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
      <rect
        x="7"
        y="7"
        width="9"
        height="9"
        rx="2"
        stroke="currentColor"
        strokeWidth="1.5"
      />
      <path
        d="M13 5.5A1.5 1.5 0 0 0 11.5 4h-6A1.5 1.5 0 0 0 4 5.5v6A1.5 1.5 0 0 0 5.5 13"
        stroke="currentColor"
        strokeWidth="1.5"
        strokeLinecap="round"
      />
    </svg>
  );
}

function CloseIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
      <path
        d="m6 6 8 8M14 6l-8 8"
        stroke="currentColor"
        strokeWidth="1.75"
        strokeLinecap="round"
      />
    </svg>
  );
}

export default function OnboardSetupWizard() {
  const uid = useId();
  const reduce = useReducedMotion();

  const [index, setIndex] = useState<number>(0);
  const [furthest, setFurthest] = useState<number>(0);
  const [direction, setDirection] = useState<number>(1);
  const [finished, setFinished] = useState<boolean>(false);
  const [showErrors, setShowErrors] = useState<boolean>(false);

  const [name, setName] = useState<string>("");
  const [slug, setSlug] = useState<string>("");
  const [slugTouched, setSlugTouched] = useState<boolean>(false);
  const [region, setRegion] = useState<RegionId>("us-east");

  const [sources, setSources] = useState<string[]>(["web"]);
  const [teamSize, setTeamSize] = useState<TeamSizeId>("small");

  const [framework, setFramework] = useState<FrameworkId>("next");
  const [copied, setCopied] = useState<boolean>(false);
  const [installed, setInstalled] = useState<boolean>(false);

  const [emails, setEmails] = useState<string[]>([]);
  const [draft, setDraft] = useState<string>("");
  const [draftError, setDraftError] = useState<string>("");

  const headingRef = useRef<HTMLHeadingElement | null>(null);
  const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
  const copyTimer = useRef<number | null>(null);
  const mounted = useRef<boolean>(false);

  const step = STEPS[index];
  const snippet = useMemo(() => snippetFor(framework, region), [framework, region]);

  const errors = useMemo<Record<StepId, string>>(() => {
    const nameError =
      name.trim().length === 0
        ? "Give the workspace a name so your team can find it."
        : name.trim().length < 3
          ? "Use at least three characters."
          : "";
    const slugError =
      slug.length === 0
        ? "A URL needs at least one character."
        : !/^[a-z0-9-]+$/.test(slug)
          ? "Lowercase letters, numbers and hyphens only."
          : "";
    return {
      workspace: nameError || slugError,
      focus: sources.length === 0 ? "Pick at least one source to trace." : "",
      install: installed ? "" : "Confirm the snippet is in your codebase to continue.",
      invite: "",
    };
  }, [name, slug, sources, installed]);

  const currentError = errors[step.id];

  useEffect(() => {
    if (!mounted.current) {
      mounted.current = true;
      return;
    }
    headingRef.current?.focus();
  }, [index, finished]);

  useEffect(() => {
    return () => {
      if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
    };
  }, []);

  const handleName = useCallback(
    (value: string) => {
      setName(value);
      if (!slugTouched) setSlug(slugify(value));
    },
    [slugTouched],
  );

  const toggleSource = useCallback((id: string) => {
    setSources((prev) =>
      prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id],
    );
  }, []);

  const goTo = useCallback(
    (next: number) => {
      setDirection(next > index ? 1 : -1);
      setIndex(next);
      setShowErrors(false);
      setFurthest((f) => Math.max(f, next));
    },
    [index],
  );

  const goNext = useCallback(() => {
    if (currentError) {
      setShowErrors(true);
      return;
    }
    if (index === STEPS.length - 1) {
      setDirection(1);
      setFinished(true);
      return;
    }
    goTo(index + 1);
  }, [currentError, index, goTo]);

  const goBack = useCallback(() => {
    if (index > 0) goTo(index - 1);
  }, [index, goTo]);

  const copySnippet = useCallback(async () => {
    try {
      await navigator.clipboard.writeText(snippet);
    } catch {
      // clipboard blocked — the snippet stays selectable in the <pre>
    }
    setCopied(true);
    if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
    copyTimer.current = window.setTimeout(() => setCopied(false), 2000);
  }, [snippet]);

  const addEmail = useCallback(() => {
    const value = draft.trim().replace(/,$/, "");
    if (value.length === 0) return;
    if (!EMAIL_RE.test(value)) {
      setDraftError(`"${value}" isn't a valid email address.`);
      return;
    }
    if (emails.includes(value.toLowerCase())) {
      setDraftError("That address is already on the list.");
      return;
    }
    setEmails((prev) => [...prev, value.toLowerCase()]);
    setDraft("");
    setDraftError("");
  }, [draft, emails]);

  const onDraftKeyDown = useCallback(
    (event: KeyboardEvent<HTMLInputElement>) => {
      if (event.key === "Enter" || event.key === "," || event.key === "Tab") {
        if (event.key === "Tab" && draft.trim().length === 0) return;
        event.preventDefault();
        addEmail();
        return;
      }
      if (event.key === "Backspace" && draft.length === 0 && emails.length > 0) {
        setEmails((prev) => prev.slice(0, -1));
      }
    },
    [addEmail, draft, emails.length],
  );

  const onTabKeyDown = useCallback(
    (event: KeyboardEvent<HTMLButtonElement>, position: number) => {
      const last = FRAMEWORKS.length - 1;
      let next = -1;
      if (event.key === "ArrowRight") next = position === last ? 0 : position + 1;
      if (event.key === "ArrowLeft") next = position === 0 ? last : position - 1;
      if (event.key === "Home") next = 0;
      if (event.key === "End") next = last;
      if (next < 0) return;
      event.preventDefault();
      setFramework(FRAMEWORKS[next].id);
      tabRefs.current[next]?.focus();
    },
    [],
  );

  const restart = useCallback(() => {
    setFinished(false);
    setIndex(0);
    setFurthest(0);
    setDirection(-1);
    setShowErrors(false);
    setName("");
    setSlug("");
    setSlugTouched(false);
    setRegion("us-east");
    setSources(["web"]);
    setTeamSize("small");
    setFramework("next");
    setInstalled(false);
    setEmails([]);
    setDraft("");
    setDraftError("");
  }, []);

  const percent = finished ? 100 : Math.round((index / STEPS.length) * 100);

  const fieldBase =
    "w-full rounded-xl border border-slate-300 bg-white px-3.5 py-2.5 text-sm text-slate-900 outline-none transition placeholder:text-slate-400 focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-indigo-500/40 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 dark:placeholder:text-slate-500 dark:focus-visible:border-indigo-400 dark:focus-visible:ring-indigo-400/40";
  const cardBase =
    "relative flex cursor-pointer gap-3 rounded-xl border border-slate-200 bg-white p-3.5 text-left transition peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500/50 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white hover:border-slate-300 dark:border-slate-800 dark:bg-slate-950 dark:peer-focus-visible:ring-indigo-400/50 dark:peer-focus-visible:ring-offset-slate-950 dark:hover:border-slate-700";
  const ghostBtn =
    "inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium text-slate-600 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:pointer-events-none disabled:opacity-40 dark:text-slate-300 dark:hover:bg-slate-800 dark:focus-visible:ring-slate-500 dark:focus-visible:ring-offset-slate-950";
  const primaryBtn =
    "inline-flex items-center justify-center gap-2 rounded-xl bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-950";

  const panelVariants = {
    enter: (dir: number) => ({ opacity: 0, x: reduce ? 0 : dir * 24 }),
    center: { opacity: 1, x: 0 },
    exit: (dir: number) => ({ opacity: 0, x: reduce ? 0 : dir * -24 }),
  };

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 sm:px-6 sm:py-24 dark:bg-slate-950">
      <style>{`
        @keyframes oswz-sheen {
          0% { transform: translateX(-120%); }
          100% { transform: translateX(320%); }
        }
        @keyframes oswz-pop {
          0% { transform: scale(0.6); opacity: 0; }
          60% { transform: scale(1.08); opacity: 1; }
          100% { transform: scale(1); opacity: 1; }
        }
        @keyframes oswz-halo {
          0%, 100% { opacity: 0.45; transform: scale(1); }
          50% { opacity: 0.85; transform: scale(1.06); }
        }
        .oswz-sheen { animation: oswz-sheen 2.4s ease-in-out infinite; }
        .oswz-pop { animation: oswz-pop 420ms cubic-bezier(0.22, 1, 0.36, 1) both; }
        .oswz-halo { animation: oswz-halo 3.6s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .oswz-sheen, .oswz-pop, .oswz-halo { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-4xl">
        <header className="mb-8 text-center">
          <p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
            Arcline setup
          </p>
          <h2 className="mt-2 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
            Four steps to your first trace
          </h2>
          <p className="mx-auto mt-3 max-w-xl text-sm leading-relaxed text-slate-600 dark:text-slate-400">
            Most teams finish this in about six minutes. Nothing here is permanent —
            every choice is editable from Settings afterwards.
          </p>
        </header>

        <div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
          <div className="border-b border-slate-200 px-5 pt-5 sm:px-8 sm:pt-7 dark:border-slate-800">
            <ol className="flex flex-wrap items-center gap-x-2 gap-y-3">
              {STEPS.map((s, i) => {
                const complete = finished || i < furthest || (i < index);
                const active = !finished && i === index;
                const reachable = i <= furthest && !finished;
                return (
                  <li key={s.id} className="flex items-center gap-2">
                    <button
                      type="button"
                      onClick={() => reachable && goTo(i)}
                      disabled={!reachable}
                      aria-current={active ? "step" : undefined}
                      className={[
                        "group inline-flex items-center gap-2 rounded-full py-1.5 pl-1.5 pr-3 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-900",
                        active
                          ? "bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-300"
                          : reachable || finished
                            ? "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
                            : "cursor-default text-slate-400 dark:text-slate-600",
                      ].join(" ")}
                    >
                      <span
                        aria-hidden="true"
                        className={[
                          "flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold transition",
                          complete
                            ? "bg-emerald-500 text-white"
                            : active
                              ? "bg-indigo-600 text-white"
                              : "bg-slate-200 text-slate-500 dark:bg-slate-800 dark:text-slate-500",
                        ].join(" ")}
                      >
                        {complete ? <CheckIcon className="h-3.5 w-3.5" /> : i + 1}
                      </span>
                      {s.label}
                      <span className="sr-only">
                        {complete ? " (completed)" : active ? " (current step)" : " (locked)"}
                      </span>
                    </button>
                    {i < STEPS.length - 1 ? (
                      <span
                        aria-hidden="true"
                        className="hidden h-px w-6 bg-slate-200 sm:block dark:bg-slate-800"
                      />
                    ) : null}
                  </li>
                );
              })}
            </ol>

            <div
              className="relative mt-5 h-1 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800"
              role="progressbar"
              aria-valuenow={percent}
              aria-valuemin={0}
              aria-valuemax={100}
              aria-label="Setup progress"
            >
              <motion.div
                className="relative h-full rounded-full bg-indigo-600 dark:bg-indigo-500"
                initial={false}
                animate={{ width: `${percent}%` }}
                transition={
                  reduce ? { duration: 0 } : { type: "spring", stiffness: 220, damping: 30 }
                }
              >
                <span className="oswz-sheen absolute inset-y-0 left-0 w-8 bg-gradient-to-r from-transparent via-white/60 to-transparent" />
              </motion.div>
            </div>
            <div className="mt-2 pb-4 text-xs text-slate-500 dark:text-slate-500">
              {finished ? "Setup complete" : `Step ${index + 1} of ${STEPS.length}`}
            </div>
          </div>

          <div aria-live="polite" className="sr-only">
            {finished
              ? "Setup complete."
              : `Step ${index + 1} of ${STEPS.length}: ${step.title}`}
          </div>

          <div className="px-5 py-7 sm:px-8 sm:py-9">
            <AnimatePresence mode="wait" custom={direction} initial={false}>
              <motion.div
                key={finished ? "done" : step.id}
                custom={direction}
                variants={panelVariants}
                initial="enter"
                animate="center"
                exit="exit"
                transition={reduce ? { duration: 0 } : { duration: 0.24, ease: "easeOut" }}
              >
                {finished ? (
                  <div className="text-center">
                    <div className="relative mx-auto mb-5 h-14 w-14">
                      <span className="oswz-halo absolute inset-0 rounded-full bg-emerald-400/30" />
                      <span className="oswz-pop relative flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500 text-white">
                        <CheckIcon className="h-7 w-7" />
                      </span>
                    </div>
                    <h3
                      ref={headingRef}
                      tabIndex={-1}
                      className="text-2xl font-semibold tracking-tight text-slate-900 outline-none dark:text-white"
                    >
                      {name.trim()} is live
                    </h3>
                    <p className="mx-auto mt-2 max-w-md text-sm leading-relaxed text-slate-600 dark:text-slate-400">
                      Deploy once and the first spans appear on your overview dashboard.
                      If nothing shows in five minutes, check that the API key matches
                      the {REGIONS.find((r) => r.id === region)?.name} region.
                    </p>

                    <dl className="mx-auto mt-7 grid max-w-lg gap-px overflow-hidden rounded-xl border border-slate-200 bg-slate-200 text-left sm:grid-cols-2 dark:border-slate-800 dark:bg-slate-800">
                      <div className="bg-white p-4 dark:bg-slate-900">
                        <dt className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-500">
                          Workspace URL
                        </dt>
                        <dd className="mt-1 truncate font-mono text-sm text-slate-900 dark:text-slate-100">
                          {slug}.arcline.dev
                        </dd>
                      </div>
                      <div className="bg-white p-4 dark:bg-slate-900">
                        <dt className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-500">
                          Region
                        </dt>
                        <dd className="mt-1 text-sm text-slate-900 dark:text-slate-100">
                          {REGIONS.find((r) => r.id === region)?.name}
                        </dd>
                      </div>
                      <div className="bg-white p-4 dark:bg-slate-900">
                        <dt className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-500">
                          Sources traced
                        </dt>
                        <dd className="mt-1 text-sm text-slate-900 dark:text-slate-100">
                          {sources
                            .map((id) => SOURCES.find((s) => s.id === id)?.name)
                            .filter(Boolean)
                            .join(", ")}
                        </dd>
                      </div>
                      <div className="bg-white p-4 dark:bg-slate-900">
                        <dt className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-500">
                          Invites queued
                        </dt>
                        <dd className="mt-1 text-sm text-slate-900 dark:text-slate-100">
                          {emails.length === 0
                            ? "None — you're flying solo"
                            : `${emails.length} ${emails.length === 1 ? "person" : "people"}`}
                        </dd>
                      </div>
                    </dl>

                    <button type="button" onClick={restart} className={`${ghostBtn} mt-7`}>
                      Start over
                    </button>
                  </div>
                ) : (
                  <div>
                    <h3
                      ref={headingRef}
                      tabIndex={-1}
                      className="text-xl font-semibold tracking-tight text-slate-900 outline-none sm:text-2xl dark:text-white"
                    >
                      {step.title}
                    </h3>
                    <p className="mt-2 max-w-2xl text-sm leading-relaxed text-slate-600 dark:text-slate-400">
                      {step.blurb}
                    </p>

                    <div className="mt-7">
                      {step.id === "workspace" ? (
                        <div className="grid gap-5">
                          <div>
                            <label
                              htmlFor={`${uid}-name`}
                              className="mb-1.5 block text-sm font-medium text-slate-900 dark:text-slate-200"
                            >
                              Workspace name
                            </label>
                            <input
                              id={`${uid}-name`}
                              value={name}
                              onChange={(e) => handleName(e.target.value)}
                              placeholder="Northwind Payments"
                              autoComplete="off"
                              spellCheck={false}
                              aria-invalid={showErrors && errors.workspace.length > 0}
                              aria-describedby={`${uid}-name-hint`}
                              className={fieldBase}
                            />
                            <p
                              id={`${uid}-name-hint`}
                              className="mt-1.5 text-xs text-slate-500 dark:text-slate-500"
                            >
                              Usually the company or the product it belongs to.
                            </p>
                          </div>

                          <div>
                            <label
                              htmlFor={`${uid}-slug`}
                              className="mb-1.5 block text-sm font-medium text-slate-900 dark:text-slate-200"
                            >
                              Workspace URL
                            </label>
                            <div className="flex items-stretch overflow-hidden rounded-xl border border-slate-300 bg-white focus-within:border-indigo-500 focus-within:ring-2 focus-within:ring-indigo-500/40 dark:border-slate-700 dark:bg-slate-950 dark:focus-within:border-indigo-400 dark:focus-within:ring-indigo-400/40">
                              <input
                                id={`${uid}-slug`}
                                value={slug}
                                onChange={(e) => {
                                  setSlugTouched(true);
                                  setSlug(slugify(e.target.value));
                                }}
                                placeholder="northwind"
                                autoComplete="off"
                                spellCheck={false}
                                aria-describedby={`${uid}-slug-hint`}
                                className="min-w-0 flex-1 bg-transparent px-3.5 py-2.5 font-mono text-sm text-slate-900 outline-none placeholder:text-slate-400 dark:text-slate-100 dark:placeholder:text-slate-600"
                              />
                              <span
                                aria-hidden="true"
                                className="flex shrink-0 items-center border-l border-slate-200 bg-slate-50 px-3 font-mono text-sm text-slate-500 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-500"
                              >
                                .arcline.dev
                              </span>
                            </div>
                            <p
                              id={`${uid}-slug-hint`}
                              className="mt-1.5 text-xs text-slate-500 dark:text-slate-500"
                            >
                              Auto-filled from the name. Edit it and we&apos;ll stop
                              overwriting your version.
                            </p>
                          </div>

                          <fieldset>
                            <legend className="mb-2 text-sm font-medium text-slate-900 dark:text-slate-200">
                              Data region
                            </legend>
                            <div className="grid gap-2.5 sm:grid-cols-3">
                              {REGIONS.map((r) => (
                                <label key={r.id} className="block">
                                  <input
                                    type="radio"
                                    name={`${uid}-region`}
                                    value={r.id}
                                    checked={region === r.id}
                                    onChange={() => setRegion(r.id)}
                                    className="peer sr-only"
                                  />
                                  <span
                                    className={`${cardBase} flex-col ${
                                      region === r.id
                                        ? "border-indigo-500 bg-indigo-50/60 dark:border-indigo-400 dark:bg-indigo-500/10"
                                        : ""
                                    }`}
                                  >
                                    <span className="text-sm font-medium text-slate-900 dark:text-slate-100">
                                      {r.name}
                                    </span>
                                    <span className="text-xs text-slate-500 dark:text-slate-400">
                                      {r.detail}
                                    </span>
                                    <span className="mt-1 font-mono text-[11px] text-emerald-600 dark:text-emerald-400">
                                      {r.latency} p50
                                    </span>
                                  </span>
                                </label>
                              ))}
                            </div>
                            <p className="mt-2 text-xs text-slate-500 dark:text-slate-500">
                              Events stay in the region you pick. Moving a workspace later
                              means a re-ingest, so choose the one closest to your users.
                            </p>
                          </fieldset>
                        </div>
                      ) : null}

                      {step.id === "focus" ? (
                        <div className="grid gap-6">
                          <fieldset>
                            <legend className="mb-2 text-sm font-medium text-slate-900 dark:text-slate-200">
                              Sources to trace
                              <span className="ml-2 font-normal text-slate-500 dark:text-slate-500">
                                {sources.length} selected
                              </span>
                            </legend>
                            <div className="grid gap-2.5 sm:grid-cols-2">
                              {SOURCES.map((s) => {
                                const on = sources.includes(s.id);
                                return (
                                  <label key={s.id} className="block">
                                    <input
                                      type="checkbox"
                                      checked={on}
                                      onChange={() => toggleSource(s.id)}
                                      className="peer sr-only"
                                    />
                                    <span
                                      className={`${cardBase} items-start ${
                                        on
                                          ? "border-indigo-500 bg-indigo-50/60 dark:border-indigo-400 dark:bg-indigo-500/10"
                                          : ""
                                      }`}
                                    >
                                      <span
                                        aria-hidden="true"
                                        className={[
                                          "mt-0.5 flex shrink-0 items-center justify-center rounded-[5px] border transition",
                                          on
                                            ? "border-indigo-600 bg-indigo-600 text-white dark:border-indigo-500 dark:bg-indigo-500"
                                            : "border-slate-300 bg-white dark:border-slate-600 dark:bg-slate-900",
                                        ].join(" ")}
                                        style={{ height: "1.125rem", width: "1.125rem" }}
                                      >
                                        {on ? <CheckIcon className="h-3 w-3" /> : null}
                                      </span>
                                      <span className="min-w-0">
                                        <span className="block text-sm font-medium text-slate-900 dark:text-slate-100">
                                          {s.name}
                                        </span>
                                        <span className="mt-0.5 block text-xs leading-relaxed text-slate-500 dark:text-slate-400">
                                          {s.detail}
                                        </span>
                                      </span>
                                    </span>
                                  </label>
                                );
                              })}
                            </div>
                          </fieldset>

                          <fieldset>
                            <legend className="mb-2 text-sm font-medium text-slate-900 dark:text-slate-200">
                              How many engineers will use this?
                            </legend>
                            <div className="inline-flex flex-wrap gap-1.5 rounded-xl bg-slate-100 p-1 dark:bg-slate-800/70">
                              {TEAM_SIZES.map((t) => (
                                <label key={t.id} className="block">
                                  <input
                                    type="radio"
                                    name={`${uid}-team`}
                                    value={t.id}
                                    checked={teamSize === t.id}
                                    onChange={() => setTeamSize(t.id)}
                                    className="peer sr-only"
                                  />
                                  <span
                                    className={[
                                      "block cursor-pointer rounded-lg px-3.5 py-1.5 text-sm font-medium transition peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-slate-100 dark:peer-focus-visible:ring-indigo-400 dark:peer-focus-visible:ring-offset-slate-800",
                                      teamSize === t.id
                                        ? "bg-white text-slate-900 shadow-sm dark:bg-slate-950 dark:text-white"
                                        : "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200",
                                    ].join(" ")}
                                  >
                                    {t.label}
                                  </span>
                                </label>
                              ))}
                            </div>
                            <p className="mt-2 text-xs text-slate-500 dark:text-slate-500">
                              This only sets your default sampling rate — {teamSize === "solo" ? "10%" : teamSize === "small" ? "25%" : teamSize === "mid" ? "50%" : "100%"} of
                              traces retained for 30 days.
                            </p>
                          </fieldset>
                        </div>
                      ) : null}

                      {step.id === "install" ? (
                        <div className="grid gap-4">
                          <div
                            role="tablist"
                            aria-label="Framework"
                            className="inline-flex w-fit gap-1 rounded-xl bg-slate-100 p-1 dark:bg-slate-800/70"
                          >
                            {FRAMEWORKS.map((f, i) => (
                              <button
                                key={f.id}
                                type="button"
                                role="tab"
                                id={`${uid}-tab-${f.id}`}
                                aria-selected={framework === f.id}
                                aria-controls={`${uid}-tabpanel`}
                                tabIndex={framework === f.id ? 0 : -1}
                                ref={(el) => {
                                  tabRefs.current[i] = el;
                                }}
                                onClick={() => setFramework(f.id)}
                                onKeyDown={(e) => onTabKeyDown(e, i)}
                                className={[
                                  "rounded-lg px-3.5 py-1.5 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-800",
                                  framework === f.id
                                    ? "bg-white text-slate-900 shadow-sm dark:bg-slate-950 dark:text-white"
                                    : "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200",
                                ].join(" ")}
                              >
                                {f.label}
                              </button>
                            ))}
                          </div>

                          <div
                            id={`${uid}-tabpanel`}
                            role="tabpanel"
                            aria-labelledby={`${uid}-tab-${framework}`}
                            tabIndex={0}
                            className="relative overflow-hidden rounded-xl border border-slate-800 bg-slate-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400"
                          >
                            <div className="flex items-center justify-between border-b border-slate-800 px-4 py-2">
                              <span className="font-mono text-xs text-slate-500">
                                npm i @arcline/
                                {framework === "next"
                                  ? "next"
                                  : framework === "vite"
                                    ? "browser"
                                    : "node"}
                              </span>
                              <button
                                type="button"
                                onClick={copySnippet}
                                className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs font-medium text-slate-300 transition hover:bg-slate-800 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400"
                              >
                                {copied ? (
                                  <CheckIcon className="h-3.5 w-3.5 text-emerald-400" />
                                ) : (
                                  <CopyIcon className="h-3.5 w-3.5" />
                                )}
                                {copied ? "Copied" : "Copy"}
                                <span className="sr-only"> code snippet</span>
                              </button>
                            </div>
                            <pre className="overflow-x-auto px-4 py-4 text-[13px] leading-relaxed text-slate-200">
                              <code>{snippet}</code>
                            </pre>
                          </div>

                          <label className="block">
                            <input
                              type="checkbox"
                              checked={installed}
                              onChange={(e) => {
                                setInstalled(e.target.checked);
                                if (e.target.checked) setShowErrors(false);
                              }}
                              aria-describedby={`${uid}-install-note`}
                              className="peer sr-only"
                            />
                            <span
                              className={`${cardBase} items-center ${
                                installed
                                  ? "border-emerald-500 bg-emerald-50/60 dark:border-emerald-500/60 dark:bg-emerald-500/10"
                                  : ""
                              }`}
                            >
                              <span
                                aria-hidden="true"
                                className={[
                                  "flex shrink-0 items-center justify-center rounded-[5px] border transition",
                                  installed
                                    ? "border-emerald-600 bg-emerald-600 text-white dark:border-emerald-500 dark:bg-emerald-500"
                                    : "border-slate-300 bg-white dark:border-slate-600 dark:bg-slate-900",
                                ].join(" ")}
                                style={{ height: "1.125rem", width: "1.125rem" }}
                              >
                                {installed ? <CheckIcon className="h-3 w-3" /> : null}
                              </span>
                              <span className="text-sm text-slate-900 dark:text-slate-100">
                                The snippet is committed to my codebase
                              </span>
                            </span>
                          </label>
                          <p
                            id={`${uid}-install-note`}
                            className="text-xs text-slate-500 dark:text-slate-500"
                          >
                            Paste the key into your environment file as well — the SDK
                            throws at boot if it&apos;s missing rather than failing quietly.
                          </p>
                        </div>
                      ) : null}

                      {step.id === "invite" ? (
                        <div className="grid gap-4">
                          <div>
                            <label
                              htmlFor={`${uid}-email`}
                              className="mb-1.5 block text-sm font-medium text-slate-900 dark:text-slate-200"
                            >
                              Email addresses
                            </label>
                            <div className="flex gap-2">
                              <input
                                id={`${uid}-email`}
                                type="email"
                                value={draft}
                                onChange={(e) => {
                                  setDraft(e.target.value);
                                  if (draftError) setDraftError("");
                                }}
                                onKeyDown={onDraftKeyDown}
                                onBlur={() => {
                                  if (draft.trim().length > 0) addEmail();
                                }}
                                placeholder="dana@northwind.com"
                                autoComplete="off"
                                aria-invalid={draftError.length > 0}
                                aria-describedby={`${uid}-email-hint`}
                                className={fieldBase}
                              />
                              <button
                                type="button"
                                onClick={addEmail}
                                disabled={draft.trim().length === 0}
                                className={`${ghostBtn} shrink-0 border border-slate-300 dark:border-slate-700`}
                              >
                                Add
                              </button>
                            </div>
                            <p
                              id={`${uid}-email-hint`}
                              className="mt-1.5 text-xs text-slate-500 dark:text-slate-500"
                            >
                              Press Enter or comma to add. Backspace on an empty field
                              removes the last one.
                            </p>
                            <p
                              role="status"
                              className={`mt-1.5 text-xs text-rose-600 dark:text-rose-400 ${
                                draftError ? "" : "hidden"
                              }`}
                            >
                              {draftError}
                            </p>
                          </div>

                          <ul className="flex flex-wrap gap-2" aria-label="People to invite">
                            <AnimatePresence initial={false}>
                              {emails.map((email) => (
                                <motion.li
                                  key={email}
                                  layout={!reduce}
                                  initial={reduce ? false : { opacity: 0, scale: 0.85 }}
                                  animate={{ opacity: 1, scale: 1 }}
                                  exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.85 }}
                                  transition={{ duration: 0.16 }}
                                  className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-slate-50 py-1 pl-3 pr-1.5 text-sm text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200"
                                >
                                  <span className="truncate">{email}</span>
                                  <button
                                    type="button"
                                    onClick={() =>
                                      setEmails((prev) => prev.filter((e) => e !== email))
                                    }
                                    className="rounded-full p-0.5 text-slate-400 transition hover:bg-slate-200 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:hover:bg-slate-700 dark:hover:text-white dark:focus-visible:ring-indigo-400"
                                  >
                                    <CloseIcon className="h-3.5 w-3.5" />
                                    <span className="sr-only">Remove {email}</span>
                                  </button>
                                </motion.li>
                              ))}
                            </AnimatePresence>
                            {emails.length === 0 ? (
                              <li className="text-xs text-slate-500 dark:text-slate-500">
                                No one yet — you can skip this and invite from Settings later.
                              </li>
                            ) : null}
                          </ul>
                        </div>
                      ) : null}
                    </div>

                    <p
                      role="alert"
                      className={`mt-6 flex items-center gap-2 text-sm text-rose-600 dark:text-rose-400 ${
                        showErrors && currentError ? "" : "hidden"
                      }`}
                    >
                      <svg
                        viewBox="0 0 20 20"
                        fill="none"
                        aria-hidden="true"
                        className="h-4 w-4 shrink-0"
                      >
                        <circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeWidth="1.5" />
                        <path
                          d="M10 6.5v4"
                          stroke="currentColor"
                          strokeWidth="1.75"
                          strokeLinecap="round"
                        />
                        <circle cx="10" cy="13.5" r="0.9" fill="currentColor" />
                      </svg>
                      {currentError}
                    </p>
                  </div>
                )}
              </motion.div>
            </AnimatePresence>
          </div>

          {!finished ? (
            <div className="flex items-center justify-between gap-3 border-t border-slate-200 bg-slate-50/70 px-5 py-4 sm:px-8 dark:border-slate-800 dark:bg-slate-950/40">
              <button
                type="button"
                onClick={goBack}
                disabled={index === 0}
                className={ghostBtn}
              >
                Back
              </button>
              <div className="flex items-center gap-2">
                {step.id === "invite" ? (
                  <button
                    type="button"
                    onClick={() => {
                      setEmails([]);
                      setDraft("");
                      setDraftError("");
                      setFinished(true);
                    }}
                    className={ghostBtn}
                  >
                    Skip for now
                  </button>
                ) : null}
                <button type="button" onClick={goNext} className={primaryBtn}>
                  {index !== STEPS.length - 1
                    ? "Continue"
                    : emails.length === 0
                      ? "Finish setup"
                      : `Send ${emails.length} ${
                          emails.length === 1 ? "invite" : "invites"
                        } & finish`}
                  <ArrowIcon className="h-4 w-4" />
                </button>
              </div>
            </div>
          ) : null}
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →