feat(project-creation): 3-path wizard — Build / OSS / Import

User feedback: the previous flow was a single-screen "name + audience"
dialog that gave AI no context about what the user actually wanted to
make. That worked for the demo but produced messy projects in practice
because everything was decided after the fact in chat.

The new flow asks the user one human question first ("How would you
like to begin?") and then captures the minimum context needed to seed
the AI's first conversation in the project.

Three paths, each is a 2-step setup screen with internal step dots:

  - Build your own idea  — Step 1: name + audience.  Step 2: free-text
    "what do you want to build". Becomes the project's vision and the
    AI's first-message context.

  - Run an open source tool  — Step 1: name + audience.  Step 2:
    segmented tabs to either (a) paste a GitHub link or (b) describe
    the kind of tool you want and have Vibn find one. Vision is set
    to either "Install and host this open-source project: <url>" or
    "Find and install an open-source tool that fits this need: <desc>"
    so the AI knows which mode to operate in on first chat.

  - Import existing code  — Step 1: name + audience + repo URL.
    Step 2: optional "what do you want to do with it" textarea.
    Public repos only for v1; private-repo OAuth lands later.

Backend:
  - /api/projects/create now accepts and persists `creationMode` and
    `sourceData` on the project record under a `kickoff` blob:
      { mode, sourceData, vision, createdAt }
    The chat endpoint will read this on first turn to seed the AI
    with the user's stated intent rather than asking them to re-type
    it in chat.

Cleanup:
  - Removed FreshIdeaSetup, CodeImportSetup, ChatImportSetup,
    MigrateSetup — replaced by BuildSetup, OssSetup, ImportSetup.
  - Removed the unused initialWorkspacePath prop from
    project-association-prompt (the new flow doesn't take it).
  - TypeSelector defaults are restored — the modal opens on the
    type-picker step now, not directly on a setup form.

UI building blocks added to setup-shared:
  - TextArea (multi-line input)
  - StepDots (page indicator)
  - SegmentedTabs (generic-typed tab selector, used in OSS Step 2)
  - SecondaryButton (used as ← Back inside Step 2)

Made-with: Cursor
This commit is contained in:
2026-04-29 16:16:53 -07:00
parent 2260f3c280
commit 7a9cd68ea8
12 changed files with 623 additions and 519 deletions

View File

@@ -78,6 +78,8 @@ export async function POST(request: Request) {
githubRepoUrl,
githubDefaultBranch,
githubToken,
creationMode,
sourceData,
} = body;
// Check slug uniqueness
@@ -239,6 +241,16 @@ export async function POST(request: Request) {
// Coolify project — one per VIBN project, scopes all app services + DBs.
// Apps are deployed on-demand via apps_create (no auto-scaffold).
coolifyProjectUuid,
// How this project was created — drives the AI's first-chat seed.
// Shape: { mode: "build"|"oss"|"import", sourceData: {...} } where
// sourceData is the path-specific payload from the wizard.
creationMode: creationMode ?? null,
kickoff: creationMode ? {
mode: creationMode,
sourceData: sourceData ?? null,
vision: vision || null,
createdAt: now,
} : null,
// Import metadata
isImport: !!githubRepoUrl,
importAnalysisStatus: githubRepoUrl ? 'pending' : null,

View File

@@ -298,7 +298,6 @@ export function ProjectAssociationPrompt({ workspace }: { workspace: string }) {
setUnassociatedWorkspace(null);
}
}}
initialWorkspacePath={unassociatedWorkspace?.workspacePath}
workspace={workspace}
/>
</>

View File

@@ -0,0 +1,132 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { JM } from "./modal-theme";
import {
SetupHeader, FieldLabel, TextInput, TextArea, ForWhomSelector,
PrimaryButton, SecondaryButton, StepDots, type SetupProps,
} from "./setup-shared";
/**
* "Build your own idea" — two-step setup.
* Step 1: project name + audience.
* Step 2: describe the idea (free text). Becomes the seed message
* for the first AI conversation in the project.
*/
export function BuildSetup({ workspace, onClose, onBack }: SetupProps) {
const router = useRouter();
const [step, setStep] = useState<0 | 1>(0);
const [name, setName] = useState("");
const [forWhom, setForWhom] = useState<"personal" | "client">("personal");
const [idea, setIdea] = useState("");
const [loading, setLoading] = useState(false);
const canContinue = name.trim().length > 0;
const canCreate = idea.trim().length > 4;
const handleCreate = async () => {
if (!canCreate) return;
setLoading(true);
try {
const res = await fetch("/api/projects/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectName: name.trim(),
projectType: "web-app",
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
vision: idea.trim(),
product: { name: name.trim(), isForClient: forWhom === "client" },
creationMode: "build",
sourceData: { idea: idea.trim() },
}),
});
if (!res.ok) {
const err = await res.json();
toast.error(err.error || "Failed to create project");
return;
}
const data = await res.json();
onClose();
router.push(`/${workspace}/project/${data.projectId}/overview`);
} catch {
toast.error("Something went wrong");
} finally {
setLoading(false);
}
};
return (
<div style={{ padding: 28 }}>
<SetupHeader
icon="✦" label="Build your own idea" tagline="Start from scratch"
accent="#4338CA" onBack={step === 0 ? onBack : () => setStep(0)} onClose={onClose}
/>
{step === 0 && (
<>
<FieldLabel>Project name</FieldLabel>
<TextInput
value={name}
onChange={setName}
placeholder="e.g. Pet Sitter Pro"
onKeyDown={e => { if (e.key === "Enter" && canContinue) setStep(1); }}
autoFocus
/>
<ForWhomSelector value={forWhom} onChange={setForWhom} />
<FlowFooter step={0} total={2} primary={
<PrimaryButton onClick={() => setStep(1)} disabled={!canContinue}>
Next
</PrimaryButton>
} />
</>
)}
{step === 1 && (
<>
<FieldLabel>What do you want to build?</FieldLabel>
<TextArea
value={idea}
onChange={setIdea}
placeholder="A booking site for my dog grooming business. Customers should be able to book online and pay a deposit by card."
rows={6}
autoFocus
/>
<p style={{ fontSize: 12, color: JM.muted, marginTop: -8, marginBottom: 18, lineHeight: 1.5 }}>
Don't worry about the tech. Vibn will pick the tools and start building from this description.
</p>
<FlowFooter
step={1} total={2}
secondary={<SecondaryButton onClick={() => setStep(0)}> Back</SecondaryButton>}
primary={
<PrimaryButton onClick={handleCreate} disabled={!canCreate} loading={loading}>
Start building
</PrimaryButton>
}
/>
</>
)}
</div>
);
}
function FlowFooter({
step, total, primary, secondary,
}: {
step: number; total: number;
primary: React.ReactNode; secondary?: React.ReactNode;
}) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 12, marginTop: 6 }}>
<StepDots step={step} total={total} />
<div style={{ flex: 1 }} />
{secondary}
<div style={{ minWidth: 160 }}>{primary}</div>
</div>
);
}

