Files
vibn-frontend/components/project-main/BuildMvpJustineV2.tsx
Mark Henderson 651ddf1e11 Rip out Theia, ship P5.1 attach E2E + Justine UI work-in-progress
Theia rip-out:
- Delete app/api/theia-auth/route.ts (Traefik ForwardAuth shim)
- Delete app/api/projects/[projectId]/workspace/route.ts and
  app/api/projects/prewarm/route.ts (Cloud Run Theia provisioning)
- Delete lib/cloud-run-workspace.ts and lib/coolify-workspace.ts
- Strip provisionTheiaWorkspace + theiaWorkspaceUrl/theiaAppUuid/
  theiaError from app/api/projects/create/route.ts response
- Remove Theia callbackUrl branch in app/auth/page.tsx
- Drop "Open in Theia" button + xterm/Theia PTY copy in build/page.tsx
- Drop theiaWorkspaceUrl from deployment/page.tsx Project type
- Strip Theia IDE line + theia-code-os from advisor + agent-chat
  context strings
- Scrub Theia mention from lib/auth/workspace-auth.ts comment

P5.1 (custom apex domains + DNS):
- lib/coolify.ts + lib/opensrs.ts: nameserver normalization, OpenSRS
  XML auth, Cloud DNS plumbing
- scripts/smoke-attach-e2e.ts: full prod GCP + sandbox OpenSRS +
  prod Coolify smoke covering register/zone/A/NS/PATCH/cleanup

In-progress (Justine onboarding/build, MVP setup, agent telemetry):
- New (justine)/stories, project (home) layouts, mvp-setup, run, tasks
  routes + supporting components
- Project shell + sidebar + nav refactor for the Stackless palette
- Agent session API hardening (sessions, events, stream, approve,
  retry, stop) + atlas-chat, advisor, design-surfaces refresh
- New scripts/sync-db-url-from-coolify.mjs +
  scripts/prisma-db-push.mjs + docker-compose.local-db.yml for
  local Prisma workflows
- lib/dev-bypass.ts, lib/chat-context-refs.ts, lib/prd-sections.ts
- Misc: stories CSS, debug/prisma route, modal-theme, BuildLivePlanPanel

Made-with: Cursor
2026-04-22 18:05:01 -07:00

