feat(web): first-time user onboarding flow
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled

New user signs in → /dashboard (user) → hits server-side getMyMeshes → 0
results → redirects to /dashboard/meshes/new?onboarding=1. Create-mesh
page renders a welcome banner explaining what a mesh is. After submit,
if ?onboarding=1 was set, the form bounces to
/dashboard/meshes/[id]/invite?onboarding=1 instead of the mesh detail
page. Invite page renders a "🎉 Mesh created" banner with the
`claudemesh join <link>` CLI snippet.

The onboarding flag is URL-driven — no persistence needed, dismissal
happens naturally when the user navigates away.

Also rewrites the /dashboard (user) home page from the placeholder
"Welcome to your Dashboard" TurboStarter card grid to a claudemesh-
native view: top 6 meshes with badges, All meshes / New mesh CTAs.
Removes the unused Card/Icons imports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-04 23:47:52 +01:00
parent 759a22e7c0
commit 138b5a24ae
4 changed files with 127 additions and 63 deletions

View File

@@ -13,13 +13,33 @@ export const generateMetadata = getMetadata({
export default async function InvitePage({ export default async function InvitePage({
params, params,
searchParams,
}: { }: {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
searchParams: Promise<{ onboarding?: string }>;
}) { }) {
const { id } = await params; const { id } = await params;
const { onboarding } = await searchParams;
const isOnboarding = onboarding === "1";
return ( return (
<> <>
{isOnboarding && (
<div className="border-primary/40 bg-primary/5 mb-6 rounded-lg border p-5">
<h2 className="text-primary mb-1 text-lg font-medium">
🎉 Mesh created
</h2>
<p className="mb-2 text-sm leading-relaxed">
Now generate your first invite link to share with a teammate or
use it yourself to join this mesh from another laptop. Your
teammate runs{" "}
<code className="bg-muted rounded px-1 py-0.5 text-xs">
claudemesh join &lt;link&gt;
</code>{" "}
in their terminal.
</p>
</div>
)}
<DashboardHeader> <DashboardHeader>
<div> <div>
<DashboardHeaderTitle>Invite teammate</DashboardHeaderTitle> <DashboardHeaderTitle>Invite teammate</DashboardHeaderTitle>

View File

@@ -11,9 +11,29 @@ export const generateMetadata = getMetadata({
description: "Create a mesh.", description: "Create a mesh.",
}); });
export default function NewMeshPage() { export default async function NewMeshPage({
searchParams,
}: {
searchParams: Promise<{ onboarding?: string }>;
}) {
const { onboarding } = await searchParams;
const isOnboarding = onboarding === "1";
return ( return (
<> <>
{isOnboarding && (
<div className="border-primary/40 bg-primary/5 mb-6 rounded-lg border p-5">
<h2 className="text-primary mb-1 text-lg font-medium">
Welcome to claudemesh 👋
</h2>
<p className="text-sm leading-relaxed">
Create your first mesh in 10 seconds. A mesh is the space where
your Claude Code sessions talk to each other. You can invite
teammates, share context, and route messages all end-to-end
encrypted.
</p>
</div>
)}
<DashboardHeader> <DashboardHeader>
<div> <div>
<DashboardHeaderTitle>New mesh</DashboardHeaderTitle> <DashboardHeaderTitle>New mesh</DashboardHeaderTitle>
@@ -23,7 +43,7 @@ export default function NewMeshPage() {
</div> </div>
</DashboardHeader> </DashboardHeader>
<div className="max-w-xl"> <div className="max-w-xl">
<CreateMeshForm /> <CreateMeshForm onboarding={isOnboarding} />
</div> </div>
</> </>
); );

View File

@@ -1,66 +1,84 @@
"use client"; import Link from "next/link";
import { redirect } from "next/navigation";
import { useTranslation } from "@turbostarter/i18n"; import { getMyMeshesResponseSchema } from "@turbostarter/api/schema";
import { Card, CardContent, CardHeader, CardTitle } from "@turbostarter/ui-web/card"; import { handle } from "@turbostarter/api/utils";
import { Icons } from "@turbostarter/ui-web/icons"; import { Badge } from "@turbostarter/ui-web/badge";
import { buttonVariants } from "@turbostarter/ui-web/button";
/** import { pathsConfig } from "~/config/paths";
* Dashboard Home Page import { api } from "~/lib/api/server";
* import { getMetadata } from "~/lib/metadata";
* Welcome page for authenticated users.
*/ export const generateMetadata = getMetadata({
export default function DashboardPage() { title: "Dashboard",
const { t } = useTranslation("dashboard"); description: "Your meshes.",
});
export default async function DashboardHomePage() {
const { data } = await handle(api.my.meshes.$get, {
schema: getMyMeshesResponseSchema,
})({
query: { page: "1", perPage: "6", sort: JSON.stringify([]) },
});
// First-time onboarding: 0-mesh user → bounce to create
if (data.length === 0) {
redirect(`${pathsConfig.dashboard.user.meshes.new}?onboarding=1`);
}
return ( return (
<div className="@container h-full p-6"> <div className="space-y-8">
<div className="space-y-6"> <div>
<div> <h1 className="text-2xl font-medium tracking-tight">Your meshes</h1>
<h1 className="text-2xl font-bold tracking-tight"> <p className="text-muted-foreground text-sm">
{t("welcome.title", { defaultValue: "Welcome to your Dashboard" })} Open one to see its members, generate invites, or share it.
</h1> </p>
<p className="text-muted-foreground"> </div>
{t("welcome.description", { defaultValue: "Get started by exploring the features below." })} <div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
</p> {data.map((m) => (
</div> <Link
key={m.id}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> href={pathsConfig.dashboard.user.meshes.mesh(m.id)}
<Card> className="group rounded-lg border p-5 transition-colors hover:border-primary hover:bg-muted/30"
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> >
<CardTitle className="text-sm font-medium">{t("features.aiChat.title", { defaultValue: "AI Chat" })}</CardTitle> <div className="mb-3 flex items-start justify-between gap-2">
<Icons.MessageSquare className="h-4 w-4 text-muted-foreground" /> <div className="min-w-0 flex-1">
</CardHeader> <h3 className="group-hover:text-primary truncate font-medium">
<CardContent> {m.name}
<p className="text-xs text-muted-foreground"> </h3>
{t("features.aiChat.description", { defaultValue: "Have a conversation with AI assistants" })} <p className="text-muted-foreground truncate font-mono text-xs">
</p> {m.slug}
</CardContent> </p>
</Card> </div>
<Badge variant="outline" className="text-xs">
<Card> {m.isOwner ? "owner" : m.myRole}
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> </Badge>
<CardTitle className="text-sm font-medium">{t("features.imageGeneration.title", { defaultValue: "Image Generation" })}</CardTitle> </div>
<Icons.Image className="h-4 w-4 text-muted-foreground" /> <div className="flex items-center gap-3 text-xs">
</CardHeader> <Badge variant="secondary" className="text-xs">
<CardContent> {m.tier}
<p className="text-xs text-muted-foreground"> </Badge>
{t("features.imageGeneration.description", { defaultValue: "Create images with AI" })} <span className="text-muted-foreground">
</p> {m.memberCount} {m.memberCount === 1 ? "member" : "members"}
</CardContent> </span>
</Card> </div>
</Link>
<Card> ))}
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> </div>
<CardTitle className="text-sm font-medium">{t("features.pdfAnalysis.title", { defaultValue: "PDF Analysis" })}</CardTitle> <div className="flex gap-3">
<Icons.FileText className="h-4 w-4 text-muted-foreground" /> <Link
</CardHeader> href={pathsConfig.dashboard.user.meshes.index}
<CardContent> className={buttonVariants({ variant: "outline" })}
<p className="text-xs text-muted-foreground"> >
{t("features.pdfAnalysis.description", { defaultValue: "Upload and analyze PDF documents" })} All meshes
</p> </Link>
</CardContent> <Link
</Card> href={pathsConfig.dashboard.user.meshes.new}
</div> className={buttonVariants({ variant: "default" })}
>
New mesh
</Link>
</div> </div>
</div> </div>
); );

View File

@@ -40,7 +40,9 @@ const slugify = (s: string) =>
.replace(/^-+|-+$/g, "") .replace(/^-+|-+$/g, "")
.slice(0, 40); .slice(0, 40);
export const CreateMeshForm = () => { export const CreateMeshForm = ({
onboarding = false,
}: { onboarding?: boolean } = {}) => {
const router = useRouter(); const router = useRouter();
const form = useForm<CreateMyMeshInput>({ const form = useForm<CreateMyMeshInput>({
resolver: zodResolver(createMyMeshInputSchema), resolver: zodResolver(createMyMeshInputSchema),
@@ -70,7 +72,11 @@ export const CreateMeshForm = () => {
form.setError("slug", { message: res.error }); form.setError("slug", { message: res.error });
return; return;
} }
router.push(pathsConfig.dashboard.user.meshes.mesh(res.id)); router.push(
onboarding
? `${pathsConfig.dashboard.user.meshes.invite(res.id)}?onboarding=1`
: pathsConfig.dashboard.user.meshes.mesh(res.id),
);
} catch (e) { } catch (e) {
form.setError("root", { form.setError("root", {
message: e instanceof Error ? e.message : "Failed to create mesh.", message: e instanceof Error ? e.message : "Failed to create mesh.",