feat: implement 4 project type flows with unique AI experiences
- 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
This commit is contained in:
@@ -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<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(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 */}
|
||||
<div
|
||||
onClick={() => onOpenChange(false)}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 50,
|
||||
background: 'rgba(26,26,26,0.35)',
|
||||
animation: 'fadeIn 0.15s ease',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 51,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 24, pointerEvents: 'none',
|
||||
}}>
|
||||
<div
|
||||
onClick={e => 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',
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes fadeIn { from { opacity:0; } to { opacity:1; } }
|
||||
@keyframes slideUp { from { opacity:0; transform:translateY(12px); } to { opacity:1; transform:translateY(0); } }
|
||||
@keyframes spin { to { transform:rotate(360deg); } }
|
||||
`}</style>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
{step === 2 && (
|
||||
<button
|
||||
onClick={() => setStep(1)}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: '#a09a90', fontSize: '1rem', padding: '2px 4px',
|
||||
borderRadius: 4, transition: 'color 0.12s', lineHeight: 1,
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = '#1a1a1a')}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = '#a09a90')}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
)}
|
||||
<div>
|
||||
<h2 style={{ fontFamily: 'Newsreader, serif', fontSize: '1.3rem', fontWeight: 400, color: '#1a1a1a', marginBottom: 2 }}>
|
||||
{step === 1 ? 'New project' : `What are you building?`}
|
||||
</h2>
|
||||
<p style={{ fontSize: '0.78rem', color: '#a09a90' }}>
|
||||
{step === 1 ? 'Give your project a name to get started.' : `Choose the type that best fits "${productName}".`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: '#b5b0a6', fontSize: '1.1rem', lineHeight: 1,
|
||||
padding: '2px 4px', borderRadius: 4, transition: 'color 0.12s', flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = '#6b6560')}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = '#b5b0a6')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Step 1 — Name */}
|
||||
{step === 1 && (
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.72rem', fontWeight: 600, color: '#6b6560', marginBottom: 7, letterSpacing: '0.02em' }}>
|
||||
Project name
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={productName}
|
||||
onChange={e => 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')}
|
||||
/>
|
||||
<button
|
||||
onClick={() => { if (productName.trim()) setStep(2); }}
|
||||
disabled={!productName.trim()}
|
||||
style={{
|
||||
width: '100%', padding: '12px',
|
||||
borderRadius: 8, border: 'none',
|
||||
background: productName.trim() ? '#1a1a1a' : '#e0dcd4',
|
||||
color: productName.trim() ? '#fff' : '#b5b0a6',
|
||||
fontSize: '0.88rem', fontWeight: 600,
|
||||
fontFamily: 'Outfit, sans-serif',
|
||||
cursor: productName.trim() ? 'pointer' : 'not-allowed',
|
||||
transition: 'opacity 0.15s, background 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { if (productName.trim()) (e.currentTarget.style.opacity = '0.85'); }}
|
||||
onMouseLeave={e => { (e.currentTarget.style.opacity = '1'); }}
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2 — Project type */}
|
||||
{step === 2 && (
|
||||
<div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginBottom: 20 }}>
|
||||
{PROJECT_TYPES.map(type => {
|
||||
const isSelected = projectType === type.id;
|
||||
return (
|
||||
<button
|
||||
key={type.id}
|
||||
onClick={() => setProjectType(type.id)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 12,
|
||||
padding: '14px 16px', borderRadius: 10, textAlign: 'left',
|
||||
border: `1px solid ${isSelected ? '#1a1a1a' : '#e8e4dc'}`,
|
||||
background: isSelected ? '#1a1a1a08' : '#fff',
|
||||
boxShadow: isSelected ? '0 0 0 1px #1a1a1a' : '0 1px 2px #1a1a1a04',
|
||||
cursor: 'pointer', transition: 'all 0.12s',
|
||||
fontFamily: 'Outfit, sans-serif',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isSelected) (e.currentTarget.style.borderColor = '#d0ccc4'); }}
|
||||
onMouseLeave={e => { if (!isSelected) (e.currentTarget.style.borderColor = '#e8e4dc'); }}
|
||||
>
|
||||
<div style={{
|
||||
width: 30, height: 30, borderRadius: 7, flexShrink: 0,
|
||||
background: isSelected ? '#1a1a1a' : '#f6f4f0',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: '0.95rem', color: isSelected ? '#fff' : '#8a8478',
|
||||
}}>
|
||||
{type.icon}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: '0.84rem', fontWeight: 600, color: '#1a1a1a', marginBottom: 2 }}>
|
||||
{type.label}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.71rem', color: '#8a8478', lineHeight: 1.45 }}>
|
||||
{type.desc}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={!projectType || loading}
|
||||
style={{
|
||||
width: '100%', padding: '12px',
|
||||
borderRadius: 8, border: 'none',
|
||||
background: projectType && !loading ? '#1a1a1a' : '#e0dcd4',
|
||||
color: projectType && !loading ? '#fff' : '#b5b0a6',
|
||||
fontSize: '0.88rem', fontWeight: 600,
|
||||
fontFamily: 'Outfit, sans-serif',
|
||||
cursor: projectType && !loading ? 'pointer' : 'not-allowed',
|
||||
transition: 'opacity 0.15s, background 0.15s',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
}}
|
||||
onMouseEnter={e => { if (projectType && !loading) (e.currentTarget.style.opacity = '0.85'); }}
|
||||
onMouseLeave={e => { (e.currentTarget.style.opacity = '1'); }}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span style={{ width: 14, height: 14, borderRadius: '50%', border: '2px solid #fff4', borderTopColor: '#fff', animation: 'spin 0.7s linear infinite', display: 'inline-block' }} />
|
||||
Creating…
|
||||
</>
|
||||
) : (
|
||||
`Create ${productName} →`
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
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";
|
||||
|
||||
Reference in New Issue
Block a user