design: implement dynamic file-ingestion and PRD checklists for research and existing-project stages
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user