From ab100f2e76866a1f8827f3a048e63c18e2ce65a3 Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Fri, 6 Mar 2026 12:48:28 -0800 Subject: [PATCH] feat: implement 4 project type flows with unique AI experiences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New multi-step CreateProjectFlow replaces 2-step modal with TypeSelector and 4 setup components (Fresh Idea, Chat Import, Code Import, Migrate) - overview/page.tsx routes to unique main component per creationMode - FreshIdeaMain: wraps AtlasChat with post-discovery decision banner (Generate PRD vs Plan MVP Test) - ChatImportMain: 3-stage flow (intake → extracting → review) with editable insight buckets (decisions, ideas, questions, architecture, users) - CodeImportMain: 4-stage flow (input → cloning → mapping → surfaces) with architecture map and surface selection - MigrateMain: 5-stage flow with audit, review, planning, and migration plan doc with checkbox-tracked tasks and non-destructive warning banner - New API routes: analyze-chats, analyze-repo, analysis-status, generate-migration-plan (all using Gemini) - ProjectShell: accepts creationMode prop, filters/renames tabs per type (code-import hides PRD, migration hides PRD/Grow/Insights, renames Atlas tab) - Right panel adapts content based on creationMode Made-with: Cursor --- .../project/[projectId]/layout.tsx | 3 + .../project/[projectId]/overview/page.tsx | 129 +++---- .../[projectId]/analysis-status/route.ts | 37 ++ .../[projectId]/analyze-chats/route.ts | 126 ++++++ .../[projectId]/analyze-repo/route.ts | 216 +++++++++++ .../generate-migration-plan/route.ts | 139 +++++++ components/layout/project-shell.tsx | 150 +++++--- components/project-creation-modal.tsx | 282 +------------- .../project-creation/ChatImportSetup.tsx | 84 ++++ .../project-creation/CodeImportSetup.tsx | 100 +++++ .../project-creation/CreateProjectFlow.tsx | 106 +++++ .../project-creation/FreshIdeaSetup.tsx | 91 +++++ components/project-creation/MigrateSetup.tsx | 159 ++++++++ components/project-creation/TypeSelector.tsx | 145 +++++++ components/project-creation/setup-shared.tsx | 153 ++++++++ components/project-main/ChatImportMain.tsx | 330 ++++++++++++++++ components/project-main/CodeImportMain.tsx | 363 ++++++++++++++++++ components/project-main/FreshIdeaMain.tsx | 133 +++++++ components/project-main/MigrateMain.tsx | 353 +++++++++++++++++ 19 files changed, 2696 insertions(+), 403 deletions(-) create mode 100644 app/api/projects/[projectId]/analysis-status/route.ts create mode 100644 app/api/projects/[projectId]/analyze-chats/route.ts create mode 100644 app/api/projects/[projectId]/analyze-repo/route.ts create mode 100644 app/api/projects/[projectId]/generate-migration-plan/route.ts create mode 100644 components/project-creation/ChatImportSetup.tsx create mode 100644 components/project-creation/CodeImportSetup.tsx create mode 100644 components/project-creation/CreateProjectFlow.tsx create mode 100644 components/project-creation/FreshIdeaSetup.tsx create mode 100644 components/project-creation/MigrateSetup.tsx create mode 100644 components/project-creation/TypeSelector.tsx create mode 100644 components/project-creation/setup-shared.tsx create mode 100644 components/project-main/ChatImportMain.tsx create mode 100644 components/project-main/CodeImportMain.tsx create mode 100644 components/project-main/FreshIdeaMain.tsx create mode 100644 components/project-main/MigrateMain.tsx diff --git a/app/[workspace]/project/[projectId]/layout.tsx b/app/[workspace]/project/[projectId]/layout.tsx index bffa3e7..2e27008 100644 --- a/app/[workspace]/project/[projectId]/layout.tsx +++ b/app/[workspace]/project/[projectId]/layout.tsx @@ -11,6 +11,7 @@ interface ProjectData { createdAt?: string; updatedAt?: string; featureCount?: number; + creationMode?: "fresh" | "chat-import" | "code-import" | "migration"; } async function getProjectData(projectId: string): Promise { @@ -31,6 +32,7 @@ async function getProjectData(projectId: string): Promise { createdAt: created_at, updatedAt: updated_at, featureCount: Array.isArray(data?.features) ? data.features.length : (data?.featureCount ?? 0), + creationMode: data?.creationMode ?? "fresh", }; } } catch (error) { @@ -62,6 +64,7 @@ export default async function ProjectLayout({ createdAt={project.createdAt} updatedAt={project.updatedAt} featureCount={project.featureCount} + creationMode={project.creationMode} > {children} diff --git a/app/[workspace]/project/[projectId]/overview/page.tsx b/app/[workspace]/project/[projectId]/overview/page.tsx index c06c2c1..9afa1e4 100644 --- a/app/[workspace]/project/[projectId]/overview/page.tsx +++ b/app/[workspace]/project/[projectId]/overview/page.tsx @@ -3,71 +3,33 @@ import { useEffect, useState } from "react"; import { useParams } from "next/navigation"; import { useSession } from "next-auth/react"; -import { AtlasChat } from "@/components/AtlasChat"; -import { OrchestratorChat } from "@/components/OrchestratorChat"; import { Loader2 } from "lucide-react"; - -function MobileQRButton({ projectId, workspace }: { projectId: string; workspace: string }) { - const [show, setShow] = useState(false); - const url = typeof window !== "undefined" - ? `${window.location.origin}/${workspace}/project/${projectId}/overview` - : ""; - const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(url)}&bgcolor=f6f4f0&color=1a1a1a&margin=2`; - - return ( -
- - {show && ( -
- QR code -

- Scan to open Atlas on your phone -

-

- {url} -

- -
- )} -
- ); -} +import { FreshIdeaMain } from "@/components/project-main/FreshIdeaMain"; +import { ChatImportMain } from "@/components/project-main/ChatImportMain"; +import { CodeImportMain } from "@/components/project-main/CodeImportMain"; +import { MigrateMain } from "@/components/project-main/MigrateMain"; interface Project { id: string; productName: string; + name?: string; stage?: "discovery" | "architecture" | "building" | "active"; + creationMode?: "fresh" | "chat-import" | "code-import" | "migration"; + creationStage?: string; + sourceData?: { + chatText?: string; + repoUrl?: string; + liveUrl?: string; + hosting?: string; + description?: string; + }; + analysisResult?: Record; + migrationPlan?: string; } export default function ProjectOverviewPage() { const params = useParams(); const projectId = params.projectId as string; - const workspace = params.workspace as string; const { status: authStatus } = useSession(); const [project, setProject] = useState(null); const [loading, setLoading] = useState(true); @@ -78,8 +40,8 @@ export default function ProjectOverviewPage() { return; } fetch(`/api/projects/${projectId}`) - .then((r) => r.json()) - .then((d) => setProject(d.project)) + .then(r => r.json()) + .then(d => setProject(d.project)) .catch(() => {}) .finally(() => setLoading(false)); }, [authStatus, projectId]); @@ -100,21 +62,50 @@ export default function ProjectOverviewPage() { ); } - return ( -
- {/* Desktop-only: Open on phone button */} - -
- -
+ const projectName = project.productName || project.name || "Untitled"; + const mode = project.creationMode ?? "fresh"; - [0]["analysisResult"]} /> -
+ ); + } + + if (mode === "code-import") { + return ( + + ); + } + + if (mode === "migration") { + return ( + + ); + } + + // Default: "fresh" — wraps AtlasChat with decision banner + return ( + ); } diff --git a/app/api/projects/[projectId]/analysis-status/route.ts b/app/api/projects/[projectId]/analysis-status/route.ts new file mode 100644 index 0000000..72d20ce --- /dev/null +++ b/app/api/projects/[projectId]/analysis-status/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth/authOptions'; +import { query } from '@/lib/db-postgres'; + +export async function GET( + _req: Request, + { params }: { params: Promise<{ projectId: string }> } +) { + try { + const { projectId } = await params; + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const rows = await query<{ data: Record }>( + `SELECT p.data FROM fs_projects p + JOIN fs_users u ON u.id = p.user_id + WHERE p.id = $1::text AND u.data->>'email' = $2::text LIMIT 1`, + [projectId, session.user.email] + ); + + if (rows.length === 0) { + return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + } + + const data = rows[0].data ?? {}; + const stage = (data.analysisStage as string) ?? 'cloning'; + const analysisResult = stage === 'done' ? data.analysisResult : undefined; + + return NextResponse.json({ stage, analysisResult }); + } catch (err) { + console.error('[analysis-status]', err); + return NextResponse.json({ error: 'Internal error' }, { status: 500 }); + } +} diff --git a/app/api/projects/[projectId]/analyze-chats/route.ts b/app/api/projects/[projectId]/analyze-chats/route.ts new file mode 100644 index 0000000..3585d04 --- /dev/null +++ b/app/api/projects/[projectId]/analyze-chats/route.ts @@ -0,0 +1,126 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth/authOptions'; +import { query } from '@/lib/db-postgres'; + +export const maxDuration = 60; + +const GEMINI_API_KEY = process.env.GOOGLE_API_KEY || ''; +const GEMINI_MODEL = process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp'; +const GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models'; + +async function callGemini(prompt: string): Promise { + const res = await fetch(`${GEMINI_BASE_URL}/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contents: [{ parts: [{ text: prompt }] }], + generationConfig: { temperature: 0.2, maxOutputTokens: 4096 }, + }), + }); + const data = await res.json(); + const text = data?.candidates?.[0]?.content?.parts?.[0]?.text ?? ''; + return text; +} + +function parseJsonBlock(raw: string): unknown { + const trimmed = raw.trim(); + const cleaned = trimmed.startsWith('```') + ? trimmed.replace(/^```(?:json)?/i, '').replace(/```$/, '').trim() + : trimmed; + return JSON.parse(cleaned); +} + +export async function POST( + req: Request, + { params }: { params: Promise<{ projectId: string }> } +) { + try { + const { projectId } = await params; + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await req.json() as { chatText?: string }; + const chatText = body.chatText?.trim() || ''; + + if (!chatText) { + return NextResponse.json({ error: 'chatText is required' }, { status: 400 }); + } + + // Verify project ownership + const rows = await query<{ data: Record }>( + `SELECT p.data FROM fs_projects p + JOIN fs_users u ON u.id = p.user_id + WHERE p.id = $1::text AND u.data->>'email' = $2::text LIMIT 1`, + [projectId, session.user.email] + ); + if (rows.length === 0) { + return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + } + + const extractionPrompt = `You are a product analyst. A founder has pasted AI chat conversation history below. + +Extract and categorise the following from those conversations. Return ONLY valid JSON — no markdown, no explanation. + +JSON schema: +{ + "decisions": ["string — concrete decisions already made"], + "ideas": ["string — product ideas and features mentioned"], + "openQuestions": ["string — unresolved questions that still need answers"], + "architecture": ["string — technical architecture notes, stack choices, infra decisions"], + "targetUsers": ["string — user segments, personas, or target audiences mentioned"] +} + +Each array can be empty if nothing was found for that category. Extract real content — be specific and concise. Max 10 items per bucket. + +--- CHAT HISTORY START --- +${chatText.slice(0, 12000)} +--- CHAT HISTORY END --- + +Return only the JSON object:`; + + const raw = await callGemini(extractionPrompt); + + let analysisResult: { + decisions: string[]; + ideas: string[]; + openQuestions: string[]; + architecture: string[]; + targetUsers: string[]; + }; + + try { + analysisResult = parseJsonBlock(raw) as typeof analysisResult; + } catch { + // Fallback: return empty buckets with a note + analysisResult = { + decisions: [], + ideas: [], + openQuestions: ["Could not parse extracted insights — try pasting more structured conversation"], + architecture: [], + targetUsers: [], + }; + } + + // Save analysis result to project data + const current = rows[0].data ?? {}; + const updated = { + ...current, + analysisResult, + creationStage: 'review', + updatedAt: new Date().toISOString(), + }; + + await query( + `UPDATE fs_projects SET data = $2::jsonb WHERE id = $1::text`, + [projectId, JSON.stringify(updated)] + ); + + return NextResponse.json({ analysisResult }); + } catch (err) { + console.error('[analyze-chats]', err); + return NextResponse.json({ error: 'Internal error' }, { status: 500 }); + } +} diff --git a/app/api/projects/[projectId]/analyze-repo/route.ts b/app/api/projects/[projectId]/analyze-repo/route.ts new file mode 100644 index 0000000..2e500d5 --- /dev/null +++ b/app/api/projects/[projectId]/analyze-repo/route.ts @@ -0,0 +1,216 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth/authOptions'; +import { query } from '@/lib/db-postgres'; +import { execSync } from 'child_process'; +import { existsSync, readdirSync, readFileSync, statSync, rmSync } from 'fs'; +import { join } from 'path'; + +export const maxDuration = 120; + +const GEMINI_API_KEY = process.env.GOOGLE_API_KEY || ''; +const GEMINI_MODEL = process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp'; +const GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models'; + +async function callGemini(prompt: string): Promise { + const res = await fetch(`${GEMINI_BASE_URL}/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contents: [{ parts: [{ text: prompt }] }], + generationConfig: { temperature: 0.2, maxOutputTokens: 6000 }, + }), + }); + const data = await res.json(); + return data?.candidates?.[0]?.content?.parts?.[0]?.text ?? ''; +} + +function parseJsonBlock(raw: string): unknown { + const trimmed = raw.trim(); + const cleaned = trimmed.startsWith('```') + ? trimmed.replace(/^```(?:json)?/i, '').replace(/```$/, '').trim() + : trimmed; + return JSON.parse(cleaned); +} + +// Read a file safely, returning empty string on failure +function safeRead(path: string, maxBytes = 8000): string { + try { + if (!existsSync(path)) return ''; + const content = readFileSync(path, 'utf8'); + return content.slice(0, maxBytes); + } catch { + return ''; + } +} + +// Walk directory and collect file listing (relative paths), limited to avoid huge outputs +function walkDir(dir: string, depth = 0, maxDepth = 4, acc: string[] = []): string[] { + if (depth > maxDepth) return acc; + try { + const entries = readdirSync(dir, { withFileTypes: true }); + for (const e of entries) { + if (e.name.startsWith('.') || e.name === 'node_modules' || e.name === '__pycache__' || e.name === '.git') continue; + const full = join(dir, e.name); + const rel = full.replace(dir + '/', ''); + if (e.isDirectory()) { + acc.push(rel + '/'); + walkDir(full, depth + 1, maxDepth, acc); + } else { + acc.push(rel); + } + } + } catch { /* skip */ } + return acc; +} + +async function updateStage(projectId: string, currentData: Record, stage: string) { + const updated = { ...currentData, analysisStage: stage, updatedAt: new Date().toISOString() }; + await query( + `UPDATE fs_projects SET data = $2::jsonb WHERE id = $1::text`, + [projectId, JSON.stringify(updated)] + ); + return updated; +} + +export async function POST( + req: Request, + { params }: { params: Promise<{ projectId: string }> } +) { + try { + const { projectId } = await params; + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await req.json() as { repoUrl?: string }; + const repoUrl = body.repoUrl?.trim() || ''; + + if (!repoUrl.startsWith('http')) { + return NextResponse.json({ error: 'Invalid repository URL' }, { status: 400 }); + } + + // Verify ownership + const rows = await query<{ data: Record }>( + `SELECT p.data FROM fs_projects p + JOIN fs_users u ON u.id = p.user_id + WHERE p.id = $1::text AND u.data->>'email' = $2::text LIMIT 1`, + [projectId, session.user.email] + ); + if (rows.length === 0) { + return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + } + + let currentData = rows[0].data ?? {}; + currentData = await updateStage(projectId, currentData, 'cloning'); + + // Clone repo into temp dir (fire and forget — status is polled separately) + const tmpDir = `/tmp/vibn-${projectId}`; + + // Run async so the request returns quickly and client can poll + setImmediate(async () => { + try { + // Clean up any existing clone + if (existsSync(tmpDir)) { + rmSync(tmpDir, { recursive: true, force: true }); + } + + execSync(`git clone --depth=1 "${repoUrl}" "${tmpDir}"`, { + timeout: 60_000, + stdio: 'ignore', + }); + + let data = { ...currentData }; + data = await updateStage(projectId, data, 'reading'); + + // Read key files + const manifest: Record = {}; + const keyFiles = [ + 'package.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', + 'requirements.txt', 'Pipfile', 'pyproject.toml', + 'Dockerfile', 'docker-compose.yml', 'docker-compose.yaml', + 'README.md', '.env.example', '.env.sample', + 'next.config.js', 'next.config.ts', 'next.config.mjs', + 'vite.config.ts', 'vite.config.js', + 'tsconfig.json', + 'prisma/schema.prisma', 'schema.prisma', + ]; + for (const f of keyFiles) { + const content = safeRead(join(tmpDir, f)); + if (content) manifest[f] = content; + } + + const fileListing = walkDir(tmpDir).slice(0, 300).join('\n'); + + data = await updateStage(projectId, data, 'analyzing'); + + const analysisPrompt = `You are a senior full-stack architect. Analyse this repository and return a structured architecture map. + +File listing (top-level): +${fileListing} + +Key file contents: +${Object.entries(manifest).map(([k, v]) => `\n### ${k}\n${v}`).join('')} + +Return ONLY valid JSON with this structure: +{ + "summary": "1-2 sentence project summary", + "rows": [ + { "category": "Tech Stack", "item": "Next.js 15", "status": "found", "detail": "next.config.ts present" }, + { "category": "Database", "item": "PostgreSQL", "status": "found", "detail": "prisma/schema.prisma detected" }, + { "category": "Auth", "item": "Authentication", "status": "missing", "detail": "No auth library detected" } + ], + "suggestedSurfaces": ["marketing", "admin"] +} + +Categories to cover: Tech Stack, Infrastructure, Database, API Surface, Frontend, Auth, Third-party, Missing / Gaps +Status values: "found", "partial", "missing" +suggestedSurfaces should only include items from: ["marketing", "web-app", "admin", "api"] +Suggest surfaces that are MISSING or incomplete in the current codebase. + +Return only the JSON:`; + + const raw = await callGemini(analysisPrompt); + let analysisResult; + try { + analysisResult = parseJsonBlock(raw); + } catch { + analysisResult = { + summary: 'Could not fully parse the repository structure.', + rows: [{ category: 'Tech Stack', item: 'Repository detected', status: 'found', detail: fileListing.split('\n').slice(0, 5).join(', ') }], + suggestedSurfaces: ['marketing'], + }; + } + + // Save result and mark done + const finalData = { + ...data, + analysisStage: 'done', + analysisResult, + creationStage: 'mapping', + sourceData: { ...(data.sourceData as object || {}), repoUrl }, + updatedAt: new Date().toISOString(), + }; + await query( + `UPDATE fs_projects SET data = $2::jsonb WHERE id = $1::text`, + [projectId, JSON.stringify(finalData)] + ); + } catch (err) { + console.error('[analyze-repo] background error', err); + await query( + `UPDATE fs_projects SET data = $2::jsonb WHERE id = $1::text`, + [projectId, JSON.stringify({ ...currentData, analysisStage: 'error', analysisError: String(err) })] + ); + } finally { + // Clean up + try { if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ok */ } + } + }); + + return NextResponse.json({ started: true }); + } catch (err) { + console.error('[analyze-repo]', err); + return NextResponse.json({ error: 'Internal error' }, { status: 500 }); + } +} diff --git a/app/api/projects/[projectId]/generate-migration-plan/route.ts b/app/api/projects/[projectId]/generate-migration-plan/route.ts new file mode 100644 index 0000000..19b4189 --- /dev/null +++ b/app/api/projects/[projectId]/generate-migration-plan/route.ts @@ -0,0 +1,139 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth/authOptions'; +import { query } from '@/lib/db-postgres'; + +export const maxDuration = 120; + +const GEMINI_API_KEY = process.env.GOOGLE_API_KEY || ''; +const GEMINI_MODEL = process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp'; +const GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models'; + +async function callGemini(prompt: string): Promise { + const res = await fetch(`${GEMINI_BASE_URL}/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contents: [{ parts: [{ text: prompt }] }], + generationConfig: { temperature: 0.3, maxOutputTokens: 8000 }, + }), + }); + const data = await res.json(); + return data?.candidates?.[0]?.content?.parts?.[0]?.text ?? ''; +} + +export async function POST( + req: Request, + { params }: { params: Promise<{ projectId: string }> } +) { + try { + const { projectId } = await params; + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await req.json() as { + analysisResult?: Record; + sourceData?: { repoUrl?: string; liveUrl?: string; hosting?: string }; + }; + + // Verify ownership + const rows = await query<{ data: Record }>( + `SELECT p.data FROM fs_projects p + JOIN fs_users u ON u.id = p.user_id + WHERE p.id = $1::text AND u.data->>'email' = $2::text LIMIT 1`, + [projectId, session.user.email] + ); + if (rows.length === 0) { + return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + } + + const current = rows[0].data ?? {}; + const projectName = (current.productName as string) || (current.name as string) || 'the product'; + const { analysisResult, sourceData } = body; + + const prompt = `You are a senior DevOps and platform migration architect. Generate a comprehensive, phased migration plan in Markdown for migrating an existing product into a new infrastructure (VIBN — a self-hosted PaaS). + +Product: ${projectName} +Repo: ${sourceData?.repoUrl || 'Not provided'} +Live URL: ${sourceData?.liveUrl || 'Not provided'} +Current hosting: ${sourceData?.hosting || 'Unknown'} + +Architecture audit summary: +${analysisResult?.summary || 'No audit data provided.'} + +Detected components: +${JSON.stringify(analysisResult?.rows || [], null, 2).slice(0, 3000)} + +Generate a complete migration plan with exactly these 4 phases: + +# ${projectName} — Migration Plan + +## Overview +Brief 2-3 sentence description of the migration approach and guiding principle (non-destructive duplication). + +## Phase 1: Mirror +Set up parallel infrastructure on VIBN without touching production. +- [ ] Clone repository to VIBN Gitea +- [ ] Configure Coolify application +- [ ] Set up identical database schema +- [ ] Configure environment variables +- [ ] Verify build passes + +## Phase 2: Validate +Run both systems in parallel and compare outputs. +- [ ] Route 5% of traffic to new infrastructure (or test internally) +- [ ] Compare API responses between old and new +- [ ] Run full end-to-end test suite +- [ ] Validate data sync between databases +- [ ] Sign off on performance benchmarks + +## Phase 3: Cutover +Redirect production traffic to the new infrastructure. +- [ ] Update DNS records to point to VIBN load balancer +- [ ] Monitor error rates and latency for 24h +- [ ] Validate all integrations (auth, payments, third-party APIs) +- [ ] Keep old infrastructure on standby for 7 days + +## Phase 4: Decommission +Remove old infrastructure after successful validation period. +- [ ] Confirm all data has been migrated +- [ ] Archive old repository access +- [ ] Terminate old hosting resources +- [ ] Update all internal documentation + +## Risk Register +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| Database migration failure | Medium | High | Full backup before any migration step | +| DNS propagation delay | Low | Medium | Use low TTL before cutover | +| Third-party integration breakage | Medium | High | Test all webhooks and OAuth in Phase 2 | + +## Rollback Plan +At any phase, revert by: pointing DNS back to original infrastructure. Data written during parallel run must be synced back manually. Old infrastructure MUST remain live until Phase 4 completes. + +--- + +Write a thorough, specific plan. Use real details from the audit where available. Every checklist item should be actionable. Return only the Markdown document.`; + + const migrationPlan = await callGemini(prompt); + + // Save to project + const updated = { + ...current, + migrationPlan, + creationStage: 'plan', + updatedAt: new Date().toISOString(), + }; + await query( + `UPDATE fs_projects SET data = $2::jsonb WHERE id = $1::text`, + [projectId, JSON.stringify(updated)] + ); + + return NextResponse.json({ migrationPlan }); + } catch (err) { + console.error('[generate-migration-plan]', err); + return NextResponse.json({ error: 'Internal error' }, { status: 500 }); + } +} diff --git a/components/layout/project-shell.tsx b/components/layout/project-shell.tsx index e908cf0..100c3b2 100644 --- a/components/layout/project-shell.tsx +++ b/components/layout/project-shell.tsx @@ -19,9 +19,10 @@ interface ProjectShellProps { createdAt?: string; updatedAt?: string; featureCount?: number; + creationMode?: "fresh" | "chat-import" | "code-import" | "migration"; } -const TABS = [ +const ALL_TABS = [ { id: "overview", label: "Atlas", path: "overview" }, { id: "prd", label: "PRD", path: "prd" }, { id: "design", label: "Design", path: "design" }, @@ -32,6 +33,23 @@ const TABS = [ { id: "settings", label: "Settings", path: "settings" }, ]; +function getTabsForMode( + mode: "fresh" | "chat-import" | "code-import" | "migration" = "fresh" +) { + switch (mode) { + case "code-import": + // Hide PRD — this project already has code; goal is go-to-market surfaces + return ALL_TABS.filter(t => t.id !== "prd"); + case "migration": + // Hide PRD, rename overview, hide Grow and Insights (less relevant) + return ALL_TABS + .filter(t => !["prd", "grow", "insights"].includes(t.id)) + .map(t => t.id === "overview" ? { ...t, label: "Migration Plan" } : t); + default: + return ALL_TABS; + } +} + const DISCOVERY_PHASES = [ { id: "big_picture", label: "Big Picture" }, { id: "users_personas", label: "Users & Personas" }, @@ -101,8 +119,10 @@ export function ProjectShell({ createdAt, updatedAt, featureCount = 0, + creationMode, }: ProjectShellProps) { const pathname = usePathname(); + const TABS = getTabsForMode(creationMode); const activeTab = TABS.find((t) => pathname?.includes(`/${t.path}`))?.id ?? "overview"; const progress = projectProgress ?? 0; @@ -250,68 +270,84 @@ export function ProjectShell({ fontFamily: "Outfit, sans-serif", display: activeTab === "design" ? "none" : undefined, }}> - {/* Discovery phases */} - Discovery - {DISCOVERY_PHASES.map((phase, i) => { - const isDone = savedPhaseIds.has(phase.id); - const isActive = !isDone && i === firstUnsavedIdx; - return ( -
-
- {isDone ? "✓" : isActive ? "→" : i + 1} -
- - {phase.label} - + {/* Right panel content — varies by creation mode */} + {(creationMode === "code-import" || creationMode === "migration") ? ( + <> + + {creationMode === "migration" ? "Migration" : "Import"} + +
+ {creationMode === "migration" + ? "Atlas will audit your existing product and generate a safe, phased migration plan." + : "Atlas will clone your repository and map the architecture, then suggest surfaces to build."}
- ); - })} - -
- - {/* Captured data — summaries from saved phases */} - Captured - {savedPhases.length > 0 ? ( - savedPhases.map((p) => ( -
-
- {p.title} -
-
- {p.summary} -
-
- )) + ) : ( -

- Atlas will capture key details here as you chat. -

+ <> + {/* Discovery phases */} + Discovery + {DISCOVERY_PHASES.map((phase, i) => { + const isDone = savedPhaseIds.has(phase.id); + const isActive = !isDone && i === firstUnsavedIdx; + return ( +
+
+ {isDone ? "✓" : isActive ? "→" : i + 1} +
+ + {phase.label} + +
+ ); + })} + +
+ + {/* Captured data — summaries from saved phases */} + Captured + {savedPhases.length > 0 ? ( + savedPhases.map((p) => ( +
+
+ {p.title} +
+
+ {p.summary} +
+
+ )) + ) : ( +

+ Atlas will capture key details here as you chat. +

+ )} + )}
- {/* Project info */} + {/* Project info — always shown */} Project Info {[ { k: "Created", v: timeAgo(createdAt) }, diff --git a/components/project-creation-modal.tsx b/components/project-creation-modal.tsx index dfad91e..22e54cc 100644 --- a/components/project-creation-modal.tsx +++ b/components/project-creation-modal.tsx @@ -1,278 +1,6 @@ -'use client'; +"use client"; -import { useState, useEffect, useRef } from 'react'; -import { createPortal } from 'react-dom'; -import { useRouter } from 'next/navigation'; -import { toast } from 'sonner'; - -interface ProjectCreationModalProps { - open: boolean; - onOpenChange: (open: boolean) => void; - workspace: string; - initialWorkspacePath?: string; -} - -const PROJECT_TYPES = [ - { id: 'web-app', label: 'Web App', icon: '⬡', desc: 'SaaS product users log into — dashboards, accounts, core features' }, - { id: 'website', label: 'Website', icon: '◎', desc: 'Marketing site, landing page, or content-driven public site' }, - { id: 'marketplace', label: 'Marketplace', icon: '⇄', desc: 'Two-sided platform connecting buyers and sellers or providers' }, - { id: 'mobile', label: 'Mobile App', icon: '▢', desc: 'iOS and Android app — touch-first, native feel' }, - { id: 'internal', label: 'Internal Tool', icon: '◫', desc: 'Admin panel, ops dashboard, or business process tool' }, - { id: 'ai-product', label: 'AI Product', icon: '◈', desc: 'AI-native product — copilot, agent, or model-powered workflow' }, -]; - -export function ProjectCreationModal({ open, onOpenChange, workspace }: ProjectCreationModalProps) { - const router = useRouter(); - const [step, setStep] = useState<1 | 2>(1); - const [productName, setProductName] = useState(''); - const [projectType, setProjectType] = useState(null); - const [loading, setLoading] = useState(false); - const inputRef = useRef(null); - - useEffect(() => { - if (open) { - setStep(1); - setProductName(''); - setProjectType(null); - setLoading(false); - setTimeout(() => inputRef.current?.focus(), 80); - } - }, [open]); - - useEffect(() => { - if (!open) return; - const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onOpenChange(false); }; - window.addEventListener('keydown', handler); - return () => window.removeEventListener('keydown', handler); - }, [open, onOpenChange]); - - const handleCreate = async () => { - if (!productName.trim() || !projectType) return; - setLoading(true); - try { - const res = await fetch('/api/projects/create', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - projectName: productName.trim(), - projectType, - slug: productName.toLowerCase().replace(/[^a-z0-9]+/g, '-'), - product: { name: productName.trim(), type: projectType }, - }), - }); - if (!res.ok) { - const err = await res.json(); - toast.error(err.error || 'Failed to create project'); - return; - } - const data = await res.json(); - onOpenChange(false); - router.push(`/${workspace}/project/${data.projectId}/overview`); - } catch { - toast.error('Something went wrong'); - } finally { - setLoading(false); - } - }; - - if (!open) return null; - - return createPortal( - <> - {/* Backdrop */} -
onOpenChange(false)} - style={{ - position: 'fixed', inset: 0, zIndex: 50, - background: 'rgba(26,26,26,0.35)', - animation: 'fadeIn 0.15s ease', - }} - /> - - {/* Modal */} -
-
e.stopPropagation()} - style={{ - background: '#fff', borderRadius: 14, - boxShadow: '0 8px 40px rgba(26,26,26,0.14)', - padding: '32px 36px', - width: '100%', maxWidth: step === 2 ? 560 : 460, - fontFamily: 'Outfit, sans-serif', - pointerEvents: 'all', - animation: 'slideUp 0.18s cubic-bezier(0.4,0,0.2,1)', - transition: 'max-width 0.2s ease', - }} - > - - - {/* Header */} -
-
- {step === 2 && ( - - )} -
-