View File

@@ -1,85 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { JM } from "./modal-theme";
import { SetupHeader, FieldLabel, TextInput, PrimaryButton, type SetupProps } from "./setup-shared";
export function ChatImportSetup({ workspace, onClose, onBack }: SetupProps) {
const router = useRouter();
const [name, setName] = useState("");
const [chatText, setChatText] = useState("");
const [loading, setLoading] = useState(false);
const canCreate = name.trim().length > 0 && chatText.trim().length > 20;
const handleCreate = async () => {
if (!canCreate) return;
setLoading(true);
try {
const res = await fetch("/api/projects/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectName: name.trim(),
projectType: "web-app",
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
product: { name: name.trim() },
creationMode: "chat-import",
sourceData: { chatText: chatText.trim() },
}),
});
if (!res.ok) {
const err = await res.json();
toast.error(err.error || "Failed to create project");
return;
}
const data = await res.json();
onClose();
router.push(`/${workspace}/project/${data.projectId}/overview`);
} catch {
toast.error("Something went wrong");
} finally {
setLoading(false);
}
};
return (
<div style={{ padding: 28 }}>
<SetupHeader
icon="⌁" label="Import Chats" tagline="You've been thinking"
accent="#059669" onBack={onBack} onClose={onClose}
/>
<FieldLabel>Project name</FieldLabel>
<TextInput
value={name}
onChange={setName}
placeholder="What are you building?"
autoFocus
/>
<FieldLabel>Paste your chat history</FieldLabel>
<textarea
value={chatText}
onChange={e => setChatText(e.target.value)}
placeholder={"Paste conversations from ChatGPT, Claude, Gemini, or any AI tool.\n\nVibn will extract decisions, ideas, open questions, and architecture notes."}
rows={8}
style={{
width: "100%", padding: "10px 13px", marginBottom: 20,
borderRadius: 8, border: `1px solid ${JM.border}`,
background: JM.inputBg, fontSize: 13, lineHeight: 1.55,
fontFamily: JM.fontSans, color: JM.ink,
outline: "none", resize: "vertical", boxSizing: "border-box",
}}
onFocus={e => (e.currentTarget.style.borderColor = JM.indigo)}
onBlur={e => (e.currentTarget.style.borderColor = JM.border)}
/>
<PrimaryButton onClick={handleCreate} disabled={!canCreate} loading={loading}>
Extract & analyse
</PrimaryButton>
</div>
);
}

View File

