Files
vibn-frontend/app/[workspace]/project/[projectId]/prd/page.tsx

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>
);
}