Closes checklist items F-01..F-06, D-01..D-28, S-01..S-10, C-01..C-07, B-01..B-07, R-01..R-02, O-03. Security (28 deletions + 10 auth gates): - Delete 28 unauthenticated debug/cursor/firebase/test routes - Gate ai/chat, ai/conversation, context/summarize, work-completed with withTenantProject/withAuth - Add HMAC-SHA256 signature verification to webhooks/coolify - Switch all admin secret comparisons to timingSafeStringEq Foundations (lib/server/*): - api-handler.ts: withAuth, withTenantProject, withWorkspace, withAdminSecret, withRateLimit - logger.ts: structured request-scoped logging with turnId - audit-log.ts: writeAuditLog helper + audit_log table - rate-limit.ts: Postgres sliding window rate limiter - coolify-webhook.ts: verifyCoolifySignature - timing-safe.ts: timingSafeStringEq Chat hardening (chat/route.ts): - MAX_TOOL_ROUNDS 15 → 8 (C-01) - Loop detection: hard-break at 3 identical fingerprints (was 5) (C-02) - Add 6-consecutive-tool-call hard-break (C-02) - Mode: respond first, act second prompt block (C-03) - SSE heartbeat every 25s via setInterval (C-04) - Per-tool 45s timeout via Promise.race (C-05) - turnId per-turn UUID for log correlation (C-06) - Recovery fires when roundsSinceText >= 4 (C-07) - SSE plan event on plan_task_add/edit (B-05) Beta features: - invites table + GET/POST /api/invites (P4.8) - invites/[token] validate + redeem (P4.8) - fs_project_dev_servers table + lib/server/dev-server-state.ts (P6.B1) - fs_project_secrets table + CRUD routes (P6.D2) - lib/integrations/brief-extract.ts (P3.7) Documentation: - app/api/ROUTES.md: full route map with auth + tenant
446 lines
18 KiB
JavaScript
446 lines
18 KiB
JavaScript
// Build + Ready screens. The build screen shows the terminal stream + a live
|
|
// preview stencil; ready is a quiet confirmation page with the workspace URL.
|
|
|
|
// ── Per-path build plans ───────────────────────────────────────────────────
|
|
function buildPlanFor(path, data) {
|
|
const common = [
|
|
{ line: "vibn init — reading brief", ms: 600 },
|
|
{ line: "↳ provisioning workspace", ms: 700 },
|
|
{ line: "↳ wiring auth (email + Google)", ms: 800 },
|
|
{ line: "↳ minting database & seed schema", ms: 700 },
|
|
];
|
|
|
|
if (path === "entrepreneur") {
|
|
return [
|
|
...common,
|
|
{ line: `↳ generating landing page · vibe "${data.vibe || "warm"}"`, ms: 900 },
|
|
{ line: `↳ writing copy aimed at "${(data.audience || "your audience").slice(0, 40)}"`, ms: 800 },
|
|
{ line: "↳ wiring email capture + Stripe payment link", ms: 700 },
|
|
{ line: "↳ scaffolding admin: subscribers, sales, comments", ms: 800 },
|
|
{ line: `↳ tuning launch plan for goal: ${data.goal || "first_customer"}`, ms: 700 },
|
|
{ line: "↳ publishing preview → " + workspaceUrlFor(path, data), ms: 900 },
|
|
{ line: "ready.", ms: 400, ok: true },
|
|
];
|
|
}
|
|
if (path === "owner") {
|
|
return [
|
|
...common,
|
|
{ line: `↳ modelling ${data.biz || "small business"} for "${data.bizName || "your business"}"`, ms: 800 },
|
|
{ line: `↳ importing your stack (${(data.tools || []).length} tools)`, ms: 800 },
|
|
{ line: `↳ building module: ${labelFor(data.firstThing)}`, ms: 1000 },
|
|
{ line: "↳ generating customer + job records (10 sample)", ms: 700 },
|
|
{ line: "↳ scheduling daily ops view + weekly report", ms: 700 },
|
|
{ line: `↳ wiring savings tracker · est. $${(data.spend || 0)}/mo replaced`, ms: 800 },
|
|
{ line: "↳ publishing preview → " + workspaceUrlFor(path, data), ms: 900 },
|
|
{ line: "ready.", ms: 400, ok: true },
|
|
];
|
|
}
|
|
return [
|
|
...common,
|
|
{ line: `↳ branding workspace for "${data.clientName || "client"}"`, ms: 800 },
|
|
{ line: `↳ scaffolding scope (${(data.scope || []).length} modules)`, ms: 1000 },
|
|
...(data.scope || []).slice(0, 4).map((s) => ({ line: ` • ${s}`, ms: 350 })),
|
|
{ line: "↳ generating handoff document + invoice template", ms: 700 },
|
|
{ line: `↳ setting deploy target: ${data.handoff || "subdomain"}`, ms: 700 },
|
|
{ line: "↳ publishing preview → " + workspaceUrlFor(path, data), ms: 900 },
|
|
{ line: "ready.", ms: 400, ok: true },
|
|
];
|
|
}
|
|
|
|
function labelFor(id) {
|
|
const map = {
|
|
booking: "Bookings & scheduling",
|
|
invoicing: "Quotes & invoices",
|
|
customers: "Customer portal",
|
|
inventory: "Inventory & orders",
|
|
team: "Team & dispatch",
|
|
marketing: "Marketing site",
|
|
};
|
|
return map[id] || "your first workflow";
|
|
}
|
|
|
|
function workspaceUrlFor(path, data) {
|
|
const seed =
|
|
(path === "owner" && data.bizName) ||
|
|
(path === "consultant" && data.clientName) ||
|
|
(path === "entrepreneur" && (data.audience || data.idea)) ||
|
|
"your-workspace";
|
|
const slug = String(seed).toLowerCase()
|
|
.replace(/[^a-z0-9\s-]/g, "")
|
|
.trim()
|
|
.split(/\s+/)
|
|
.slice(0, 3)
|
|
.join("-")
|
|
.slice(0, 28) || "your-workspace";
|
|
return `${slug}.vibn.app`;
|
|
}
|
|
|
|
const BUILD_BIZ_LABEL = {
|
|
service: "Trades / services",
|
|
retail: "Retail",
|
|
food: "Food & drink",
|
|
appointments: "Appointments",
|
|
events: "Events / hospitality",
|
|
other: "Small business",
|
|
};
|
|
|
|
// ── Build screen ───────────────────────────────────────────────────────────
|
|
function BuildScreen({ path, data, onBack, onClose, onOpen }) {
|
|
const plan = React.useMemo(() => buildPlanFor(path, data), [path, data]);
|
|
const [lineIdx, setLineIdx] = React.useState(0);
|
|
const [done, setDone] = React.useState(false);
|
|
const logRef = React.useRef(null);
|
|
|
|
React.useEffect(() => {
|
|
if (lineIdx >= plan.length) { setDone(true); return undefined; }
|
|
const t = setTimeout(() => setLineIdx(lineIdx + 1), plan[lineIdx].ms);
|
|
return () => clearTimeout(t);
|
|
}, [lineIdx, plan]);
|
|
|
|
React.useEffect(() => {
|
|
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
|
|
}, [lineIdx]);
|
|
|
|
const url = workspaceUrlFor(path, data);
|
|
const lane = LANE_LABELS[path];
|
|
const previewTitle =
|
|
path === "owner" ? (data.bizName || "Your business") :
|
|
path === "consultant" ? (data.clientName || "Your client") :
|
|
"Your launch page";
|
|
const previewSub =
|
|
path === "owner" ? `${BUILD_BIZ_LABEL[data.biz] || "Small business"} · ${labelFor(data.firstThing)}` :
|
|
path === "consultant" ? (data.industry || "Project") :
|
|
(data.audience || "An idea worth building").slice(0, 64);
|
|
|
|
const pct = done ? 1 : lineIdx / plan.length;
|
|
|
|
return (
|
|
<>
|
|
<WizardTop
|
|
onBack={onBack}
|
|
onClose={onClose}
|
|
lane={lane}
|
|
stepText={done ? "Done" : "Building"}
|
|
progress={pct}
|
|
/>
|
|
<main className="wiz-body" style={{ paddingTop: "clamp(28px, 5vh, 56px)" }}>
|
|
<div className="wiz-card xwide" style={{ gap: 18 }}>
|
|
<style>{`
|
|
.b-grid {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
|
|
gap: 18px;
|
|
}
|
|
@media (max-width: 920px) { .b-grid { grid-template-columns: 1fr; } }
|
|
|
|
.b-pane {
|
|
border-radius: 12px;
|
|
border: 1px solid var(--hairline);
|
|
background: linear-gradient(180deg, oklch(0.16 0.008 60 / 0.92), oklch(0.14 0.008 60 / 0.92));
|
|
min-height: 420px;
|
|
display: flex; flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
.b-pane-bar {
|
|
display: flex; align-items: center; gap: 8px;
|
|
padding: 10px 14px;
|
|
border-bottom: 1px solid var(--hairline);
|
|
background: oklch(0.16 0.008 60 / 0.6);
|
|
font-family: var(--font-mono);
|
|
font-size: 11px;
|
|
color: var(--fg-mute);
|
|
letter-spacing: 0.04em;
|
|
}
|
|
.b-pane-bar .live {
|
|
margin-left: auto;
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
color: oklch(0.85 0.16 155);
|
|
font-size: 10.5px; letter-spacing: 0.08em; text-transform: uppercase;
|
|
}
|
|
.b-pane-bar .live::before {
|
|
content: ""; width: 5px; height: 5px; border-radius: 50%;
|
|
background: oklch(0.78 0.16 155);
|
|
box-shadow: 0 0 0 0 oklch(0.78 0.16 155 / 0.6);
|
|
animation: pulse 2s ease-out infinite;
|
|
}
|
|
.b-log {
|
|
flex: 1;
|
|
padding: 14px 16px;
|
|
font-family: var(--font-mono);
|
|
font-size: 12.5px;
|
|
line-height: 1.7;
|
|
color: var(--fg-dim);
|
|
overflow-y: auto;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
}
|
|
.b-log .l-ok { color: oklch(0.85 0.16 155); font-weight: 500; }
|
|
.b-log .l-current { color: var(--fg); }
|
|
.b-log .l-done { color: var(--fg-mute); }
|
|
.b-log .l-done::before { content: "✓ "; color: var(--ok); margin-right: 2px; }
|
|
.b-log .l-current::before { content: "● "; color: var(--accent); margin-right: 2px;
|
|
animation: blink 1s steps(2) infinite; }
|
|
.b-cursor {
|
|
display: inline-block;
|
|
width: 7px; height: 13px; vertical-align: -2px;
|
|
background: var(--accent);
|
|
margin-left: 2px;
|
|
animation: blink 1s steps(2) infinite;
|
|
box-shadow: 0 0 12px var(--accent-glow);
|
|
}
|
|
|
|
.b-prev-bar {
|
|
padding: 10px 14px;
|
|
display: flex; gap: 10px; align-items: center;
|
|
border-bottom: 1px solid var(--hairline);
|
|
background: oklch(0.16 0.008 60 / 0.5);
|
|
}
|
|
.b-prev-url {
|
|
font-family: var(--font-mono);
|
|
font-size: 11.5px;
|
|
color: var(--fg-mute);
|
|
padding: 4px 12px;
|
|
background: oklch(0.13 0.008 60);
|
|
border: 1px solid var(--hairline);
|
|
border-radius: 999px;
|
|
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
.b-prev-stage {
|
|
flex: 1;
|
|
display: flex; align-items: center; justify-content: center;
|
|
padding: 22px;
|
|
}
|
|
|
|
.b-stencil {
|
|
width: 100%;
|
|
display: flex; flex-direction: column; gap: 12px;
|
|
opacity: 0;
|
|
animation: stencil-in 0.7s ease-out forwards;
|
|
}
|
|
@keyframes stencil-in {
|
|
from { opacity: 0; transform: translateY(6px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
.b-stencil-block {
|
|
border-radius: 9px;
|
|
background: linear-gradient(135deg, oklch(0.22 0.011 60), oklch(0.18 0.009 60));
|
|
border: 1px solid var(--hairline);
|
|
position: relative; overflow: hidden;
|
|
}
|
|
.b-stencil-block::after {
|
|
content: "";
|
|
position: absolute; inset: 0;
|
|
background: linear-gradient(90deg, transparent, oklch(1 0 0 / 0.06), transparent);
|
|
transform: translateX(-100%);
|
|
animation: shimmer 2s ease-in-out infinite;
|
|
}
|
|
@keyframes shimmer { to { transform: translateX(100%); } }
|
|
|
|
@keyframes blink { 50% { opacity: 0.25; } }
|
|
@keyframes pulse {
|
|
0% { box-shadow: 0 0 0 0 oklch(0.78 0.16 155 / 0.6); }
|
|
70% { box-shadow: 0 0 0 8px oklch(0.78 0.16 155 / 0); }
|
|
100% { box-shadow: 0 0 0 0 oklch(0.78 0.16 155 / 0); }
|
|
}
|
|
|
|
.b-foot {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
gap: 14px;
|
|
padding-top: 6px;
|
|
}
|
|
.b-foot-status {
|
|
font-family: var(--font-mono);
|
|
font-size: 11px; letter-spacing: 0.06em;
|
|
color: var(--fg-faint);
|
|
}
|
|
.b-foot-status b { color: var(--accent); font-weight: 500; }
|
|
`}</style>
|
|
|
|
<WizardQ
|
|
title={done ? "Your workspace is live." : "Building your workspace…"}
|
|
sub={done
|
|
? "Open it to see what Vibn made. Every change from here happens in the chat."
|
|
: "Nothing for you to do. Vibn is scaffolding everything from your answers."}
|
|
/>
|
|
|
|
<div className="b-grid">
|
|
{/* terminal */}
|
|
<div className="b-pane">
|
|
<div className="b-pane-bar">
|
|
<span style={{ width: 9, height: 9, borderRadius: "50%", background: "oklch(0.65 0.18 25 / 0.7)" }} />
|
|
<span style={{ width: 9, height: 9, borderRadius: "50%", background: "oklch(0.78 0.13 80 / 0.7)" }} />
|
|
<span style={{ width: 9, height: 9, borderRadius: "50%", background: "oklch(0.72 0.16 145 / 0.7)" }} />
|
|
<span style={{ marginLeft: 6 }}>vibn build — {url}</span>
|
|
{!done && <span className="live">live</span>}
|
|
</div>
|
|
<div className="b-log" ref={logRef}>
|
|
{plan.slice(0, lineIdx + 1).map((p, i) => {
|
|
const isCurrent = i === lineIdx && !done;
|
|
const isOk = p.ok && i <= lineIdx;
|
|
const cls = isOk ? "l-ok" : isCurrent ? "l-current" : "l-done";
|
|
return <div key={i} className={cls}>{p.line}</div>;
|
|
})}
|
|
{!done && <span className="b-cursor" aria-hidden="true" />}
|
|
</div>
|
|
</div>
|
|
|
|
{/* preview */}
|
|
<div className="b-pane">
|
|
<div className="b-prev-bar">
|
|
<span style={{ display: "flex", gap: 5 }}>
|
|
<span style={{ width: 9, height: 9, borderRadius: "50%", background: "oklch(0.30 0.010 60)" }}/>
|
|
<span style={{ width: 9, height: 9, borderRadius: "50%", background: "oklch(0.30 0.010 60)" }}/>
|
|
<span style={{ width: 9, height: 9, borderRadius: "50%", background: "oklch(0.30 0.010 60)" }}/>
|
|
</span>
|
|
<span className="b-prev-url">https://{url}</span>
|
|
</div>
|
|
<div className="b-prev-stage">
|
|
<div className="b-stencil">
|
|
<div style={{ fontSize: 20, fontWeight: 500, letterSpacing: "-0.02em", color: "var(--fg)", lineHeight: 1.15 }}>
|
|
{previewTitle}
|
|
</div>
|
|
<div style={{ fontSize: 13, color: "var(--fg-mute)", lineHeight: 1.45 }}>
|
|
{previewSub}
|
|
</div>
|
|
<div style={{ height: 4 }} />
|
|
<div className="b-stencil-block" style={{ height: 80 }} />
|
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
|
|
<div className="b-stencil-block" style={{ height: 60, animationDelay: "0.4s" }} />
|
|
<div className="b-stencil-block" style={{ height: 60, animationDelay: "0.8s" }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="b-foot">
|
|
<span className="b-foot-status">
|
|
{done ? "build complete" : <>Building <b>{Math.min(lineIdx, plan.length)}/{plan.length}</b></>}
|
|
</span>
|
|
{done && (
|
|
<button type="button" className="btn btn-primary btn-wiz" onClick={onOpen}>
|
|
Open my workspace <Arrow size={13} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ── Ready screen ───────────────────────────────────────────────────────────
|
|
function ReadyScreen({ path, data, onClose, onOpenChat }) {
|
|
const url = workspaceUrlFor(path, data);
|
|
const summary = summaryFor(path, data);
|
|
|
|
return (
|
|
<>
|
|
<WizardTop
|
|
onClose={onClose}
|
|
lane={LANE_LABELS[path]}
|
|
stepText="Ready"
|
|
progress={1}
|
|
/>
|
|
<WizardBody>
|
|
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 8 }}>
|
|
<span
|
|
style={{
|
|
width: 32, height: 32, borderRadius: 8,
|
|
background: "linear-gradient(135deg, var(--accent), oklch(0.65 0.20 18))",
|
|
boxShadow: "0 0 18px var(--accent-glow)",
|
|
display: "grid", placeItems: "center",
|
|
color: "var(--accent-fg)", flexShrink: 0,
|
|
}}
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor"
|
|
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<path d="m3 8.5 3.2 3.2L13 5"/>
|
|
</svg>
|
|
</span>
|
|
</div>
|
|
|
|
<WizardQ
|
|
title="You're in."
|
|
sub="Workspace provisioned, first build is online. Every change from here happens in the chat."
|
|
/>
|
|
|
|
<div
|
|
style={{
|
|
borderRadius: 10,
|
|
border: "1px solid var(--hairline)",
|
|
background: "oklch(0.18 0.009 60 / 0.6)",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
padding: "14px 16px",
|
|
borderBottom: "1px solid var(--hairline)",
|
|
display: "flex", alignItems: "center", gap: 10,
|
|
}}
|
|
>
|
|
<span className="mono" style={{ fontSize: 10.5, color: "var(--fg-faint)", letterSpacing: "0.12em", textTransform: "uppercase" }}>
|
|
Workspace URL
|
|
</span>
|
|
<span className="mono" style={{ fontSize: 14, color: "var(--fg)", marginLeft: "auto" }}>
|
|
{url}
|
|
</span>
|
|
</div>
|
|
<div style={{ padding: "12px 16px 14px", display: "flex", flexDirection: "column", gap: 8 }}>
|
|
{summary.map((row, i) => (
|
|
<div key={i} style={{ display: "flex", gap: 14, alignItems: "baseline", fontSize: 13.5 }}>
|
|
<span
|
|
className="mono"
|
|
style={{
|
|
width: 86, flexShrink: 0,
|
|
color: "var(--fg-faint)",
|
|
fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase",
|
|
}}
|
|
>
|
|
{row.label}
|
|
</span>
|
|
<span style={{ color: "var(--fg-dim)" }}>{row.value}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="wiz-foot">
|
|
<a href="index.html" className="wiz-skip">Back to home</a>
|
|
<button type="button" className="btn btn-primary btn-wiz" onClick={onOpenChat}>
|
|
Open the build chat <Arrow size={13} />
|
|
</button>
|
|
</div>
|
|
</WizardBody>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function summaryFor(path, data) {
|
|
if (path === "owner") {
|
|
return [
|
|
{ label: "Business", value: `${data.bizName || "Untitled"} · ${BUILD_BIZ_LABEL[data.biz] || "Small business"}` },
|
|
{ label: "Replacing", value: `${(data.tools || []).length} tools · ~$${data.spend || 0}/mo` },
|
|
{ label: "First fix", value: labelFor(data.firstThing) },
|
|
{ label: "Team", value: `${data.team || 1} · ${(data.customers || 0).toLocaleString()} cust/mo` },
|
|
];
|
|
}
|
|
if (path === "consultant") {
|
|
return [
|
|
{ label: "Client", value: `${data.clientName || "Untitled"} · ${data.industry || ""}` },
|
|
{ label: "Scope", value: `${(data.scope || []).length} modules` },
|
|
{ label: "Brief", value: (data.brief || "").slice(0, 60) + ((data.brief || "").length > 60 ? "…" : "") },
|
|
{ label: "Handoff", value: data.handoff || "subdomain" },
|
|
];
|
|
}
|
|
return [
|
|
{ label: "Building", value: (data.idea || "").slice(0, 64) + ((data.idea || "").length > 64 ? "…" : "") },
|
|
{ label: "Audience", value: (data.audience || "").slice(0, 64) },
|
|
{ label: "Goal", value: data.goal || "first_customer" },
|
|
{ label: "Vibe", value: data.vibe || "warm" },
|
|
];
|
|
}
|
|
|
|
Object.assign(window, { BuildScreen, ReadyScreen });
|