SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
13.2 KB · 259 lines tsx
Raw Blame History
1"use client";2import * as React from "react";3import Link from "next/link";4import { useRouter } from "next/navigation";5import { ArrowLeft, ArrowRight, Check, Code2, FolderKanban, KeyRound, Play, Route, ShieldCheck, Zap } from "lucide-react";6import type { CreatedKey } from "@/actions/api-keys";7import { completeOnboarding } from "@/actions/account";8import { Button } from "@/components/ui/button";9import { Badge } from "@/components/ui/badge";10import { Alert } from "@/components/ui/alert";11import { CodeBlock } from "@/components/ui/code-block";12import { cn } from "@/lib/utils";13import { curlSnippet } from "@/lib/account-snippets";14import { CreateKeyForm, type ProjectOption } from "@/components/dashboard/api-keys/create-key-dialog";15import { KeyReveal } from "@/components/dashboard/api-keys/key-reveal";16import { CreateProjectDialog } from "@/components/dashboard/projects/create-project-dialog";1718const STEPS = [19  { id: "welcome", label: "Welcome", icon: Zap },20  { id: "project", label: "Project", icon: FolderKanban },21  { id: "key", label: "API key", icon: KeyRound },22  { id: "request", label: "First request", icon: Play },23  { id: "code", label: "Generated code", icon: Code2 },24] as const;2526export function OnboardingFlow({27  projects,28  currentProject,29  hasKey,30  hasRequest,31  alreadyCompleted,32  userName,33}: {34  projects: ProjectOption[];35  currentProject: ProjectOption;36  hasKey: boolean;37  hasRequest: boolean;38  alreadyCompleted: boolean;39  userName: string;40}) {41  const router = useRouter();42  const [step, setStep] = React.useState(0);43  const [created, setCreated] = React.useState<CreatedKey | null>(null);44  const [finishing, startFinish] = React.useTransition();45  const [error, setError] = React.useState<string | null>(null);4647  const keyReady = hasKey || Boolean(created);48  const apiKeyForSnippet = created?.plaintext ?? "fch_live_YOUR_API_KEY";49  const last = step === STEPS.length - 1;5051  function finish() {52    setError(null);53    startFinish(async () => {54      const res = await completeOnboarding();55      if (!res.ok) {56        setError(res.error);57        return;58      }59      router.push("/dashboard");60      router.refresh();61    });62  }6364  return (65    <div className="grid gap-6">66      {/* Progress */}67      <ol className="grid grid-cols-5 gap-2" aria-label="Onboarding progress">68        {STEPS.map((s, i) => {69          const done = i < step || (i === 1) || (i === 2 && keyReady) || (i === 3 && hasRequest);70          const active = i === step;71          const Icon = s.icon;72          return (73            <li key={s.id} className="grid gap-2">74              <button75                type="button"76                onClick={() => setStep(i)}77                className={cn("flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-[12.5px] font-medium transition-colors hover:bg-bg-muted", active ? "text-fg" : "text-fg-muted")}78                aria-current={active ? "step" : undefined}79              >80                <span className={cn("flex size-6 shrink-0 items-center justify-center rounded-full border text-[11px]", active ? "border-accent bg-accent text-accent-fg" : done && !active ? "border-success/40 bg-success-soft text-success" : "border-border bg-bg")}>81                  {done && !active ? <Check className="size-3.5" /> : <Icon className="size-3.5" />}82                </span>83                <span className="hidden truncate sm:inline">{s.label}</span>84              </button>85              <span className={cn("h-1 rounded-full", i <= step ? "bg-accent" : "bg-bg-muted")} aria-hidden />86            </li>87          );88        })}89      </ol>9091      <div className="rounded-lg border border-border bg-bg-elevated p-6 shadow-xs sm:p-8">92        {alreadyCompleted && step === 0 ? <Alert variant="info" className="mb-6">You already finished onboarding — feel free to walk through it again.</Alert> : null}9394        {step === 0 ? (95          <div className="grid gap-5">96            <div>97              <div className="text-[11.5px] font-medium uppercase tracking-wide text-fg-subtle">Step 1 of 5</div>98              <h2 className="mt-1 text-xl font-semibold tracking-tight">Welcome to Fetcha{userName ? `, ${userName.split(" ")[0]}` : ""}</h2>99              <p className="mt-2 max-w-xl text-[13.5px] leading-relaxed text-fg-muted">One API that fetches any public web page for you, choosing the right network and location automatically, retrying when a target blocks, and returning clean HTML, text or JSON.</p>100            </div>101            <ul className="grid gap-3 sm:grid-cols-3">102              {[103                { icon: Route, title: "Intelligent routing", desc: "Every request is scored on success history, cost, latency, network health and geography, then escalated only when needed." },104                { icon: ShieldCheck, title: "Built-in resilience", desc: "Retries with a fresh IP or alternate network, circuit breakers per domain and sticky sessions when you need the same identity." },105                { icon: Code2, title: "Developer first", desc: "A single POST /v1/fetch, request IDs on every call, a Playground that writes your code, and SDKs for JS and Python." },106              ].map((b) => (107                <li key={b.title} className="rounded-md border border-border bg-bg p-4">108                  <b.icon className="size-4 text-accent" aria-hidden />109                  <div className="mt-2 text-[13.5px] font-medium">{b.title}</div>110                  <div className="mt-1 text-[12.5px] leading-relaxed text-fg-muted">{b.desc}</div>111                </li>112              ))}113            </ul>114          </div>115        ) : null}116117        {step === 1 ? (118          <div className="grid gap-5">119            <div>120              <div className="text-[11.5px] font-medium uppercase tracking-wide text-fg-subtle">Step 2 of 5</div>121              <h2 className="mt-1 text-xl font-semibold tracking-tight">Create your first project</h2>122              <p className="mt-2 max-w-xl text-[13.5px] leading-relaxed text-fg-muted">Projects separate keys, logs, defaults and spending limits. We created one for you — keep it or add another for a different environment.</p>123            </div>124            <div className="flex flex-col gap-3 rounded-md border border-border bg-bg p-4 sm:flex-row sm:items-center">125              <div className="min-w-0 flex-1">126                <div className="flex items-center gap-2">127                  <span className="text-[14px] font-medium">{currentProject.name}</span>128                  <Badge variant={currentProject.environment === "production" ? "success" : "warning"}>{currentProject.environment}</Badge>129                  <Badge variant="accent" dot>130                    Current131                  </Badge>132                </div>133                <div className="mt-0.5 font-mono text-[11.5px] text-fg-subtle">{currentProject.id}</div>134              </div>135              <div className="flex items-center gap-2">136                <Button asChild variant="ghost" size="sm">137                  <Link href={`/dashboard/projects/${currentProject.id}`}>Edit defaults</Link>138                </Button>139                <CreateProjectDialog140                  redirectToDetail={false}141                  trigger={142                    <Button variant="outline" size="sm">143                      New project144                    </Button>145                  }146                />147              </div>148            </div>149            {projects.length > 1 ? <p className="text-[12.5px] text-fg-subtle">You have {projects.length} projects. The current one is used for the key in the next step.</p> : null}150          </div>151        ) : null}152153        {step === 2 ? (154          <div className="grid gap-5">155            <div>156              <div className="text-[11.5px] font-medium uppercase tracking-wide text-fg-subtle">Step 3 of 5</div>157              <h2 className="mt-1 text-xl font-semibold tracking-tight">Generate an API key</h2>158              <p className="mt-2 max-w-xl text-[13.5px] leading-relaxed text-fg-muted">Keys are shown once and stored hashed. This one gets the default scopes for fetching, sessions and usage.</p>159            </div>160            {created ? (161              <KeyReveal plaintext={created.plaintext} name={created.name} compact />162            ) : hasKey ? (163              <Alert164                variant="success"165                title="You already have an active key"166                action={167                  <Button asChild variant="outline" size="xs">168                    <Link href="/dashboard/api-keys">Manage keys</Link>169                  </Button>170                }171              >172                Continue with it, or create another one below for this walkthrough.173              </Alert>174            ) : null}175            {!created ? (176              <div className="rounded-md border border-border bg-bg p-4">177                <CreateKeyForm projects={projects} defaultProjectId={currentProject.id} onCreated={setCreated} compact defaultName="Getting started" submitLabel="Generate key" />178              </div>179            ) : null}180          </div>181        ) : null}182183        {step === 3 ? (184          <div className="grid gap-5">185            <div>186              <div className="text-[11.5px] font-medium uppercase tracking-wide text-fg-subtle">Step 4 of 5</div>187              <h2 className="mt-1 text-xl font-semibold tracking-tight">Run your first request</h2>188              <p className="mt-2 max-w-xl text-[13.5px] leading-relaxed text-fg-muted">189                Paste this in a terminal. {created ? "It already contains the key you just created." : "Replace the placeholder with your key."} The response includes <code className="font-mono text-[12.5px]">request_id</code>, the page content and routing metadata.190              </p>191            </div>192            {hasRequest ? <Alert variant="success">Nice — this organization has already made requests. The Requests page shows every one of them.</Alert> : null}193            <CodeBlock code={curlSnippet(apiKeyForSnippet)} lang="bash" title="terminal" />194            <div className="flex flex-wrap items-center gap-2">195              <Button asChild variant="outline" size="sm">196                <Link href="/dashboard/playground">197                  <Play /> Try it in the Playground198                </Link>199              </Button>200              <Button asChild variant="ghost" size="sm">201                <Link href="/dashboard/requests">View requests</Link>202              </Button>203            </div>204          </div>205        ) : null}206207        {step === 4 ? (208          <div className="grid gap-5">209            <div>210              <div className="text-[11.5px] font-medium uppercase tracking-wide text-fg-subtle">Step 5 of 5</div>211              <h2 className="mt-1 text-xl font-semibold tracking-tight">Get generated code</h2>212              <p className="mt-2 max-w-xl text-[13.5px] leading-relaxed text-fg-muted">The Playground writes the request in cURL, JavaScript, Python, Go, PHP, Ruby, Java and C# as you configure it. Copy it straight into your project.</p>213            </div>214            <div className="grid gap-3 sm:grid-cols-2">215              <Link href="/dashboard/playground?tab=code" className="group rounded-md border border-border bg-bg p-4 transition-colors hover:border-border-strong">216                <Code2 className="size-4 text-accent" aria-hidden />217                <div className="mt-2 flex items-center gap-1 text-[13.5px] font-medium">218                  Playground · Code tab <ArrowRight className="size-3.5 transition-transform group-hover:translate-x-0.5" />219                </div>220                <div className="mt-1 text-[12.5px] text-fg-muted">Snippets stay in sync with the request you build.</div>221              </Link>222              <Link href="/docs" className="group rounded-md border border-border bg-bg p-4 transition-colors hover:border-border-strong">223                <ShieldCheck className="size-4 text-accent" aria-hidden />224                <div className="mt-2 flex items-center gap-1 text-[13.5px] font-medium">225                  Documentation <ArrowRight className="size-3.5 transition-transform group-hover:translate-x-0.5" />226                </div>227                <div className="mt-1 text-[12.5px] text-fg-muted">API reference, error codes, sessions, SDK install from source.</div>228              </Link>229            </div>230            {error ? <Alert variant="danger">{error}</Alert> : null}231          </div>232        ) : null}233234        <div className="mt-8 flex items-center justify-between border-t border-border pt-5">235          <Button variant="ghost" size="sm" onClick={() => setStep((s) => Math.max(0, s - 1))} disabled={step === 0 || finishing}>236            <ArrowLeft /> Back237          </Button>238          <div className="flex items-center gap-2">239            {!last ? (240              <Button variant="link" size="sm" onClick={finish} disabled={finishing} className="text-fg-muted">241                Skip for now242              </Button>243            ) : null}244            {last ? (245              <Button variant="primary" onClick={finish} loading={finishing}>246                Finish <Check />247              </Button>248            ) : (249              <Button variant="primary" onClick={() => setStep((s) => Math.min(STEPS.length - 1, s + 1))}>250                {step === 2 && !keyReady ? "Skip this step" : "Continue"} <ArrowRight />251              </Button>252            )}253          </div>254        </div>255      </div>256    </div>257  );258}259