@@ -1,105 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { JM } from "./modal-theme";
import { SetupHeader, FieldLabel, TextInput, PrimaryButton, type SetupProps } from "./setup-shared";
export function CodeImportSetup({ workspace, onClose, onBack }: SetupProps) {
const router = useRouter();
const [name, setName] = useState("");
const [repoUrl, setRepoUrl] = useState("");
const [pat, setPat] = useState("");
const [loading, setLoading] = useState(false);
const isValidUrl = repoUrl.trim().startsWith("http");
const canCreate = name.trim().length > 0 && isValidUrl;
const handleCreate = async () => {
if (!canCreate) return;
setLoading(true);
try {
const res = await fetch("/api/projects/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectName: name.trim(),
projectType: "web-app",
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
product: { name: name.trim() },
creationMode: "code-import",
sourceData: { repoUrl: repoUrl.trim(), pat: pat.trim() || undefined },
}),
});
if (!res.ok) {
const err = await res.json();
toast.error(err.error || "Failed to create project");
return;
}
const data = await res.json();
onClose();
router.push(`/${workspace}/project/${data.projectId}/overview`);
} catch {
toast.error("Something went wrong");
} finally {
setLoading(false);
}
};
return (
<div style={{ padding: 28 }}>
<SetupHeader
icon="⌘" label="Import Code" tagline="Already have a repo"
accent="#1D4ED8" onBack={onBack} onClose={onClose}
/>
<FieldLabel>Project name</FieldLabel>
<TextInput
value={name}
onChange={setName}
placeholder="What is this project called?"
autoFocus
/>
<FieldLabel>Repository URL</FieldLabel>
<TextInput
value={repoUrl}
onChange={setRepoUrl}
placeholder="https://github.com/yourorg/your-repo"
/>
<FieldLabel>
Personal Access Token{" "}
<span style={{ color: JM.muted, fontWeight: 400 }}>(required for private repos)</span>
</FieldLabel>
<input
type="password"
value={pat}
onChange={e => setPat(e.target.value)}
placeholder="ghp_… or similar"
style={{
width: "100%", padding: "10px 13px", marginBottom: 20,
borderRadius: 8, border: `1px solid ${JM.border}`,
background: JM.inputBg, fontSize: 14,
fontFamily: JM.fontSans, color: JM.ink,
outline: "none", boxSizing: "border-box",
}}
onFocus={e => (e.currentTarget.style.borderColor = JM.indigo)}
onBlur={e => (e.currentTarget.style.borderColor = JM.border)}
/>
<div style={{
fontSize: 12, color: JM.mid, marginBottom: 20, lineHeight: 1.5,
padding: "12px 14px", background: JM.cream, borderRadius: 8,
border: `1px solid ${JM.border}`, fontFamily: JM.fontSans,
}}>
Vibn will clone your repo, read key files, and build a full architecture map tech stack, routes, database, auth, and third-party integrations. Tokens are used only for cloning and are not stored.
</div>
<PrimaryButton onClick={handleCreate} disabled={!canCreate} loading={loading}>
Import & map
</PrimaryButton>
</div>
);
}

View File