1069 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { JM, JV } from "@/components/project-creation/modal-theme";
const STEPS = [
{ l: "Creating Gitea repository", d: "Setting up version control for your project" },
{ l: "Scaffolding the app", d: "Next.js · TypeScript · Tailwind CSS" },
{ l: "Setting up your database", d: "PostgreSQL + schema based on your product plan" },
{ l: "Building sign up & login", d: "Email + Google + GitHub OAuth" },
{ l: "Wiring payments", d: "Stripe checkout, webhooks, billing portal" },
{ l: "Generating app pages", d: "Dashboard, settings, onboarding, invite flow" },
{ l: "Applying your design", d: "Theme applied across all pages" },
{ l: "Building marketing website", d: "SEO-ready marketing surface" },
{ l: "Setting up email", d: "Welcome, password reset, and marketing templates" },
{ l: "Pushing to Gitea", d: "Full codebase committed and pushed" },
{ l: "Deploying via Coolify", d: "Building Docker image, deploying to your servers" },
{ l: "Running health checks", d: "Verifying pages, auth, and payments are live" },
] as const;
type PhaseRowProps = {
done: boolean;
active: boolean;
label: string;
sub?: string;
onClick?: () => void;
};
function PhaseRow({ done, active, label, sub, onClick }: PhaseRowProps) {
return (
<button
type="button"
onClick={onClick}
style={{
display: "flex",
alignItems: "center",
gap: 9,
padding: "9px 10px",
borderRadius: 8,
border: "none",
background: active ? "#fafaff" : "transparent",
cursor: onClick ? "pointer" : "default",
width: "100%",
textAlign: "left",
fontFamily: JM.fontSans,
}}
onMouseEnter={e => {
if (onClick && !active) (e.currentTarget as HTMLElement).style.background = "#F5F3FF";
}}
onMouseLeave={e => {
if (!active) (e.currentTarget as HTMLElement).style.background = "transparent";
}}
>
<div
style={{
width: 20,
height: 20,
borderRadius: "50%",
background: done ? JM.primaryGradient : active ? JM.indigo : "#e5e7eb",
color: "#fff",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
fontSize: 10,
fontWeight: 700,
}}
>
{done ? "✓" : active ? "▲" : ""}
</div>
<div>
<div style={{ fontSize: 12.5, fontWeight: active ? 600 : 400, color: JM.ink }}>{label}</div>
{sub && <div style={{ fontSize: 10, color: JM.muted }}>{sub}</div>}
</div>
</button>
);
}
function Confetti() {
const colors = ["#6366F1", "#818CF8", "#4338CA", "#A5B4FC", "#C7D2FE", "#FCD34D", "#34D399", "#60A5FA"];
const pieces = Array.from({ length: 90 }, (_, i) => ({
i,
color: colors[i % colors.length],
left: Math.random() * 100,
delay: Math.random() * 1.2,
dur: Math.random() * 2.5 + 2,
size: Math.random() * 9 + 4,
xDrift: (Math.random() - 0.5) * 200,
rot: Math.random() * 360,
br: ["50%", "3px", "0"][Math.floor(Math.random() * 3)],
}));
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
zIndex: 9999,
overflow: "hidden",
}}
>
<style>{`
@keyframes justineConfettiFall {
0% { transform: translateY(-20px) rotate(0deg); opacity: 1; }
80% { opacity: 1; }
100% { transform: translateY(105vh) rotate(800deg); opacity: 0; }
}
`}</style>
{pieces.map(p => (
<div
key={p.i}
style={{
position: "absolute",
top: -12,
left: `${p.left}%`,
width: p.size,
height: p.size * (Math.random() * 0.6 + 0.4),
background: p.color,
borderRadius: p.br,
animation: `justineConfettiFall ${p.dur}s ${p.delay}s ease-in forwards`,
transform: `translateX(${p.xDrift}px) rotate(${p.rot}deg)`,
}}
/>
))}
</div>
);
}
export interface BuildMvpJustineV2Props {
workspace: string;
projectId: string;
projectName: string;
giteaRepo?: string;
/** First webapp surface label + theme if any */
designFeel?: string;
designStructure?: string;
accentLabel?: string;
accentHex?: string;
websiteVoice?: string;
websiteStyle?: string;
topicsLine?: string;
pageColumns?: { title: string; pages: string[] }[];
onSwitchToPreview: () => void;
}
export function BuildMvpJustineV2({
workspace,
projectId,
projectName,
giteaRepo,
designFeel = "Friendly",
designStructure = "Clean",
accentLabel = "Indigo",
accentHex = "#6366F1",
websiteVoice = "Friendly · Balanced · Warm",
websiteStyle = "Editorial",
topicsLine = "The problem · Who it's for · Why now",
pageColumns = [
{ title: "Public", pages: ["Landing page", "Pricing", "About", "Blog"] },
{ title: "Auth", pages: ["Sign up", "Log in", "Forgot password"] },
{ title: "App", pages: ["Dashboard", "Onboarding", "Settings"] },
{ title: "Payments", pages: ["Checkout", "Success", "Manage subscription"] },
],
onSwitchToPreview,
}: BuildMvpJustineV2Props) {
const router = useRouter();
const [uiPhase, setUiPhase] = useState<"review" | "progress" | "done">("review");
const [curStep, setCurStep] = useState(0);
const [building, setBuilding] = useState(false);
const [showConfetti, setShowConfetti] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const giteaWebBase = process.env.NEXT_PUBLIC_GITEA_WEB_URL ?? "https://git.vibnai.com";
const giteaHref = giteaRepo ? `${giteaWebBase}/${giteaRepo}` : giteaWebBase;
const clearBuildInterval = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, []);
useEffect(() => () => clearBuildInterval(), [clearBuildInterval]);
const startBuild = () => {
if (building) return;
setBuilding(true);
setTimeout(() => {
setUiPhase("progress");
setCurStep(0);
intervalRef.current = setInterval(() => {
setCurStep(c => {
const next = c + 1;
if (next >= STEPS.length) {
clearBuildInterval();
setUiPhase("done");
setBuilding(false);
setShowConfetti(true);
setTimeout(() => setShowConfetti(false), 5000);
return STEPS.length;
}
return next;
});
}, 700);
}, 400);
};
const renderStepRows = () =>
STEPS.map((s, i) => {
const done = i < curStep;
const active = i === curStep && uiPhase === "progress";
return (
<div
key={s.l}
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "10px 15px",
borderBottom: i < STEPS.length - 1 ? `1px solid ${JM.border}` : "none",
background: active ? JV.violetTint : "transparent",
transition: "background 0.3s",
}}
>
<div
style={{
width: 20,
height: 20,
borderRadius: "50%",
background: done
? JM.primaryGradient
: active
? "linear-gradient(135deg,#4338CA,#6C7CFF)"
: "#d3d1c7",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
{done && <span style={{ color: "#fff", fontSize: 9, fontWeight: 900 }}></span>}
{active && (
<span
style={{
color: "#fff",
fontSize: 8,
animation: "spin 1s linear infinite",
display: "inline-block",
}}
>
</span>
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 12.5,
fontWeight: active ? 600 : 400,
color: done ? JM.muted : active ? JM.ink : "#b4b2a9",
}}
>
{s.l}
</div>
{(done || active) && (
<div style={{ fontSize: 11, color: JM.mid, marginTop: 1 }}>{s.d}</div>
)}
</div>
</div>
);
});
return (
<>
<style>{`
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes grad-flow {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.justine-grad-anim {
background: linear-gradient(270deg,#1A1A2E,#4338CA,#6366F1,#A5B4FC,#6366F1,#4338CA,#1A1A2E);
background-size: 400% 400%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: grad-flow 4s ease infinite;
}
.justine-grad-title {
background: linear-gradient(135deg,#1A1A2E 0%,#2E2A5E 30%,#4338CA 65%,#6366F1 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
`}</style>
{showConfetti && <Confetti />}
<div
style={{
width: 200,
flexShrink: 0,
background: "#ffffff",
borderRight: `1px solid ${JM.border}`,
display: "flex",
flexDirection: "column",
padding: "18px 12px",
overflow: "hidden",
}}
>
<div style={{ padding: "0 6px", marginBottom: 26 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}>
<div
style={{
width: 26,
height: 26,
background: JM.primaryGradient,
borderRadius: 6,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<span style={{ fontSize: 13, fontWeight: 700, color: "#fff" }}>V</span>
</div>
<span style={{ fontSize: 16, fontWeight: 700, color: JM.ink, letterSpacing: "-0.02em" }}>vibn</span>
</div>
<div
style={{
fontSize: 11,
fontWeight: 500,
color: JM.muted,
paddingLeft: 34,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{projectName}
</div>
</div>
<div
style={{
fontSize: 9.5,
fontWeight: 600,
letterSpacing: "0.08em",
textTransform: "uppercase",
color: JM.muted,
padding: "0 6px",
marginBottom: 8,
}}
>
MVP Setup
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 2, flex: 1, minHeight: 0 }}>
<PhaseRow
done
active={false}
label="Describe"
onClick={() => router.push(`/${workspace}/project/${projectId}/overview`)}
/>
<PhaseRow
done
active={false}
label="Architect"
onClick={() => router.push(`/${workspace}/project/${projectId}/tasks`)}
/>
<PhaseRow
done
active={false}
label="Design"
onClick={() => router.push(`/${workspace}/project/${projectId}/design`)}
/>
<PhaseRow
done
active={false}
label="Website"
onClick={() => router.push(`/${workspace}/project/${projectId}/growth`)}
/>
<PhaseRow done={false} active label="Build MVP" sub="Review & launch" />
</div>
<div style={{ borderTop: `1px solid ${JM.border}`, marginTop: 14, paddingTop: 12 }}>
<Link
href={`/${workspace}/projects`}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 7,
width: "100%",
background: JV.violetTint,
border: `1px solid ${JV.bubbleAiBorder}`,
borderRadius: 8,
padding: "9px 10px",
fontFamily: JM.fontSans,
textDecoration: "none",
}}
>
<span style={{ fontSize: 12, fontWeight: 600, color: JM.indigo }}>Save & go to dashboard</span>
</Link>
</div>
</div>
<div style={{ flex: 1, overflowY: "auto", minWidth: 0 }}>
{uiPhase === "review" && (
<div style={{ padding: "28px 32px", maxWidth: 680, margin: "0 auto" }}>
<div style={{ fontSize: 22, fontWeight: 700, color: JM.ink, marginBottom: 6 }}>Ready to build</div>
<p style={{ fontSize: 13.5, color: JM.muted, marginBottom: 22, lineHeight: 1.55 }}>
Review everything below. Once you hit Build, AI codes your full product and deploys it.
</p>
<div
style={{
background: "#fff",
border: `1px solid ${JM.border}`,
borderRadius: 12,
overflow: "hidden",
marginBottom: 14,
}}
>
<div style={{ padding: "12px 18px", borderBottom: `1px solid ${JM.border}` }}>
<span
style={{
fontSize: 10.5,
fontWeight: 700,
color: JM.muted,
textTransform: "uppercase",
letterSpacing: "0.06em",
}}
>
What&apos;s being built
</span>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr" }}>
{(
[
{ icon: "⛓", k: "Sign up & login", v: "Email + social login", br: true, bb: true },
{ icon: "$", k: "Payments", v: "Subscription billing", br: false, bb: true },
{ icon: "✉", k: "Email", v: "Transactional + marketing", br: true, bb: true },
{ icon: "◧", k: "Product style", v: "Clean & focused", br: false, bb: true },
{ icon: "◉", k: "Website style", v: websiteStyle, br: true, bb: false },
{ icon: "≡", k: "Campaign topics", v: topicsLine, br: false, bb: false },
] as const
).map(cell => (
<div
key={cell.k}
style={{
padding: "13px 18px",
borderRight: cell.br ? `1px solid ${JM.border}` : undefined,
borderBottom: cell.bb ? `1px solid ${JM.border}` : undefined,
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<div
style={{
width: 26,
height: 26,
borderRadius: 7,
background: JV.violetTint,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 12,
color: JM.indigo,
flexShrink: 0,
}}
>
{cell.icon}
</div>
<div>
<div
style={{
fontSize: 10,
color: JM.muted,
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 2,
}}
>
{cell.k}
</div>
<div style={{ fontSize: 13, fontWeight: 600, color: JM.ink }}>{cell.v}</div>
</div>
</div>
))}
</div>
</div>
<div
style={{
background: "#fff",
border: `1px solid ${JM.border}`,
borderRadius: 12,
overflow: "hidden",
marginBottom: 14,
}}
>
<div
style={{
padding: "12px 18px",
borderBottom: `1px solid ${JM.border}`,
display: "flex",
justifyContent: "space-between",
}}
>
<span
style={{
fontSize: 10.5,
fontWeight: 700,
color: JM.muted,
textTransform: "uppercase",
letterSpacing: "0.06em",
}}
>
Pages
</span>
<span style={{ fontSize: 12, color: JM.muted }}>
{pageColumns.reduce((n, c) => n + c.pages.length, 0)} pages total
</span>
</div>
<div style={{ padding: "16px 18px", display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 0 }}>
{pageColumns.map((col, ci) => (
<div
key={col.title}
style={{
padding: "0 14px 0 0",
borderRight: ci < pageColumns.length - 1 ? `1px solid ${JM.border}` : undefined,
marginRight: ci < pageColumns.length - 1 ? 14 : 0,
}}
>
<div
style={{
fontSize: 9.5,
fontWeight: 700,
color: "#7171b7",
textTransform: "uppercase",
letterSpacing: "0.07em",
marginBottom: 8,
}}
>
{col.title}
</div>
<div style={{ fontSize: 12.5, color: "#2c2c2a", lineHeight: 2 }}>
{col.pages.map(p => (
<span key={p}>
{p}
<br />
</span>
))}
</div>
</div>
))}
</div>
</div>
<div
style={{
background: "#fff",
border: `1px solid ${JM.border}`,
borderRadius: 12,
overflow: "hidden",
marginBottom: 14,
}}
>
<div style={{ padding: "12px 18px", borderBottom: `1px solid ${JM.border}` }}>
<span
style={{
fontSize: 10.5,
fontWeight: 700,
color: JM.muted,
textTransform: "uppercase",
letterSpacing: "0.06em",
}}
>
Your design
</span>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr" }}>
<div
style={{
padding: "13px 18px",
borderRight: `1px solid ${JM.border}`,
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<div
style={{
width: 26,
height: 26,
borderRadius: 7,
background: JV.violetTint,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 12,
color: JM.indigo,
}}
>
</div>
<div>
<div style={{ fontSize: 10, color: JM.muted, textTransform: "uppercase", marginBottom: 2 }}>Feel</div>
<div style={{ fontSize: 13, fontWeight: 600, color: JM.ink }}>{designFeel}</div>
</div>
</div>
<div
style={{
padding: "13px 18px",
borderRight: `1px solid ${JM.border}`,
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<div
style={{
width: 26,
height: 26,
borderRadius: "50%",
background: accentHex,
flexShrink: 0,
boxShadow: `0 0 0 3px #fff, 0 0 0 4px ${accentHex}44`,
}}
/>
<div>
<div style={{ fontSize: 10, color: JM.muted, textTransform: "uppercase", marginBottom: 2 }}>Accent</div>
<div style={{ fontSize: 13, fontWeight: 600, color: JM.ink }}>{accentLabel}</div>
</div>
</div>
<div style={{ padding: "13px 18px", display: "flex", alignItems: "center", gap: 10 }}>
<div
style={{
width: 26,
height: 26,
borderRadius: 7,
background: JV.violetTint,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 12,
color: JM.indigo,
}}
>
</div>
<div>
<div style={{ fontSize: 10, color: JM.muted, textTransform: "uppercase", marginBottom: 2 }}>Layout</div>
<div style={{ fontSize: 13, fontWeight: 600, color: JM.ink }}>{designStructure}</div>
</div>
</div>
</div>
</div>
<div
style={{
background: "#fff",
border: `1px solid ${JM.border}`,
borderRadius: 12,
overflow: "hidden",
marginBottom: 14,
}}
>
<div style={{ padding: "12px 18px", borderBottom: `1px solid ${JM.border}` }}>
<span
style={{
fontSize: 10.5,
fontWeight: 700,
color: JM.muted,
textTransform: "uppercase",
letterSpacing: "0.06em",
}}
>
Your website
</span>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr" }}>
<div
style={{
padding: "13px 18px",
borderRight: `1px solid ${JM.border}`,
borderBottom: `1px solid ${JM.border}`,
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<div
style={{
width: 26,
height: 26,
borderRadius: 7,
background: JV.violetTint,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 12,
color: JM.indigo,
}}
>
</div>
<div>
<div style={{ fontSize: 10, color: JM.muted, textTransform: "uppercase", marginBottom: 2 }}>Voice</div>
<div style={{ fontSize: 13, fontWeight: 600, color: JM.ink }}>{websiteVoice}</div>
</div>
</div>
<div
style={{
padding: "13px 18px",
borderBottom: `1px solid ${JM.border}`,
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<div
style={{
width: 26,
height: 26,
borderRadius: 7,
background: JV.violetTint,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 12,
color: JM.indigo,
}}
>
</div>
<div>
<div style={{ fontSize: 10, color: JM.muted, textTransform: "uppercase", marginBottom: 2 }}>
Website style
</div>
<div style={{ fontSize: 13, fontWeight: 600, color: JM.ink }}>{websiteStyle}</div>
</div>
</div>
<div
style={{
padding: "13px 18px",
gridColumn: "1 / -1",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<div
style={{
width: 26,
height: 26,
borderRadius: 7,
background: JV.violetTint,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 12,
color: JM.indigo,
}}
>
</div>
<div>
<div style={{ fontSize: 10, color: JM.muted, textTransform: "uppercase", marginBottom: 2 }}>Topics</div>
<div style={{ fontSize: 13, fontWeight: 600, color: JM.ink }}>{topicsLine}</div>
</div>
</div>
</div>
</div>
<hr style={{ border: "none", borderTop: `1px solid ${JM.border}`, margin: "28px 0" }} />
<div
style={{
background: "#fff",
border: `1px solid ${JM.border}`,
borderRadius: 16,
padding: "28px 28px 24px",
boxShadow: "0 2px 12px rgba(99,102,241,0.06), 0 0 0 1px rgba(255,255,255,0.6)",
}}
>
<div style={{ marginBottom: 24 }}>
<div
style={{
fontSize: 24,
fontWeight: 700,
color: JM.ink,
letterSpacing: "-0.01em",
marginBottom: 8,
}}
>
You&apos;re ready to build your product
</div>
<p style={{ fontSize: 14, color: JM.muted, lineHeight: 1.5, maxWidth: 520, margin: 0 }}>
Your app will be generated, your backend configured, and everything deployed to your infrastructure
fully automated, no code needed.
</p>
</div>
<div style={{ marginTop: 26, marginBottom: 6 }}>
<div
style={{
fontSize: 11,
fontWeight: 600,
color: JM.muted,
textTransform: "uppercase",
letterSpacing: "0.08em",
marginBottom: 14,
opacity: 0.7,
}}
>
What happens next
</div>
{[
{ icon: "✦", t: "Generate UI & all pages", est: "~30s" },
{ icon: "⛁", t: "Set up database & backend", est: "~45s" },
{ icon: "⛓", t: "Connect auth, payments & email", est: "~30s" },
{ icon: "▲", t: "Deploy your app live", est: "~20s" },
].map(row => (
<div
key={row.t}
style={{
display: "flex",
alignItems: "center",
gap: 11,
padding: "9px 0",
borderBottom: `1px solid ${JM.border}`,
}}
>
<div
style={{
width: 24,
height: 24,
borderRadius: 6,
background: JV.violetTint,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 11,
color: JM.indigo,
flexShrink: 0,
opacity: 0.85,
}}
>
{row.icon}
</div>
<div style={{ flex: 1 }}>
<span style={{ fontSize: 13, fontWeight: 500, color: JM.ink }}>{row.t}</span>
</div>
<span style={{ fontSize: 12, color: "#b4b2a9" }}>{row.est}</span>
</div>
))}
</div>
<p style={{ fontSize: 11.5, color: JM.muted, marginBottom: 28 }}>
Takes ~24 minutes · All steps run in parallel
</p>
<button
type="button"
disabled={building}
onClick={startBuild}
style={{
width: "100%",
background: JM.primaryGradient,
color: "#fff",
border: "none",
borderRadius: 12,
padding: "16px 24px",
fontFamily: JM.fontSans,
fontSize: 15,
fontWeight: 700,
cursor: building ? "not-allowed" : "pointer",
letterSpacing: "-0.01em",
boxShadow: JM.primaryShadow,
opacity: building ? 0.75 : 1,
}}
>
{building ? "Starting…" : "Build my product"}
</button>
<p style={{ fontSize: 12.5, color: JM.muted, textAlign: "center", marginTop: 10, marginBottom: 4, opacity: 0.85 }}>
No code needed · You can edit everything after
</p>
</div>
<div style={{ textAlign: "center", paddingBottom: 8 }}>
<button
type="button"
onClick={() => router.push(`/${workspace}/project/${projectId}/design`)}
style={{
background: "none",
border: "none",
fontFamily: JM.fontSans,
fontSize: 12.5,
color: JM.muted,
cursor: "pointer",
padding: "6px 0",
}}
>
Go back and tweak choices
</button>
</div>
</div>
)}
{(uiPhase === "progress" || uiPhase === "done") && (
<div style={{ padding: 32, maxWidth: 580, margin: "0 auto", width: "100%" }}>
<div style={{ textAlign: "center", marginBottom: 26 }}>
{uiPhase === "done" ? (
<>
<div style={{ fontSize: 36, marginBottom: 12 }}>🚀</div>
<div
className="justine-grad-title"
style={{ fontSize: 24, fontWeight: 700, letterSpacing: "-0.03em", marginBottom: 6, fontFamily: JM.fontDisplay }}
>
Your MVP is live
</div>
<div style={{ fontSize: 13.5, color: JM.muted }}>
Deployed to Coolify · Pushed to Gitea · Ready to share
</div>
</>
) : (
<>
<div
className="justine-grad-anim"
style={{ fontSize: 24, fontWeight: 700, letterSpacing: "-0.03em", marginBottom: 6, fontFamily: JM.fontDisplay }}
>
Building your product
</div>
<div style={{ fontSize: 13.5, color: JM.muted }}>
Step {curStep} of {STEPS.length}
</div>
</>
)}
</div>
<div
style={{
background: "#fff",
border: `1px solid ${JM.border}`,
borderRadius: 13,
overflow: "hidden",
marginBottom: 18,
}}
>
{renderStepRows()}
</div>
{uiPhase === "done" && (
<div>
<div
style={{
background: "#fff",
border: `1px solid ${JM.border}`,
borderRadius: 12,
padding: "18px 20px",
marginBottom: 14,
}}
>
<div className="justine-grad-title" style={{ fontSize: 10.5, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 12 }}>
Your next 3 actions
</div>
{[
{
n: "1",
t: "Open your live app",
d: "Share the URL with 5 real people today.",
},
{
n: "2",
t: "Sign up as a user",
d: "Go through your own onboarding. Fix anything confusing.",
},
{
n: "3",
t: "Post your first topic",
d: "AI has drafted your first content batch. Publish one today.",
},
].map((a, i, arr) => (
<div
key={a.n}
style={{
display: "flex",
gap: 12,
padding: "10px 0",
borderBottom: i < arr.length - 1 ? `1px solid ${JM.border}` : undefined,
}}
>
<div
style={{
width: 24,
height: 24,
borderRadius: "50%",
background: JM.primaryGradient,
color: "#fff",
fontSize: 11,
fontWeight: 700,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
{a.n}
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: JM.ink, marginBottom: 2 }}>{a.t}</div>
<div style={{ fontSize: 12, color: JM.muted, lineHeight: 1.5 }}>{a.d}</div>
</div>
</div>
))}
</div>
<div style={{ display: "flex", gap: 10 }}>
<button
type="button"
onClick={onSwitchToPreview}
className="build-cta"
style={{
flex: 2,
background: JM.primaryGradient,
color: "#fff",
border: "none",
borderRadius: 11,
padding: "14px 20px",
fontFamily: JM.fontSans,
fontSize: 14,
fontWeight: 700,
cursor: "pointer",
boxShadow: JM.primaryShadow,
}}
>
Open my app
</button>
<a
href={giteaHref}
target="_blank"
rel="noopener noreferrer"
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "transparent",
color: JM.mid,
border: `1px solid ${JM.border}`,
borderRadius: 11,
padding: "14px 16px",
fontFamily: JM.fontSans,
fontSize: 13,
fontWeight: 500,
textDecoration: "none",
}}
>
View in Gitea
</a>
</div>
</div>
)}
</div>
)}
</div>
</>
);
}