design: implement dynamic file-ingestion and PRD checklists for research and existing-project stages

This commit is contained in:
2026-06-08 12:33:43 -07:00
parent cca53538ed
commit f72d27790a

View File

@@ -9,8 +9,8 @@ import {
Field,
} from "./onboarding-primitives";
const ENTREP_TOTAL = 4;
const ENTREP_STEP_NAMES = ["Type", "Idea", "Status", "Name"];
const ENTREP_TOTAL = 5;
const ENTREP_STEP_NAMES = ["Type", "Idea", "Status", "Details", "Name"];
const IDEA_PROMPTS = [
"A community for indie game devs to swap playtesters, with weekly demo nights",
@@ -205,6 +205,508 @@ function EntrepStatus({ value, onChange }) {
);
}
// ── Step 3 DYNAMIC: Research Ingestion ──────────────────────────────────────
function EntrepResearch({ files = [], brief = "", onChange }) {
const [uploading, setUploading] = useState<string | null>(null);
const [progress, setProgress] = useState(0);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = e.target.files?.[0];
if (!selected) return;
setUploading(selected.name);
setProgress(0);
// Simulate a smooth upload progress bar
const interval = setInterval(() => {
setProgress((p) => {
if (p >= 100) {
clearInterval(interval);
setTimeout(() => {
onChange({
files: [
...files,
{
name: selected.name,
size: `${(selected.size / 1024).toFixed(1)} KB`,
},
],
});
setUploading(null);
}, 300);
return 100;
}
return p + 15;
});
}, 80);
};
const removeFile = (name: string) => {
onChange({
files: files.filter(
(f: { name: string; size: string }) => f.name !== name,
),
});
};
return (
<>
<WizardQ
title="Share your research"
sub="Vibn parses and ingests these directly into your project's knowledge base."
/>
{/* File Uploader */}
<Field
label="Upload research documents"
optional
hint="PDFs, briefs, wireframes, or text notes."
>
<div style={{ position: "relative" }}>
<label
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: 104,
border: "1px dashed var(--hairline-2)",
borderRadius: 12,
background: "oklch(0.16 0.008 60 / 0.4)",
cursor: "pointer",
padding: 16,
textAlign: "center",
transition: "border-color .15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "var(--accent)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "var(--hairline-2)";
}}
>
<input
type="file"
onChange={handleFileChange}
style={{ display: "none" }}
/>
{uploading ? (
<div
style={{
width: "100%",
maxWidth: 200,
display: "flex",
flexDirection: "column",
gap: 8,
alignItems: "center",
}}
>
<span className="spinner" style={{ width: 20, height: 20 }} />
<div style={{ fontSize: 13, color: "var(--fg)" }}>
Uploading {uploading}
</div>
<div
style={{
width: "100%",
height: 3,
background: "rgba(255,255,255,0.08)",
borderRadius: 1.5,
overflow: "hidden",
}}
>
<div
style={{
width: `${progress}%`,
height: "100%",
background: "var(--accent)",
transition: "width .1s",
}}
/>
</div>
</div>
) : (
<>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="var(--fg-faint)"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
style={{ marginBottom: 8 }}
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12" />
</svg>
<div
style={{
fontSize: 13.5,
fontWeight: 500,
color: "var(--fg-dim)",
marginBottom: 2,
}}
>
Drag & drop files or{" "}
<span
style={{
color: "var(--accent)",
textDecoration: "underline",
}}
>
browse
</span>
</div>
<div
style={{
fontSize: 11,
color: "var(--fg-faint)",
fontFamily: "var(--font-mono)",
}}
>
Maximum size: 20MB per file
</div>
</>
)}
</label>
</div>
</Field>
{/* Uploaded File List */}
{files.length > 0 && (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 6,
marginBottom: 12,
}}
>
{files.map((file: { name: string; size: string }) => (
<div
key={file.name}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "8px 12px",
background: "oklch(0.18 0.009 60 / 0.5)",
border: "1px solid var(--hairline)",
borderRadius: 10,
}}
>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="var(--accent)"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6" />
<path d="M16 13H8M16 17H8M10 9H8" />
</svg>
<span
style={{ fontSize: 13, fontWeight: 500, color: "var(--fg)" }}
>
{file.name}
</span>
<span
style={{
fontSize: 11,
color: "var(--fg-faint)",
fontFamily: "var(--font-mono)",
}}
>
({file.size})
</span>
</div>
<button
type="button"
onClick={() => removeFile(file.name)}
style={{ color: "var(--fg-faint)", cursor: "pointer" }}
onMouseEnter={(e) => {
e.currentTarget.style.color = "oklch(0.65 0.18 25)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = "var(--fg-faint)";
}}
>
</button>
</div>
))}
</div>
)}
{/* Conversation Paste area */}
<Field
label="Or paste an AI conversation / notes"
optional
hint="Paste previous chats or text notes to help guide the AI."
>
<textarea
className="wiz-input"
style={{ minHeight: 120, fontSize: 14.5 }}
placeholder="Paste previous ChatGPT/Claude conversations or general notes here…"
value={brief}
onChange={(e) => onChange({ brief: e.target.value })}
/>
</Field>
</>
);
}
// ── Step 3 DYNAMIC: Existing Project Ingestion ──────────────────────────────
function EntrepExisting({ checklist = {}, onChange }) {
const [uploading, setUploading] = useState<"designs" | "code" | null>(null);
const [progress, setProgress] = useState(0);
const toggleCheck = (key: string) => {
onChange({ checklist: { ...checklist, [key]: !checklist[key] } });
};
const handleFileChange = (
key: "designs" | "code",
e: React.ChangeEvent<HTMLInputElement>,
) => {
const selected = e.target.files?.[0];
if (!selected) return;
setUploading(key);
setProgress(0);
const interval = setInterval(() => {
setProgress((p) => {
if (p >= 100) {
clearInterval(interval);
setTimeout(() => {
const currentFiles = checklist[`${key}Files`] || [];
onChange({
checklist: {
...checklist,
[`${key}Files`]: [
...currentFiles,
{
name: selected.name,
size: `${(selected.size / 1024).toFixed(1)} KB`,
},
],
},
});
setUploading(null);
}, 300);
return 100;
}
return p + 15;
});
}, 80);
};
const removeFile = (key: "designs" | "code", name: string) => {
const currentFiles = checklist[`${key}Files`] || [];
onChange({
checklist: {
...checklist,
[`${key}Files`]: currentFiles.filter(
(f: { name: string; size: string }) => f.name !== name,
),
},
});
};
const renderUploader = (key: "designs" | "code", label: string) => {
const files = checklist[`${key}Files`] || [];
return (
<div
style={{
marginTop: 10,
paddingLeft: 22,
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<label
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: 48,
border: "1px dashed var(--hairline-2)",
borderRadius: 8,
background: "oklch(0.16 0.008 60 / 0.2)",
cursor: "pointer",
textAlign: "center",
width: "100%",
}}
>
<input
type="file"
onChange={(e) => handleFileChange(key, e)}
style={{ display: "none" }}
/>
{uploading === key ? (
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<span className="spinner" style={{ width: 12, height: 12 }} />
<span style={{ fontSize: 12, color: "var(--fg-dim)" }}>
Uploading ({progress}%)
</span>
</div>
) : (
<span style={{ fontSize: 12.5, color: "var(--accent)" }}>
+ Upload {label}
</span>
)}
</label>
{files.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{files.map((file: { name: string; size: string }) => (
<div
key={file.name}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "6px 10px",
background: "oklch(0.18 0.009 60 / 0.3)",
border: "1px solid var(--hairline)",
borderRadius: 8,
}}
>
<span
style={{
fontSize: 12,
fontWeight: 500,
color: "var(--fg-dim)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{file.name} ({file.size})
</span>
<button
type="button"
onClick={() => removeFile(key, file.name)}
style={{
color: "var(--fg-faint)",
cursor: "pointer",
fontSize: 10,
}}
>
</button>
</div>
))}
</div>
)}
</div>
);
};
const list = [
{ key: "prd", label: "Product requirements doc (PRD) or similar" },
{
key: "designs",
label: "Created designs we can upload",
uploadLabel: "design files (PDF/Figma/Sketch)",
},
{
key: "code",
label: "Generated some code we can upload",
uploadLabel: "code files (zip/JSON/JS)",
},
{ key: "github", label: "Have a repo in GitHub we can connect" },
];
return (
<>
<WizardQ
title="Check what you've done"
sub="Help us align. Let Vibn know what assets you have ready to connect."
/>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{list.map((item) => {
const active = !!checklist[item.key];
return (
<div
key={item.key}
style={{ display: "flex", flexDirection: "column" }}
>
<button
type="button"
onClick={() => toggleCheck(item.key)}
style={{
display: "flex",
alignItems: "center",
gap: 12,
textAlign: "left",
padding: "14px 16px",
borderRadius: 12,
border: `1px solid ${active ? "var(--accent)" : "var(--hairline)"}`,
background: active
? "oklch(0.20 0.04 35 / 0.4)"
: "oklch(0.18 0.009 60 / 0.6)",
boxShadow: active
? "0 0 0 3px oklch(0.74 0.175 35 / 0.1)"
: "none",
transition: "border-color .15s, background .15s",
color: "var(--fg)",
cursor: "pointer",
}}
>
<span
style={{
width: 14,
height: 14,
borderRadius: 3,
border: `1.5px solid ${active ? "var(--accent)" : "var(--hairline-2)"}`,
background: active ? "var(--accent)" : "transparent",
display: "grid",
placeItems: "center",
color: "var(--accent-fg)",
flexShrink: 0,
}}
>
{active && (
<svg
width="8"
height="8"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="m3 8.5 3.2 3.2L13 5" />
</svg>
)}
</span>
<span style={{ fontSize: 14, fontWeight: 500 }}>
{item.label}
</span>
</button>
{active &&
item.uploadLabel &&
renderUploader(
item.key as "designs" | "code",
item.uploadLabel,
)}
</div>
);
})}
</div>
</>
);
}
function EntrepName({ value, onChange, ideaDescription }) {
// Pre-fill the workspace name from what they typed, or fall back
const trimmed = String(value || "").trim();
@@ -254,12 +756,24 @@ export function EntrepreneurPath({
step,
}) {
const next = () => {
if (step < ENTREP_TOTAL - 1) onJumpToStep(step + 1);
else onComplete();
if (step === 2 && data.projectStatus === "scratch") {
// Bypasses step 3 and goes straight to step 4 (Name Workspace)
onJumpToStep(4);
} else if (step < ENTREP_TOTAL - 1) {
onJumpToStep(step + 1);
} else {
onComplete();
}
};
const back = () => {
if (step === 0) onBack();
else onJumpToStep(step - 1);
if (step === 0) {
onBack();
} else if (step === 4 && data.projectStatus === "scratch") {
// Bypasses step 3 and goes straight back to step 2 (Status selection)
onJumpToStep(2);
} else {
onJumpToStep(step - 1);
}
};
let body, canNext;
@@ -302,6 +816,36 @@ export function EntrepreneurPath({
/>
);
canNext = !!data.projectStatus;
} else if (step === 3) {
// Dynamic Step 3 details page based on Page 4 selection
if (data.projectStatus === "research") {
body = (
<EntrepResearch
files={data.researchFiles || []}
brief={data.researchBrief || ""}
onChange={(patch) =>
onUpdate({
researchFiles: patch.files ?? data.researchFiles,
researchBrief: patch.brief ?? data.researchBrief,
})
}
/>
);
canNext = true; // optional research
} else {
body = (
<EntrepExisting
checklist={data.existingChecklist || {}}
onChange={(patch) =>
onUpdate({
existingChecklist: patch.checklist ?? data.existingChecklist,
})
}
/>
);
// Let them continue if they checked at least one thing, or they can skip.
canNext = true;
}
} else {
body = (
<EntrepName