@@ -4,10 +4,9 @@ import { useState, useEffect } from "react";
import { createPortal } from "react-dom";
import { Plus_Jakarta_Sans } from "next/font/google";
import { TypeSelector } from "./TypeSelector";
import { FreshIdeaSetup } from "./FreshIdeaSetup";
import { ChatImportSetup } from "./ChatImportSetup";
import { CodeImportSetup } from "./CodeImportSetup";
import { MigrateSetup } from "./MigrateSetup";
import { BuildSetup } from "./BuildSetup";
import { OssSetup } from "./OssSetup";
import { ImportSetup } from "./ImportSetup";
import { JM } from "./modal-theme";
const modalFont = Plus_Jakarta_Sans({
@@ -17,7 +16,16 @@ const modalFont = Plus_Jakarta_Sans({
display: "swap",
});
export type CreationMode = "fresh" | "chat-import" | "code-import" | "migration";
/**
* Three project-creation paths surfaced to the user:
* - "build" — describe an idea, AI scaffolds and builds it.
* - "oss" — install an open-source tool (link OR describe-it-and-find).
* - "import" — bring a public GitHub repo in.
*
* Each path is its own setup screen with internal step dots (basics → describe).
* The TypeSelector is the entry point; ESC closes; backdrop click closes.
*/
export type CreationMode = "build" | "oss" | "import";
interface CreateProjectFlowProps {
open: boolean;
@@ -28,13 +36,13 @@ interface CreateProjectFlowProps {
type Step = "select-type" | "setup";
export function CreateProjectFlow({ open, onOpenChange, workspace }: CreateProjectFlowProps) {
const [step, setStep] = useState<Step>("setup");
const [mode, setMode] = useState<CreationMode | null>("fresh");
const [step, setStep] = useState<Step>("select-type");
const [mode, setMode] = useState<CreationMode | null>(null);
useEffect(() => {
if (open) {
setStep("setup");
setMode("fresh");
setStep("select-type");
setMode(null);
}
}, [open]);
@@ -67,7 +75,6 @@ export function CreateProjectFlow({ open, onOpenChange, workspace }: CreateProje
@keyframes vibn-spin { to { transform:rotate(360deg); } }
`}</style>
{/* Backdrop */}
<div
onClick={() => onOpenChange(false)}
style={{
@@ -79,7 +86,6 @@ export function CreateProjectFlow({ open, onOpenChange, workspace }: CreateProje
}}
/>
{/* Modal container — matches justine/03_dashboard.html #modal-new */}
<div style={{
position: "fixed", inset: 0, zIndex: 201,
display: "flex", alignItems: "center", justifyContent: "center",
@@ -105,10 +111,9 @@ export function CreateProjectFlow({ open, onOpenChange, workspace }: CreateProje
onClose={() => onOpenChange(false)}
/>
)}
{step === "setup" && mode === "fresh" && <FreshIdeaSetup {...setupProps} />}
{step === "setup" && mode === "chat-import" && <ChatImportSetup {...setupProps} />}
{step === "setup" && mode === "code-import" && <CodeImportSetup {...setupProps} />}
{step === "setup" && mode === "migration" && <MigrateSetup {...setupProps} />}
{step === "setup" && mode === "build" && <BuildSetup {...setupProps} />}
{step === "setup" && mode === "oss" && <OssSetup {...setupProps} />}
{step === "setup" && mode === "import" && <ImportSetup {...setupProps} />}
</div>
</div>
</>,

View File

@@ -1,92 +0,0 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { JM } from "./modal-theme";
import { FieldLabel, TextInput, PrimaryButton, ForWhomSelector, type SetupProps } from "./setup-shared";
export function FreshIdeaSetup({ workspace, onClose }: SetupProps) {
const router = useRouter();
const [name, setName] = useState("");
const [forWhom, setForWhom] = useState<"personal" | "client">("personal");
const [loading, setLoading] = useState(false);
const nameRef = useRef<HTMLInputElement>(null);
const canCreate = name.trim().length > 0;
const handleCreate = async () => {
if (!canCreate) return;
setLoading(true);
try {
const res = await fetch("/api/projects/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectName: name.trim(),
projectType: "web-app",
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
product: { name: name.trim(), isForClient: forWhom === "client" },
creationMode: "fresh",
sourceData: {},
}),
});
if (!res.ok) {
const err = await res.json();
toast.error(err.error || "Failed to create project");
return;
}
const data = await res.json();
onClose();
router.push(`/${workspace}/project/${data.projectId}/overview`);
} catch {
toast.error("Something went wrong");
} finally {
setLoading(false);
}
};
return (
<div style={{ padding: 28 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 22 }}>
<h3 style={{
fontFamily: JM.fontDisplay, fontSize: 18, fontWeight: 700,
color: JM.ink, margin: 0, letterSpacing: "-0.02em",
}}>
New project
</h3>
<button
type="button"
onClick={onClose}
style={{
background: "none", border: "none", cursor: "pointer",
color: JM.muted, fontSize: 20, padding: 4, lineHeight: 1,
fontFamily: JM.fontSans,
}}
onMouseEnter={e => (e.currentTarget.style.color = JM.mid)}
onMouseLeave={e => (e.currentTarget.style.color = JM.muted)}
>
×
</button>
</div>
<div style={{ marginBottom: 16 }}>
<FieldLabel>Project name</FieldLabel>
<TextInput
value={name}
onChange={setName}
placeholder="e.g. My SaaS App"
onKeyDown={e => { if (e.key === "Enter" && canCreate) void handleCreate(); }}
inputRef={nameRef}
autoFocus
/>
</div>
<ForWhomSelector value={forWhom} onChange={setForWhom} />
<PrimaryButton onClick={() => { void handleCreate(); }} disabled={!canCreate} loading={loading}>
Create project
</PrimaryButton>
</div>
);
}

View File

@@ -0,0 +1,145 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { JM } from "./modal-theme";
import {
SetupHeader, FieldLabel, TextInput, TextArea, ForWhomSelector,
PrimaryButton, SecondaryButton, StepDots, type SetupProps,
} from "./setup-shared";
/**
* "Import existing code" — two-step setup.
* Step 1: project name + audience + repo URL.
* Step 2: describe what you want Vibn to focus on (optional, but
* recommended). Becomes the seed for the first AI conversation.
*
* v1: public repos only. Private-repo OAuth lands in v2.
*/
export function ImportSetup({ workspace, onClose, onBack }: SetupProps) {
const router = useRouter();
const [step, setStep] = useState<0 | 1>(0);
const [name, setName] = useState("");
const [forWhom, setForWhom] = useState<"personal" | "client">("personal");
const [repoUrl, setRepoUrl] = useState("");
const [intent, setIntent] = useState("");
const [loading, setLoading] = useState(false);
const isValidUrl = /^https?:\/\//i.test(repoUrl.trim());
const canContinue = name.trim().length > 0 && isValidUrl;
const handleCreate = async () => {
if (!canContinue) return;
setLoading(true);
try {
const res = await fetch("/api/projects/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectName: name.trim(),
projectType: "web-app",
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
vision: intent.trim() || `Continue work on ${repoUrl.trim()}`,
product: { name: name.trim(), isForClient: forWhom === "client" },
creationMode: "import",
githubRepoUrl: repoUrl.trim(),
sourceData: { repoUrl: repoUrl.trim(), intent: intent.trim() || null },
}),
});
if (!res.ok) {
const err = await res.json();
toast.error(err.error || "Failed to create project");
return;
}
const data = await res.json();
onClose();
router.push(`/${workspace}/project/${data.projectId}/overview`);
} catch {
toast.error("Something went wrong");
} finally {
setLoading(false);
}
};
return (
<div style={{ padding: 28 }}>
<SetupHeader
icon="⌘" label="Import existing code" tagline="From GitHub"
accent="#1D4ED8" onBack={step === 0 ? onBack : () => setStep(0)} onClose={onClose}
/>
{step === 0 && (
<>
<FieldLabel>Project name</FieldLabel>
<TextInput
value={name}
onChange={setName}
placeholder="What do you want to call this?"
autoFocus
/>
<ForWhomSelector value={forWhom} onChange={setForWhom} />
<FieldLabel>GitHub repository link</FieldLabel>
<TextInput
value={repoUrl}
onChange={setRepoUrl}
placeholder="https://github.com/yourname/your-repo"
/>
<p style={{ fontSize: 12, color: JM.muted, marginTop: -8, marginBottom: 18, lineHeight: 1.5 }}>
Public repos work today. Private-repo support is coming soon.
</p>
<FlowFooter step={0} total={2} primary={
<PrimaryButton onClick={() => setStep(1)} disabled={!canContinue}>
Next
</PrimaryButton>
} />
</>
)}
{step === 1 && (
<>
<FieldLabel>What do you want to do with it? <span style={{ color: JM.muted, fontWeight: 400 }}>(optional)</span></FieldLabel>
<TextArea
value={intent}
onChange={setIntent}
placeholder="Add a payments page, fix the login bug, and clean up the dashboard."
rows={6}
autoFocus
/>
<p style={{ fontSize: 12, color: JM.muted, marginTop: -8, marginBottom: 18, lineHeight: 1.5 }}>
This tells Vibn where to start. Skip it and you can guide things in chat once the project's open.
</p>
<FlowFooter
step={1} total={2}
secondary={<SecondaryButton onClick={() => setStep(0)}> Back</SecondaryButton>}
primary={
<PrimaryButton onClick={handleCreate} disabled={!canContinue} loading={loading}>
Import & open
</PrimaryButton>
}
/>
</>
)}
</div>
);
}
function FlowFooter({
step, total, primary, secondary,
}: {
step: number; total: number;
primary: React.ReactNode; secondary?: React.ReactNode;
}) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 12, marginTop: 6 }}>
<StepDots step={step} total={total} />
<div style={{ flex: 1 }} />
{secondary}
<div style={{ minWidth: 160 }}>{primary}</div>
</div>
);
}

View File

@@ -1,166 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { JM } from "./modal-theme";
import { SetupHeader, FieldLabel, TextInput, PrimaryButton, type SetupProps } from "./setup-shared";
const HOSTING_OPTIONS = [
{ value: "", label: "Select hosting provider" },
{ value: "vercel", label: "Vercel" },
{ value: "aws", label: "AWS (EC2 / ECS / Elastic Beanstalk)" },
{ value: "heroku", label: "Heroku" },
{ value: "digitalocean", label: "DigitalOcean (Droplet / App Platform)" },
{ value: "gcp", label: "Google Cloud Platform" },
{ value: "azure", label: "Microsoft Azure" },
{ value: "railway", label: "Railway" },
{ value: "render", label: "Render" },
{ value: "netlify", label: "Netlify" },
{ value: "self-hosted", label: "Self-hosted / VPS" },
{ value: "other", label: "Other" },
];
export function MigrateSetup({ workspace, onClose, onBack }: SetupProps) {
const router = useRouter();
const [name, setName] = useState("");
const [repoUrl, setRepoUrl] = useState("");
const [liveUrl, setLiveUrl] = useState("");
const [hosting, setHosting] = useState("");
const [pat, setPat] = useState("");
const [loading, setLoading] = useState(false);
const isValidRepo = repoUrl.trim().startsWith("http");
const isValidLive = liveUrl.trim().startsWith("http");
const canCreate = name.trim().length > 0 && (isValidRepo || isValidLive);
const handleCreate = async () => {
if (!canCreate) return;
setLoading(true);
try {
const res = await fetch("/api/projects/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectName: name.trim(),
projectType: "web-app",
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
product: { name: name.trim() },
creationMode: "migration",
githubRepoUrl: repoUrl.trim() || undefined,
githubToken: pat.trim() || undefined,
sourceData: {
liveUrl: liveUrl.trim() || undefined,
hosting: hosting || undefined,
},
}),
});
if (!res.ok) {
const err = await res.json();
toast.error(err.error || "Failed to create project");
return;
}
const data = await res.json();
onClose();
router.push(`/${workspace}/project/${data.projectId}/overview`);
} catch {
toast.error("Something went wrong");
} finally {
setLoading(false);
}
};
return (
<div style={{ padding: 28 }}>
<SetupHeader
icon="⇢" label="Migrate Product" tagline="Move an existing product"
accent="#4a2a5a" onBack={onBack} onClose={onClose}
/>
<FieldLabel>Product name</FieldLabel>
<TextInput
value={name}
onChange={setName}
placeholder="What is this product called?"
autoFocus
/>
<FieldLabel>
Repository URL{" "}
<span style={{ color: JM.muted, fontWeight: 400 }}>(recommended)</span>
</FieldLabel>
<TextInput
value={repoUrl}
onChange={setRepoUrl}
placeholder="https://github.com/yourorg/your-repo"
/>
<FieldLabel>
Live URL{" "}
<span style={{ color: JM.muted, fontWeight: 400 }}>(optional)</span>
</FieldLabel>
<TextInput
value={liveUrl}
onChange={setLiveUrl}
placeholder="https://yourproduct.com"
/>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12, marginBottom: 4 }}>
<div>
<FieldLabel>Hosting provider</FieldLabel>
<select
value={hosting}
onChange={e => setHosting(e.target.value)}
style={{
width: "100%", padding: "10px 13px", marginBottom: 16,
borderRadius: 8, border: `1px solid ${JM.border}`,
background: JM.inputBg, fontSize: 13,
fontFamily: JM.fontSans, color: hosting ? JM.ink : JM.muted,
outline: "none", boxSizing: "border-box", appearance: "none",
backgroundImage: `url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%239CA3AF' strokeWidth='1.5' strokeLinecap='round' strokeLinejoin='round'/%3E%3C/svg%3E")`,
backgroundRepeat: "no-repeat", backgroundPosition: "right 12px center",
}}
onFocus={e => (e.currentTarget.style.borderColor = JM.indigo)}
onBlur={e => (e.currentTarget.style.borderColor = JM.border)}
>
{HOSTING_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div>
<FieldLabel>
PAT{" "}<span style={{ color: JM.muted, fontWeight: 400 }}>(private repos)</span>
</FieldLabel>
<input
type="password"
value={pat}
onChange={e => setPat(e.target.value)}
placeholder="ghp_…"
style={{
width: "100%", padding: "10px 13px", marginBottom: 16,
borderRadius: 8, border: `1px solid ${JM.border}`,
background: JM.inputBg, fontSize: 14,
fontFamily: JM.fontSans, color: JM.ink,
outline: "none", boxSizing: "border-box",
}}
onFocus={e => (e.currentTarget.style.borderColor = JM.indigo)}
onBlur={e => (e.currentTarget.style.borderColor = JM.border)}
/>
</div>
</div>
<div style={{
fontSize: 12, color: JM.mid, marginBottom: 20, lineHeight: 1.5,
padding: "12px 14px", background: JM.cream, borderRadius: 8,
border: `1px solid ${JM.border}`, fontFamily: JM.fontSans,
}}>
<strong style={{ color: "#5B21B6" }}>Non-destructive.</strong> Vibn builds a full audit and migration plan. Your existing product stays live throughout the entire migration process.
</div>
<PrimaryButton onClick={handleCreate} disabled={!canCreate} loading={loading}>
Start migration plan
</PrimaryButton>
</div>
);
}

View File

@@ -0,0 +1,172 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { JM } from "./modal-theme";
import {
SetupHeader, FieldLabel, TextInput, TextArea, ForWhomSelector,
PrimaryButton, SecondaryButton, SegmentedTabs, StepDots, type SetupProps,
} from "./setup-shared";
/**
* "Run an open source tool" — two-step setup.
* Step 1: project name + audience.
* Step 2: either paste a link to the OSS repo (e.g. github.com/twentyhq/twenty)
* OR describe the tool you want and let Vibn find one.
* Both produce a free-text "what do you want to do with it?" seed
* that we hand to the AI on first chat.
*/
export function OssSetup({ workspace, onClose, onBack }: SetupProps) {
const router = useRouter();
const [step, setStep] = useState<0 | 1>(0);
const [name, setName] = useState("");
const [forWhom, setForWhom] = useState<"personal" | "client">("personal");
const [tab, setTab] = useState<"link" | "describe">("link");
const [repoUrl, setRepoUrl] = useState("");
const [needs, setNeeds] = useState("");
const [loading, setLoading] = useState(false);
const canContinue = name.trim().length > 0;
const isValidUrl = /^https?:\/\//i.test(repoUrl.trim());
const canCreate =
(tab === "link" && isValidUrl) ||
(tab === "describe" && needs.trim().length > 4);
const handleCreate = async () => {
if (!canCreate) return;
setLoading(true);
try {
const seed = tab === "link"
? `Install and host this open-source project: ${repoUrl.trim()}`
: `Find and install an open-source tool that fits this need: ${needs.trim()}`;
const res = await fetch("/api/projects/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectName: name.trim(),
projectType: "service",
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
vision: seed,
product: { name: name.trim(), isForClient: forWhom === "client" },
creationMode: "oss",
sourceData: tab === "link"
? { kind: "link", repoUrl: repoUrl.trim() }
: { kind: "describe", description: needs.trim() },
}),
});
if (!res.ok) {
const err = await res.json();
toast.error(err.error || "Failed to create project");
return;
}
const data = await res.json();
onClose();
router.push(`/${workspace}/project/${data.projectId}/overview`);
} catch {
toast.error("Something went wrong");
} finally {
setLoading(false);
}
};
return (
<div style={{ padding: 28 }}>
<SetupHeader
icon="◇" label="Run an open source tool" tagline="Install ready-made"
accent="#059669" onBack={step === 0 ? onBack : () => setStep(0)} onClose={onClose}
/>
{step === 0 && (
<>
<FieldLabel>Project name</FieldLabel>
<TextInput
value={name}
onChange={setName}
placeholder="e.g. My CRM"
onKeyDown={e => { if (e.key === "Enter" && canContinue) setStep(1); }}
autoFocus
/>
<ForWhomSelector value={forWhom} onChange={setForWhom} />
<FlowFooter step={0} total={2} primary={
<PrimaryButton onClick={() => setStep(1)} disabled={!canContinue}>
Next
</PrimaryButton>
} />
</>
)}
{step === 1 && (
<>
<SegmentedTabs<"link" | "describe">
value={tab}
onChange={setTab}
options={[
{ id: "link", label: "I have a link" },
{ id: "describe", label: "Help me find one" },
]}
/>
{tab === "link" ? (
<>
<FieldLabel>Link to the open source project</FieldLabel>
<TextInput
value={repoUrl}
onChange={setRepoUrl}
placeholder="https://github.com/twentyhq/twenty"
autoFocus
/>
<p style={{ fontSize: 12, color: JM.muted, marginTop: -8, marginBottom: 18, lineHeight: 1.5 }}>
Paste a GitHub link. Vibn will check it works and host it for you.
</p>
</>
) : (
<>
<FieldLabel>What kind of tool are you looking for?</FieldLabel>
<TextArea
value={needs}
onChange={setNeeds}
placeholder="A simple CRM where my sales team can track deals and notes on customers."
rows={6}
autoFocus
/>
<p style={{ fontSize: 12, color: JM.muted, marginTop: -8, marginBottom: 18, lineHeight: 1.5 }}>
Vibn will suggest open-source projects that match and let you pick.
</p>
</>
)}
<FlowFooter
step={1} total={2}
secondary={<SecondaryButton onClick={() => setStep(0)}> Back</SecondaryButton>}
primary={
<PrimaryButton onClick={handleCreate} disabled={!canCreate} loading={loading}>
{tab === "link" ? "Install →" : "Find tools →"}
</PrimaryButton>
}
/>
</>
)}
</div>
);
}
function FlowFooter({
step, total, primary, secondary,
}: {
step: number; total: number;
primary: React.ReactNode; secondary?: React.ReactNode;
}) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 12, marginTop: 6 }}>
<StepDots step={step} total={total} />
<div style={{ flex: 1 }} />
{secondary}
<div style={{ minWidth: 160 }}>{primary}</div>
</div>
);
}

