460 lines
19 KiB
TypeScript
460 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useParams, useRouter } from "next/navigation";
|
|
|
|
// Maps each PRD section to the discovery phase that populates it
|
|
const PRD_SECTIONS = [
|
|
{ id: "executive_summary", label: "Executive Summary", phaseId: "big_picture" },
|
|
{ id: "problem_statement", label: "Problem Statement", phaseId: "big_picture" },
|
|
{ id: "vision_metrics", label: "Vision & Success Metrics", phaseId: "big_picture" },
|
|
{ id: "users_personas", label: "Users & Personas", phaseId: "users_personas" },
|
|
{ id: "user_flows", label: "User Flows", phaseId: "users_personas" },
|
|
{ id: "feature_requirements", label: "Feature Requirements", phaseId: "features_scope" },
|
|
{ id: "screen_specs", label: "Screen Specs", phaseId: "screens_data" },
|
|
{ id: "business_model", label: "Business Model", phaseId: "business_model" },
|
|
{ id: "integrations", label: "Integrations & Dependencies", phaseId: "features_scope" },
|
|
{ id: "non_functional", label: "Non-Functional Reqs", phaseId: "features_scope" },
|
|
{ id: "risks", label: "Risks & Mitigations", phaseId: "risks_questions" },
|
|
{ id: "open_questions", label: "Open Questions", phaseId: "risks_questions" },
|
|
];
|
|
|
|
interface SavedPhase {
|
|
phase: string;
|
|
title: string;
|
|
summary: string;
|
|
data: Record<string, unknown>;
|
|
saved_at: string;
|
|
}
|
|
|
|
function formatValue(v: unknown): string {
|
|
if (v === null || v === undefined) return "—";
|
|
if (Array.isArray(v)) return v.map(item => typeof item === "object" ? JSON.stringify(item) : String(item)).join(", ");
|
|
return String(v);
|
|
}
|
|
|
|
function PhaseDataCard({ phase }: { phase: SavedPhase }) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const entries = Object.entries(phase.data).filter(([, v]) => v !== null && v !== undefined && v !== "");
|
|
return (
|
|
<div style={{
|
|
marginTop: 10, background: "#f6f4f0", borderRadius: 8,
|
|
border: "1px solid #e8e4dc", overflow: "hidden",
|
|
}}>
|
|
<button
|
|
onClick={() => setExpanded(e => !e)}
|
|
style={{
|
|
width: "100%", textAlign: "left", padding: "10px 14px",
|
|
background: "none", border: "none", cursor: "pointer",
|
|
display: "flex", alignItems: "center", justifyContent: "space-between",
|
|
fontFamily: "Outfit, sans-serif",
|
|
}}
|
|
>
|
|
<span style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.45 }}>
|
|
{phase.summary}
|
|
</span>
|
|
<span style={{ fontSize: "0.7rem", color: "#a09a90", marginLeft: 8, flexShrink: 0 }}>
|
|
{expanded ? "▲" : "▼"}
|
|
</span>
|
|
</button>
|
|
{expanded && entries.length > 0 && (
|
|
<div style={{ padding: "4px 14px 14px", borderTop: "1px solid #e8e4dc" }}>
|
|
{entries.map(([k, v]) => (
|
|
<div key={k} style={{ marginTop: 10 }}>
|
|
<div style={{
|
|
fontSize: "0.6rem", color: "#b5b0a6", textTransform: "uppercase",
|
|
letterSpacing: "0.06em", fontWeight: 600, marginBottom: 2,
|
|
}}>
|
|
{k.replace(/_/g, " ")}
|
|
</div>
|
|
<div style={{ fontSize: "0.78rem", color: "#2a2824", lineHeight: 1.5 }}>
|
|
{formatValue(v)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface ArchApp { name: string; type: string; description: string; tech?: string[]; screens?: string[] }
|
|
interface ArchInfra { name: string; reason: string }
|
|
interface ArchPackage { name: string; description: string }
|
|
interface ArchIntegration { name: string; required?: boolean; notes?: string }
|
|
interface Architecture {
|
|
productName?: string;
|
|
productType?: string;
|
|
summary?: string;
|
|
apps?: ArchApp[];
|
|
packages?: ArchPackage[];
|
|
infrastructure?: ArchInfra[];
|
|
integrations?: ArchIntegration[];
|
|
designSurfaces?: string[];
|
|
riskNotes?: string[];
|
|
}
|
|
|
|
function ArchitectureView({ arch }: { arch: Architecture }) {
|
|
const Section = ({ title, children }: { title: string; children: React.ReactNode }) => (
|
|
<div style={{ marginBottom: 24 }}>
|
|
<div style={{ fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 10 }}>{title}</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
const Card = ({ children }: { children: React.ReactNode }) => (
|
|
<div style={{ background: "#fff", border: "1px solid #e8e4dc", borderRadius: 9, padding: "14px 16px", marginBottom: 8 }}>{children}</div>
|
|
);
|
|
const Tag = ({ label }: { label: string }) => (
|
|
<span style={{ background: "#f0ece4", borderRadius: 4, padding: "2px 7px", fontSize: "0.68rem", color: "#6b6560", fontFamily: "IBM Plex Mono, monospace", marginRight: 4, display: "inline-block", marginBottom: 3 }}>{label}</span>
|
|
);
|
|
|
|
return (
|
|
<div style={{ maxWidth: 760 }}>
|
|
{arch.summary && (
|
|
<div style={{ background: "#1a1a1a", borderRadius: 10, padding: "18px 22px", marginBottom: 24, color: "#e8e4dc", fontSize: "0.88rem", lineHeight: 1.7 }}>
|
|
{arch.summary}
|
|
</div>
|
|
)}
|
|
{(arch.apps ?? []).length > 0 && (
|
|
<Section title="Applications">
|
|
{arch.apps!.map(a => (
|
|
<Card key={a.name}>
|
|
<div style={{ display: "flex", alignItems: "baseline", gap: 8, marginBottom: 4 }}>
|
|
<span style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a" }}>{a.name}</span>
|
|
<span style={{ fontSize: "0.72rem", color: "#9a9490" }}>{a.type}</span>
|
|
</div>
|
|
<div style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.55, marginBottom: a.tech?.length ? 8 : 0 }}>{a.description}</div>
|
|
{a.tech?.map(t => <Tag key={t} label={t} />)}
|
|
{a.screens && a.screens.length > 0 && (
|
|
<div style={{ marginTop: 6, fontSize: "0.72rem", color: "#a09a90" }}>Screens: {a.screens.join(", ")}</div>
|
|
)}
|
|
</Card>
|
|
))}
|
|
</Section>
|
|
)}
|
|
{(arch.packages ?? []).length > 0 && (
|
|
<Section title="Shared Packages">
|
|
{arch.packages!.map(p => (
|
|
<Card key={p.name}>
|
|
<div style={{ display: "flex", gap: 8, alignItems: "baseline" }}>
|
|
<span style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a", fontFamily: "IBM Plex Mono, monospace" }}>{p.name}</span>
|
|
<span style={{ fontSize: "0.78rem", color: "#4a4640" }}>{p.description}</span>
|
|
</div>
|
|
</Card>
|
|
))}
|
|
</Section>
|
|
)}
|
|
{(arch.infrastructure ?? []).length > 0 && (
|
|
<Section title="Infrastructure">
|
|
{arch.infrastructure!.map(i => (
|
|
<Card key={i.name}>
|
|
<div style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 3 }}>{i.name}</div>
|
|
<div style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.5 }}>{i.reason}</div>
|
|
</Card>
|
|
))}
|
|
</Section>
|
|
)}
|
|
{(arch.integrations ?? []).length > 0 && (
|
|
<Section title="Integrations">
|
|
{arch.integrations!.map(i => (
|
|
<Card key={i.name}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: i.notes ? 4 : 0 }}>
|
|
<span style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a" }}>{i.name}</span>
|
|
{i.required && <span style={{ fontSize: "0.62rem", background: "#fef3c7", color: "#92400e", padding: "1px 6px", borderRadius: 4 }}>required</span>}
|
|
</div>
|
|
{i.notes && <div style={{ fontSize: "0.78rem", color: "#4a4640" }}>{i.notes}</div>}
|
|
</Card>
|
|
))}
|
|
</Section>
|
|
)}
|
|
{(arch.riskNotes ?? []).length > 0 && (
|
|
<Section title="Architectural Risks">
|
|
{arch.riskNotes!.map((r, i) => (
|
|
<div key={i} style={{ display: "flex", gap: 10, alignItems: "flex-start", marginBottom: 8 }}>
|
|
<span style={{ fontSize: "0.72rem", color: "#d97706", marginTop: 2, flexShrink: 0 }}>⚠</span>
|
|
<span style={{ fontSize: "0.82rem", color: "#4a4640", lineHeight: 1.5 }}>{r}</span>
|
|
</div>
|
|
))}
|
|
</Section>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function PRDPage() {
|
|
const params = useParams();
|
|
const projectId = params.projectId as string;
|
|
const workspace = params.workspace as string;
|
|
const [prd, setPrd] = useState<string | null>(null);
|
|
const [architecture, setArchitecture] = useState<Architecture | null>(null);
|
|
const [savedPhases, setSavedPhases] = useState<SavedPhase[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [activeTab, setActiveTab] = useState<"prd" | "architecture">("prd");
|
|
const [archGenerating, setArchGenerating] = useState(false);
|
|
const [archError, setArchError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
Promise.all([
|
|
fetch(`/api/projects/${projectId}`).then(r => r.json()).catch(() => ({})),
|
|
fetch(`/api/projects/${projectId}/save-phase`).then(r => r.json()).catch(() => ({ phases: [] })),
|
|
]).then(([projectData, phaseData]) => {
|
|
setPrd(projectData?.project?.prd ?? null);
|
|
setArchitecture(projectData?.project?.architecture ?? null);
|
|
setSavedPhases(phaseData?.phases ?? []);
|
|
setLoading(false);
|
|
});
|
|
}, [projectId]);
|
|
|
|
const router = useRouter();
|
|
|
|
const handleGenerateArchitecture = async () => {
|
|
setArchGenerating(true);
|
|
setArchError(null);
|
|
try {
|
|
const res = await fetch(`/api/projects/${projectId}/architecture`, { method: "POST" });
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error ?? "Generation failed");
|
|
setArchitecture(data.architecture);
|
|
setActiveTab("architecture");
|
|
} catch (e) {
|
|
setArchError(e instanceof Error ? e.message : "Something went wrong");
|
|
} finally {
|
|
setArchGenerating(false);
|
|
}
|
|
};
|
|
|
|
const phaseMap = new Map(savedPhases.map(p => [p.phase, p]));
|
|
const savedPhaseIds = new Set(savedPhases.map(p => p.phase));
|
|
|
|
const sections = PRD_SECTIONS.map(s => ({
|
|
...s,
|
|
savedPhase: s.phaseId ? phaseMap.get(s.phaseId) ?? null : null,
|
|
isDone: s.phaseId ? savedPhaseIds.has(s.phaseId) : false,
|
|
}));
|
|
|
|
const doneCount = sections.filter(s => s.isDone).length;
|
|
const totalPct = Math.round((doneCount / sections.length) * 100);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit, sans-serif", color: "#a09a90" }}>
|
|
Loading…
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const tabs = [
|
|
{ id: "prd" as const, label: "PRD", available: true },
|
|
{ id: "architecture" as const, label: "Architecture", available: !!architecture },
|
|
];
|
|
|
|
return (
|
|
<div style={{ padding: "28px 32px", flex: 1, overflow: "auto", fontFamily: "Outfit, sans-serif" }}>
|
|
|
|
{/* Tab bar — only when at least one doc exists */}
|
|
{(prd || architecture) && (
|
|
<div style={{ display: "flex", gap: 4, marginBottom: 24 }}>
|
|
{tabs.map(t => {
|
|
const isActive = activeTab === t.id;
|
|
return (
|
|
<button
|
|
key={t.id}
|
|
onClick={() => t.available && setActiveTab(t.id)}
|
|
disabled={!t.available}
|
|
style={{
|
|
padding: "6px 14px", borderRadius: 8, border: "none", cursor: t.available ? "pointer" : "default",
|
|
background: isActive ? "#1a1a1a" : "transparent",
|
|
color: isActive ? "#fff" : t.available ? "#6b6560" : "#c5c0b8",
|
|
fontSize: "0.8rem", fontWeight: isActive ? 600 : 400,
|
|
fontFamily: "Outfit, sans-serif",
|
|
transition: "all 0.1s",
|
|
}}
|
|
>
|
|
{t.label}
|
|
{!t.available && <span style={{ marginLeft: 5, fontSize: "0.65rem", opacity: 0.6 }}>—</span>}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Next step banner — PRD done but no architecture yet */}
|
|
{prd && !architecture && activeTab === "prd" && (
|
|
<div style={{
|
|
marginBottom: 24, padding: "18px 22px",
|
|
background: "#1a1a1a", borderRadius: 10,
|
|
display: "flex", alignItems: "center", justifyContent: "space-between",
|
|
gap: 16, flexWrap: "wrap",
|
|
}}>
|
|
<div>
|
|
<div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#fff", marginBottom: 4 }}>
|
|
Next: Generate technical architecture
|
|
</div>
|
|
<div style={{ fontSize: "0.76rem", color: "#a09a90", lineHeight: 1.5 }}>
|
|
The AI will read your PRD and recommend the apps, services, and infrastructure your product needs. Takes ~30 seconds.
|
|
</div>
|
|
{archError && (
|
|
<div style={{ fontSize: "0.74rem", color: "#f87171", marginTop: 6 }}>⚠ {archError}</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={handleGenerateArchitecture}
|
|
disabled={archGenerating}
|
|
style={{
|
|
padding: "10px 20px", borderRadius: 8, border: "none",
|
|
background: archGenerating ? "#4a4640" : "#fff",
|
|
color: archGenerating ? "#a09a90" : "#1a1a1a",
|
|
fontSize: "0.82rem", fontWeight: 700,
|
|
fontFamily: "Outfit, sans-serif",
|
|
cursor: archGenerating ? "default" : "pointer",
|
|
flexShrink: 0, display: "flex", alignItems: "center", gap: 8,
|
|
transition: "opacity 0.15s",
|
|
}}
|
|
>
|
|
{archGenerating && (
|
|
<span style={{
|
|
width: 12, height: 12, borderRadius: "50%",
|
|
border: "2px solid #60606040", borderTopColor: "#a09a90",
|
|
animation: "spin 0.7s linear infinite", display: "inline-block",
|
|
}} />
|
|
)}
|
|
{archGenerating ? "Analysing PRD…" : "Generate architecture →"}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Architecture tab */}
|
|
{activeTab === "architecture" && architecture && (
|
|
<ArchitectureView arch={architecture} />
|
|
)}
|
|
|
|
{/* PRD tab — finalized */}
|
|
{activeTab === "prd" && prd && (
|
|
<div style={{ maxWidth: 760 }}>
|
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 20 }}>
|
|
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", margin: 0 }}>
|
|
Product Requirements
|
|
</h3>
|
|
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.72rem", color: "#6b6560", background: "#f0ece4", padding: "4px 10px", borderRadius: 5 }}>
|
|
PRD complete
|
|
</span>
|
|
</div>
|
|
<div style={{
|
|
background: "#fff", borderRadius: 10, border: "1px solid #e8e4dc",
|
|
padding: "28px 32px", lineHeight: 1.8,
|
|
fontSize: "0.88rem", color: "#2a2824",
|
|
whiteSpace: "pre-wrap", fontFamily: "Outfit, sans-serif",
|
|
}}>
|
|
{prd}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* PRD tab — section progress (no finalized PRD yet) */}
|
|
{activeTab === "prd" && !prd && (
|
|
/* ── Section progress view ── */
|
|
<div style={{ maxWidth: 680 }}>
|
|
{/* Progress bar */}
|
|
<div style={{
|
|
display: "flex", alignItems: "center", gap: 16,
|
|
padding: "16px 20px", background: "#fff",
|
|
border: "1px solid #e8e4dc", borderRadius: 10,
|
|
marginBottom: 24, boxShadow: "0 1px 2px #1a1a1a05",
|
|
}}>
|
|
<div style={{
|
|
fontFamily: "IBM Plex Mono, monospace",
|
|
fontSize: "1.4rem", fontWeight: 500, color: "#1a1a1a", minWidth: 52,
|
|
}}>
|
|
{totalPct}%
|
|
</div>
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ height: 4, borderRadius: 2, background: "#eae6de" }}>
|
|
<div style={{
|
|
height: "100%", borderRadius: 2,
|
|
width: `${totalPct}%`, background: "#1a1a1a",
|
|
transition: "width 0.6s ease",
|
|
}} />
|
|
</div>
|
|
</div>
|
|
<span style={{ fontSize: "0.75rem", color: "#a09a90" }}>
|
|
{doneCount}/{sections.length} sections
|
|
</span>
|
|
</div>
|
|
|
|
{/* Sections */}
|
|
{sections.map((s, i) => (
|
|
<div
|
|
key={s.id}
|
|
style={{
|
|
padding: "14px 18px", marginBottom: 6,
|
|
background: "#fff", borderRadius: 10,
|
|
border: `1px solid ${s.isDone ? "#a5d6a740" : "#e8e4dc"}`,
|
|
animationDelay: `${i * 0.04}s`,
|
|
}}
|
|
>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
|
{/* Status icon */}
|
|
<div style={{
|
|
width: 24, height: 24, borderRadius: 6, flexShrink: 0,
|
|
background: s.isDone ? "#2e7d3210" : "#f6f4f0",
|
|
display: "flex", alignItems: "center", justifyContent: "center",
|
|
fontSize: "0.65rem", fontWeight: 700,
|
|
color: s.isDone ? "#2e7d32" : "#c5c0b8",
|
|
}}>
|
|
{s.isDone ? "✓" : "○"}
|
|
</div>
|
|
|
|
<span style={{
|
|
flex: 1, fontSize: "0.84rem",
|
|
color: s.isDone ? "#1a1a1a" : "#a09a90",
|
|
fontWeight: s.isDone ? 500 : 400,
|
|
}}>
|
|
{s.label}
|
|
</span>
|
|
|
|
{s.isDone && s.savedPhase && (
|
|
<span style={{
|
|
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
|
|
color: "#2e7d32", background: "#2e7d3210",
|
|
padding: "2px 7px", borderRadius: 4, fontWeight: 500,
|
|
}}>
|
|
saved
|
|
</span>
|
|
)}
|
|
{!s.isDone && !s.phaseId && (
|
|
<span style={{
|
|
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
|
|
color: "#b5b0a6", padding: "2px 7px",
|
|
}}>
|
|
generated
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Expandable phase data */}
|
|
{s.isDone && s.savedPhase && (
|
|
<PhaseDataCard phase={s.savedPhase} />
|
|
)}
|
|
|
|
{/* Pending hint */}
|
|
{!s.isDone && (
|
|
<div style={{ marginTop: 6, marginLeft: 36, fontSize: "0.72rem", color: "#c5c0b8" }}>
|
|
{s.phaseId
|
|
? `Complete the ${s.savedPhase ? s.savedPhase.title : "discovery"} phase in Vibn`
|
|
: "Will be generated when PRD is finalized"}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{doneCount === 0 && (
|
|
<p style={{ fontSize: "0.78rem", color: "#b5b0a6", marginTop: 20, textAlign: "center" }}>
|
|
Continue chatting with Vibn — saved phases will appear here automatically.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|