Files
claudemesh/apps/web/src/modules/mesh/create-mesh-form.tsx
Alejandro Gutiérrez 138b5a24ae
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
feat(web): first-time user onboarding flow
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>
2026-04-04 23:47:52 +01:00

184 lines
5.3 KiB
TypeScript

"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
createMyMeshInputSchema,
type CreateMyMeshInput,
} from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils";
import { Button } from "@turbostarter/ui-web/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@turbostarter/ui-web/form";
import { Input } from "@turbostarter/ui-web/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@turbostarter/ui-web/select";
import { pathsConfig } from "~/config/paths";
import { api } from "~/lib/api/client";
const slugify = (s: string) =>
s
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
export const CreateMeshForm = ({
onboarding = false,
}: { onboarding?: boolean } = {}) => {
const router = useRouter();
const form = useForm<CreateMyMeshInput>({
resolver: zodResolver(createMyMeshInputSchema),
defaultValues: {
name: "",
slug: "",
visibility: "private",
transport: "managed",
},
});
const nameValue = form.watch("name");
const slugDirty = form.formState.dirtyFields.slug;
useEffect(() => {
if (!slugDirty && nameValue) {
form.setValue("slug", slugify(nameValue));
}
}, [nameValue, slugDirty, form]);
const onSubmit = async (values: CreateMyMeshInput) => {
try {
const res = (await handle(api.my.meshes.$post)({
json: values,
})) as { id: string; slug: string } | { error: string };
if ("error" in res) {
form.setError("slug", { message: res.error });
return;
}
router.push(
onboarding
? `${pathsConfig.dashboard.user.meshes.invite(res.id)}?onboarding=1`
: pathsConfig.dashboard.user.meshes.mesh(res.id),
);
} catch (e) {
form.setError("root", {
message: e instanceof Error ? e.message : "Failed to create mesh.",
});
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Platform team" {...field} />
</FormControl>
<FormDescription>
Display name what teammates see.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input placeholder="platform-team" {...field} />
</FormControl>
<FormDescription>
URL-safe identifier: lowercase letters, digits, hyphens.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="visibility"
render={({ field }) => (
<FormItem>
<FormLabel>Visibility</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="private">
Private invite-only
</SelectItem>
<SelectItem value="public">
Public anyone with the link
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="transport"
render={({ field }) => (
<FormItem>
<FormLabel>Transport</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="managed">Managed (claudemesh.com)</SelectItem>
<SelectItem value="tailscale">Tailscale</SelectItem>
<SelectItem value="self_hosted">Self-hosted broker</SelectItem>
</SelectContent>
</Select>
<FormDescription>
How peers reach the broker.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{form.formState.errors.root && (
<p className="text-destructive text-sm">
{form.formState.errors.root.message}
</p>
)}
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Creating…" : "Create mesh"}
</Button>
</form>
</Form>
);
};