feat(web): first-time user onboarding flow
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
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:
@@ -13,13 +13,33 @@ export const generateMetadata = getMetadata({
|
||||
|
||||
export default async function InvitePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ onboarding?: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const { onboarding } = await searchParams;
|
||||
const isOnboarding = onboarding === "1";
|
||||
|
||||
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 <link>
|
||||
</code>{" "}
|
||||
in their terminal.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<DashboardHeader>
|
||||
<div>
|
||||
<DashboardHeaderTitle>Invite teammate</DashboardHeaderTitle>
|
||||
|
||||
@@ -11,9 +11,29 @@ export const generateMetadata = getMetadata({
|
||||
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 (
|
||||
<>
|
||||
{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>
|
||||
<div>
|
||||
<DashboardHeaderTitle>New mesh</DashboardHeaderTitle>
|
||||
@@ -23,7 +43,7 @@ export default function NewMeshPage() {
|
||||
</div>
|
||||
</DashboardHeader>
|
||||
<div className="max-w-xl">
|
||||
<CreateMeshForm />
|
||||
<CreateMeshForm onboarding={isOnboarding} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,66 +1,84 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@turbostarter/ui-web/card";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { getMyMeshesResponseSchema } from "@turbostarter/api/schema";
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { buttonVariants } from "@turbostarter/ui-web/button";
|
||||
|
||||
/**
|
||||
* Dashboard Home Page
|
||||
*
|
||||
* Welcome page for authenticated users.
|
||||
*/
|
||||
export default function DashboardPage() {
|
||||
const { t } = useTranslation("dashboard");
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { api } from "~/lib/api/server";
|
||||
import { getMetadata } from "~/lib/metadata";
|
||||
|
||||
export const generateMetadata = getMetadata({
|
||||
title: "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 (
|
||||
<div className="@container h-full p-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("welcome.title", { defaultValue: "Welcome to your Dashboard" })}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{t("welcome.description", { defaultValue: "Get started by exploring the features below." })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card>
|
||||
<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>
|
||||
<Icons.MessageSquare className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("features.aiChat.description", { defaultValue: "Have a conversation with AI assistants" })}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t("features.imageGeneration.title", { defaultValue: "Image Generation" })}</CardTitle>
|
||||
<Icons.Image className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("features.imageGeneration.description", { defaultValue: "Create images with AI" })}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t("features.pdfAnalysis.title", { defaultValue: "PDF Analysis" })}</CardTitle>
|
||||
<Icons.FileText className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("features.pdfAnalysis.description", { defaultValue: "Upload and analyze PDF documents" })}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-medium tracking-tight">Your meshes</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Open one to see its members, generate invites, or share it.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{data.map((m) => (
|
||||
<Link
|
||||
key={m.id}
|
||||
href={pathsConfig.dashboard.user.meshes.mesh(m.id)}
|
||||
className="group rounded-lg border p-5 transition-colors hover:border-primary hover:bg-muted/30"
|
||||
>
|
||||
<div className="mb-3 flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="group-hover:text-primary truncate font-medium">
|
||||
{m.name}
|
||||
</h3>
|
||||
<p className="text-muted-foreground truncate font-mono text-xs">
|
||||
{m.slug}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{m.isOwner ? "owner" : m.myRole}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{m.tier}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">
|
||||
{m.memberCount} {m.memberCount === 1 ? "member" : "members"}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
href={pathsConfig.dashboard.user.meshes.index}
|
||||
className={buttonVariants({ variant: "outline" })}
|
||||
>
|
||||
All meshes
|
||||
</Link>
|
||||
<Link
|
||||
href={pathsConfig.dashboard.user.meshes.new}
|
||||
className={buttonVariants({ variant: "default" })}
|
||||
>
|
||||
New mesh
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user