View File

@@ -8,53 +8,36 @@ interface TypeSelectorProps {
onClose: () => void;
}
const ALL_FLOW_TYPES: {
const FLOW_TYPES: {
id: CreationMode;
icon: string;
label: string;
tagline: string;
desc: string;
accent: string;
hidden?: boolean;
}[] = [
{
id: "fresh",
id: "build",
icon: "✦",
label: "Fresh Idea",
tagline: "Start from scratch",
desc: "Talk through your idea with Vibn. We'll explore it together and shape it into a full product plan.",
label: "Build your own idea",
desc: "Tell Vibn what you want to make. We'll scaffold the codebase and you'll guide it from there.",
accent: "#4338CA",
},
{
id: "chat-import",
icon: "",
label: "Import Chats",
tagline: "You've been thinking",
desc: "Paste conversations from ChatGPT or Claude. Vibn extracts your decisions, ideas, and open questions.",
id: "oss",
icon: "",
label: "Run an open source tool",
desc: "Install something ready-made — like a CRM, automation, or analytics — and we'll host it for you.",
accent: "#059669",
},
{
id: "code-import",
id: "import",
icon: "⌘",
label: "Import Code",
tagline: "Already have a repo",
desc: "Point Vibn at your GitHub or Bitbucket repo. We'll map your stack and show what's missing.",
label: "Import existing code",
desc: "Bring a project you already have on GitHub. We'll mirror it, host it, and pick up where you left off.",
accent: "#1D4ED8",
hidden: true,
},
{
id: "migration",
icon: "⇢",
label: "Migrate Product",
tagline: "Move an existing product",
desc: "Bring your live product into the VIBN infrastructure. Vibn builds a safe, phased migration plan.",
accent: "#7C3AED",
hidden: true,
},
];
const FLOW_TYPES = ALL_FLOW_TYPES.filter(t => !t.hidden);
export function TypeSelector({ onSelect, onClose }: TypeSelectorProps) {
return (
<div style={{ padding: 28 }}>
@@ -92,20 +75,19 @@ export function TypeSelector({ onSelect, onClose }: TypeSelectorProps) {
type="button"
onClick={() => onSelect(type.id)}
style={{
display: "flex", flexDirection: "column", alignItems: "flex-start",
gap: 0, padding: 16, borderRadius: 12, textAlign: "left",
display: "flex", alignItems: "center",
gap: 14, padding: 14, borderRadius: 12, textAlign: "left",
border: `1px solid ${JM.border}`,
background: JM.inputBg,
cursor: "pointer",
transition: "border-color 0.15s, background 0.15s, box-shadow 0.15s",
fontFamily: JM.fontSans,
position: "relative",
overflow: "hidden",
}}
onMouseEnter={e => {
e.currentTarget.style.borderColor = JM.indigo;
e.currentTarget.style.background = JM.cream;
e.currentTarget.style.boxShadow = "0 2px 12px rgba(99,102,241,0.1)";
e.currentTarget.style.borderColor = type.accent;
e.currentTarget.style.background = "#fff";
e.currentTarget.style.boxShadow = `0 2px 12px ${type.accent}1a`;
}}
onMouseLeave={e => {
e.currentTarget.style.borderColor = JM.border;
@@ -114,34 +96,24 @@ export function TypeSelector({ onSelect, onClose }: TypeSelectorProps) {
}}
>
<div style={{
width: 36, height: 36, borderRadius: 9, marginBottom: 12,
background: "rgba(99,102,241,0.12)",
width: 40, height: 40, borderRadius: 10,
background: `${type.accent}1a`,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "1.05rem", color: type.accent,
fontSize: "1.15rem", color: type.accent, flexShrink: 0,
}}>
{type.icon}
</div>
<div style={{ fontSize: 13.5, fontWeight: 600, color: JM.ink, marginBottom: 2 }}>
{type.label}
</div>
<div style={{
fontSize: 10.5, fontWeight: 600, color: type.accent,
letterSpacing: "0.07em", marginBottom: 8, textTransform: "uppercase",
}}>
{type.tagline}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13.5, fontWeight: 600, color: JM.ink, marginBottom: 2 }}>
{type.label}
</div>
<div style={{ fontSize: 12.5, color: JM.mid, lineHeight: 1.45 }}>
{type.desc}
</div>
</div>
<div style={{ fontSize: 12.5, color: JM.mid, lineHeight: 1.55 }}>
{type.desc}
</div>
<div style={{
position: "absolute", right: 16, bottom: 16,
fontSize: 14, color: JM.muted,
}}>
</div>
<div style={{ fontSize: 14, color: JM.muted, flexShrink: 0 }}></div>
</button>
))}
</div>

