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>
184 lines
5.3 KiB
TypeScript
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>
|
|
);
|
|
};
|