Files
vibn-frontend/components/project-creation/ImportSetup.tsx
Mark Henderson 8969371134 fix: redirect to /product after project creation instead of /overview
/overview does not exist as a route; the correct landing tab is /product.

Made-with: Cursor
2026-04-30 18:50:55 -07:00

498 lines
18 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { JM } from "./modal-theme";
import {
SetupHeader, FieldLabel, TextInput, TextArea, AudienceSelector,
PrimaryButton, SecondaryButton, StepDots,
type SetupProps, type Audience,
} from "./setup-shared";
/**
* "Import existing code" — two-step setup with GitHub OAuth.
*
* Step 1: project name + audience + repo source.
* Repo source defaults to the connected-GitHub picker; falls back to
* "paste a public URL" if the user hasn't linked GitHub yet (or
* prefers to bring an arbitrary repo).
* Step 2: optional "what do you want to do with it" textarea (seeds
* the AI's first message).
*/
export function ImportSetup({ workspace, onClose, onBack }: SetupProps) {
const router = useRouter();
const [step, setStep] = useState<0 | 1>(0);
const [name, setName] = useState("");
const [audience, setAudience] = useState<Audience>("customers");
// GitHub OAuth state. `connected === undefined` means we haven't
// checked yet; `null` means "checked, not linked".
const [connected, setConnected] = useState<undefined | null | { login: string; repos: GhRepo[] }>(undefined);
const [picker, setPicker] = useState<"github" | "url">("github");
const [filter, setFilter] = useState("");
const [selectedRepoId, setSelectedRepoId] = useState<number | null>(null);
const [manualUrl, setManualUrl] = useState("");
const [intent, setIntent] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
let cancelled = false;
fetch("/api/integrations/github/repos", { credentials: "include" })
.then(r => r.json())
.then(d => {
if (cancelled) return;
if (d?.connected && Array.isArray(d.repos)) {
setConnected({ login: d.login, repos: d.repos });
setPicker("github");
} else {
setConnected(null);
setPicker("url"); // no point defaulting to a picker that's empty
}
})
.catch(() => { if (!cancelled) setConnected(null); });
return () => { cancelled = true; };
}, []);
// Surface ?gh_error / ?gh_connected toasts after returning from GitHub.
useEffect(() => {
if (typeof window === "undefined") return;
const u = new URL(window.location.href);
const err = u.searchParams.get("gh_error");
const ok = u.searchParams.get("gh_connected");
if (err) {
toast.error(`GitHub: ${err}`);
u.searchParams.delete("gh_error");
window.history.replaceState({}, "", u.toString());
}
if (ok) {
toast.success(`Connected GitHub as @${ok}`);
u.searchParams.delete("gh_connected");
window.history.replaceState({}, "", u.toString());
// Refresh repo list silently.
fetch("/api/integrations/github/repos", { credentials: "include" })
.then(r => r.json())
.then(d => {
if (d?.connected) setConnected({ login: d.login, repos: d.repos });
})
.catch(() => {});
}
}, []);
const selectedRepo = connected && typeof connected === "object"
? connected.repos.find(r => r.id === selectedRepoId) ?? null
: null;
const isValidUrl = /^https?:\/\//i.test(manualUrl.trim());
const canContinue =
name.trim().length > 0 &&
((picker === "github" && !!selectedRepo) || (picker === "url" && isValidUrl));
const handleConnect = () => {
const returnTo = window.location.pathname + window.location.search;
window.location.href = `/api/integrations/github/connect?returnTo=${encodeURIComponent(returnTo)}`;
};
const handleDisconnect = async () => {
await fetch("/api/integrations/github/disconnect", { method: "POST", credentials: "include" });
setConnected(null);
setPicker("url");
setSelectedRepoId(null);
toast.success("Disconnected GitHub");
};
const handleCreate = async () => {
if (!canContinue) return;
const repoUrl = picker === "github" && selectedRepo ? selectedRepo.htmlUrl : manualUrl.trim();
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}`,
product: { name: name.trim() },
audience,
creationMode: "import",
githubRepoUrl: repoUrl,
githubDefaultBranch: selectedRepo?.defaultBranch ?? null,
githubRepoId: selectedRepo?.id ?? null,
sourceData: {
audience, repoUrl,
via: picker === "github" ? "oauth" : "url",
ghLogin: picker === "github" ? connected && typeof connected === "object" ? connected.login : null : null,
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}/product`);
} catch {
toast.error("Something went wrong");
} finally {
setLoading(false);
}
};
const filteredRepos = connected && typeof connected === "object"
? connected.repos.filter(r => {
const q = filter.trim().toLowerCase();
if (!q) return true;
return r.fullName.toLowerCase().includes(q) || (r.description ?? "").toLowerCase().includes(q);
})
: [];
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
/>
<AudienceSelector value={audience} onChange={setAudience} />
{/* Repo source */}
{connected === undefined ? (
<div style={loadingBox}>Checking GitHub connection</div>
) : connected === null ? (
<NotConnectedBlock
onConnect={handleConnect}
picker={picker}
setPicker={setPicker}
manualUrl={manualUrl}
setManualUrl={setManualUrl}
/>
) : (
<ConnectedBlock
login={connected.login}
picker={picker}
setPicker={setPicker}
repos={filteredRepos}
filter={filter}
setFilter={setFilter}
selectedRepoId={selectedRepoId}
setSelectedRepoId={setSelectedRepoId}
manualUrl={manualUrl}
setManualUrl={setManualUrl}
onDisconnect={handleDisconnect}
/>
)}
<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>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Sub-blocks
// ─────────────────────────────────────────────────────────────────────────────
interface GhRepo {
id: number;
name: string;
fullName: string;
description: string | null;
defaultBranch: string;
htmlUrl: string;
private: boolean;
language: string | null;
pushedAt: string | null;
fork: boolean;
}
function NotConnectedBlock({
onConnect, picker, setPicker, manualUrl, setManualUrl,
}: {
onConnect: () => void;
picker: "github" | "url";
setPicker: (p: "github" | "url") => void;
manualUrl: string;
setManualUrl: (s: string) => void;
}) {
return (
<>
<FieldLabel>Where's the code?</FieldLabel>
<div style={{
padding: 14, borderRadius: 10, border: `1px solid ${JM.border}`,
background: JM.cream, marginBottom: 14,
display: "flex", alignItems: "center", gap: 12,
}}>
<div style={{
width: 34, height: 34, borderRadius: 8, background: "#1A1A1A",
display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
}}>
<GhMark />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: JM.ink }}>Connect your GitHub</div>
<div style={{ fontSize: 11.5, color: JM.mid, marginTop: 1 }}>
Pick from a list of your repos public or private.
</div>
</div>
<button
type="button"
onClick={onConnect}
style={{
padding: "7px 13px", borderRadius: 7,
background: "#1A1A1A", color: "#fff", border: "none",
fontSize: 12.5, fontWeight: 600, cursor: "pointer",
fontFamily: JM.fontSans, flexShrink: 0,
}}
>
Connect
</button>
</div>
<button
type="button"
onClick={() => setPicker(picker === "url" ? "github" : "url")}
style={linkButton}
>
{picker === "url" ? "I'd rather connect GitHub" : "Or paste a public repo URL instead →"}
</button>
{picker === "url" && (
<div style={{ marginTop: 10 }}>
<FieldLabel>Public GitHub URL</FieldLabel>
<TextInput
value={manualUrl}
onChange={setManualUrl}
placeholder="https://github.com/yourname/your-repo"
/>
<p style={{ fontSize: 11.5, color: JM.muted, marginTop: -8, marginBottom: 14, lineHeight: 1.5 }}>
Public repos work without connecting GitHub. Private repos need the connection above.
</p>
</div>
)}
</>
);
}
function ConnectedBlock({
login, picker, setPicker, repos, filter, setFilter,
selectedRepoId, setSelectedRepoId, manualUrl, setManualUrl, onDisconnect,
}: {
login: string;
picker: "github" | "url";
setPicker: (p: "github" | "url") => void;
repos: GhRepo[];
filter: string; setFilter: (s: string) => void;
selectedRepoId: number | null;
setSelectedRepoId: (n: number | null) => void;
manualUrl: string;
setManualUrl: (s: string) => void;
onDisconnect: () => void;
}) {
return (
<>
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "8px 12px", borderRadius: 8,
background: JM.cream, border: `1px solid ${JM.border}`,
marginBottom: 12, fontSize: 12, color: JM.mid, fontFamily: JM.fontSans,
}}>
<GhMark size={14} dark />
<span>Connected as <strong style={{ color: JM.ink, fontWeight: 600 }}>@{login}</strong></span>
<div style={{ flex: 1 }} />
<button type="button" onClick={onDisconnect} style={textBtn}>Disconnect</button>
</div>
{picker === "github" ? (
<>
<FieldLabel>Pick a repository</FieldLabel>
<input
type="text"
value={filter}
onChange={e => setFilter(e.target.value)}
placeholder={`Search ${repos.length > 0 ? `${repos.length} repos…` : "repos…"}`}
style={{
width: "100%", padding: "9px 12px", marginBottom: 8,
borderRadius: 7, border: `1px solid ${JM.border}`,
background: JM.inputBg, fontSize: 13,
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={{
maxHeight: 220, overflowY: "auto",
border: `1px solid ${JM.border}`, borderRadius: 8,
background: "#fff", marginBottom: 12,
}}>
{repos.length === 0 ? (
<div style={{ padding: 18, fontSize: 12.5, color: JM.muted, textAlign: "center" }}>
No matching repos
</div>
) : (
repos.map(r => {
const sel = r.id === selectedRepoId;
return (
<button
key={r.id}
type="button"
onClick={() => setSelectedRepoId(r.id)}
style={{
width: "100%", textAlign: "left",
padding: "9px 12px",
border: "none", borderBottom: `1px solid ${JM.border}`,
background: sel ? "#EEF2FF" : "transparent",
cursor: "pointer", fontFamily: JM.fontSans,
display: "flex", alignItems: "center", gap: 8,
}}
onMouseEnter={e => { if (!sel) e.currentTarget.style.background = JM.cream; }}
onMouseLeave={e => { if (!sel) e.currentTarget.style.background = "transparent"; }}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13, fontWeight: 600, color: JM.ink,
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
}}>
{r.fullName}
</div>
{r.description && (
<div style={{
fontSize: 11.5, color: JM.mid, marginTop: 1,
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
}}>
{r.description}
</div>
)}
</div>
<div style={{ display: "flex", gap: 5, flexShrink: 0 }}>
{r.private && <Badge>Private</Badge>}
{r.language && <Badge muted>{r.language}</Badge>}
</div>
</button>
);
})
)}
</div>
<button type="button" onClick={() => setPicker("url")} style={linkButton}>
Or paste a different URL
</button>
</>
) : (
<>
<FieldLabel>Public repo URL</FieldLabel>
<TextInput
value={manualUrl}
onChange={setManualUrl}
placeholder="https://github.com/yourname/your-repo"
/>
<button type="button" onClick={() => setPicker("github")} style={linkButton}>
Back to your repos
</button>
</>
)}
</>
);
}
function Badge({ children, muted }: { children: React.ReactNode; muted?: boolean }) {
return (
<span style={{
fontSize: 10, fontWeight: 600, padding: "2px 7px", borderRadius: 4,
background: muted ? "#F3F4F6" : "#FEF3C7",
color: muted ? "#6B7280" : "#92400E",
letterSpacing: "0.02em", fontFamily: JM.fontSans,
}}>{children}</span>
);
}
function GhMark({ size = 18, dark }: { size?: number; dark?: boolean }) {
return (
<svg width={size} height={size} viewBox="0 0 16 16" fill={dark ? "#1A1A1A" : "#fff"}>
<path fillRule="evenodd" d="M8 0C3.58 0 0 3.58 0 8a8 8 0 005.47 7.59c.4.07.55-.17.55-.38v-1.34c-2.23.48-2.7-1.07-2.7-1.07-.36-.92-.89-1.17-.89-1.17-.73-.5.05-.49.05-.49.81.06 1.24.83 1.24.83.72 1.23 1.88.87 2.34.66.07-.52.28-.87.5-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.13 0 0 .67-.21 2.2.82a7.6 7.6 0 014 0c1.53-1.04 2.2-.82 2.2-.82.44 1.11.16 1.93.08 2.13.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48v2.2c0 .21.15.46.55.38A8.001 8.001 0 0016 8c0-4.42-3.58-8-8-8z"/>
</svg>
);
}
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>
);
}
const linkButton: React.CSSProperties = {
background: "none", border: "none", padding: 0,
fontSize: 12, color: JM.indigo, cursor: "pointer",
fontFamily: JM.fontSans, fontWeight: 500,
marginBottom: 14,
};
const textBtn: React.CSSProperties = {
background: "none", border: "none", padding: 0,
fontSize: 11.5, color: JM.muted, cursor: "pointer",
fontFamily: JM.fontSans,
textDecoration: "underline",
};
const loadingBox: React.CSSProperties = {
padding: "14px 16px", borderRadius: 8,
background: JM.cream, border: `1px solid ${JM.border}`,
fontSize: 12.5, color: JM.mid, marginBottom: 14, fontFamily: JM.fontSans,
};