- {step === 1 ? 'New project' : `What are you building?`} -

-

- {step === 1 ? 'Give your project a name to get started.' : `Choose the type that best fits "${productName}".`} -

-
-
- -
- - {/* Step 1 — Name */} - {step === 1 && ( -
- - setProductName(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter' && productName.trim()) setStep(2); }} - placeholder="e.g. Foxglove, Meridian, OpsAI…" - style={{ - width: '100%', padding: '11px 14px', marginBottom: 16, - borderRadius: 8, border: '1px solid #e0dcd4', - background: '#faf8f5', fontSize: '0.9rem', - fontFamily: 'Outfit, sans-serif', color: '#1a1a1a', - outline: 'none', transition: 'border-color 0.12s', - boxSizing: 'border-box', - }} - onFocus={e => (e.currentTarget.style.borderColor = '#1a1a1a')} - onBlur={e => (e.currentTarget.style.borderColor = '#e0dcd4')} - /> - -
- )} - - {/* Step 2 — Project type */} - {step === 2 && ( -
-
- {PROJECT_TYPES.map(type => { - const isSelected = projectType === type.id; - return ( - - ); - })} -
- - -
- )} -
-
- , - document.body - ); -} +// Re-export the new multi-step creation flow as a drop-in replacement +// for the original 2-step ProjectCreationModal. +export { CreateProjectFlow as ProjectCreationModal } from "./project-creation/CreateProjectFlow"; +export type { CreationMode } from "./project-creation/CreateProjectFlow"; diff --git a/components/project-creation/ChatImportSetup.tsx b/components/project-creation/ChatImportSetup.tsx new file mode 100644 index 0000000..c56be99 --- /dev/null +++ b/components/project-creation/ChatImportSetup.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +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 ( +
+ + + Project name + + + Paste your chat history +