View File

@@ -173,6 +173,121 @@ export function TextInput({
);
}
export function TextArea({
value,
onChange,
placeholder,
rows = 5,
autoFocus,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
rows?: number;
autoFocus?: boolean;
}) {
return (
<textarea
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
rows={rows}
autoFocus={autoFocus}
style={{
width: "100%", padding: "11px 13px", marginBottom: 16,
borderRadius: 8, border: `1px solid ${JM.border}`,
background: JM.inputBg, fontSize: 14,
fontFamily: JM.fontSans, color: JM.ink,
outline: "none", boxSizing: "border-box",
resize: "vertical", lineHeight: 1.5,
minHeight: 96,
}}
onFocus={e => (e.currentTarget.style.borderColor = JM.indigo)}
onBlur={e => (e.currentTarget.style.borderColor = JM.border)}
/>
);
}
/** Page indicator dots for a multi-step setup screen. */
export function StepDots({ step, total }: { step: number; total: number }) {
return (
<div style={{ display: "flex", gap: 5, alignItems: "center" }}>
{Array.from({ length: total }).map((_, i) => (
<span
key={i}
style={{
width: i === step ? 18 : 6, height: 6, borderRadius: 3,
background: i === step ? JM.indigo : JM.border,
transition: "all 0.2s",
}}
/>
))}
</div>
);
}
/** Inline tab selector — used inside OSS setup to pick paste-link vs describe-it. */
export function SegmentedTabs<T extends string>({
value, onChange, options,
}: {
value: T;
onChange: (v: T) => void;
options: { id: T; label: string }[];
}) {
return (
<div style={{
display: "flex", padding: 3, marginBottom: 14,
background: JM.inputBg, border: `1px solid ${JM.border}`,
borderRadius: 9, gap: 3,
}}>
{options.map(opt => {
const sel = opt.id === value;
return (
<button
key={opt.id}
type="button"
onClick={() => onChange(opt.id)}
style={{
flex: 1, padding: "7px 10px", borderRadius: 6,
border: "none", cursor: "pointer",
background: sel ? "#fff" : "transparent",
color: sel ? JM.ink : JM.mid,
fontSize: 12.5, fontWeight: sel ? 600 : 500,
fontFamily: JM.fontSans,
boxShadow: sel ? "0 1px 3px rgba(0,0,0,0.06)" : "none",
transition: "all 0.15s",
}}
>
{opt.label}
</button>
);
})}
</div>
);
}
export function SecondaryButton({
onClick, children,
}: { onClick: () => void; children: ReactNode }) {
return (
<button
type="button"
onClick={onClick}
style={{
padding: "11px 16px", borderRadius: 8,
border: `1px solid ${JM.border}`, background: "#fff",
color: JM.mid, fontSize: 13.5, fontWeight: 600,
fontFamily: JM.fontSans, cursor: "pointer",
transition: "border-color 0.15s, color 0.15s",
}}
onMouseEnter={e => { e.currentTarget.style.borderColor = JM.mid; e.currentTarget.style.color = JM.ink; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = JM.border; e.currentTarget.style.color = JM.mid; }}
>
{children}
</button>
);
}
export function PrimaryButton({
onClick,
disabled,