feat(ai): optimize tool loops, fix deployments, and integrate new onboarding flow

This commit is contained in:
2026-05-19 12:52:47 -07:00
parent 726fb02560
commit 93087d4f9a
243 changed files with 3670 additions and 24413 deletions

View File

@@ -1,86 +0,0 @@
media/8a480f0b521d4e75-s.8e0177b5.woff2,1764292945234,cccaf7bf72117d313a9afc3a475289a19b9fcd0f735c9b2e4ded6cba08a517a0
media/7178b3e590c64307-s.b97b3418.woff2,1764292945325,2b719e95831c9d92a31ebbe512bbd87cc76501765edb9b6ca4734cc5a2becb94
media/4fa387ec64143e14-s.c1fdd6c2.woff2,1764292945447,7cdf2599fa32a0a3edc7d4126b5c8f5233d62799ddb46552ca6890389ba7d9c1
debHSXN92soAOPU1HZgYF/_ssgManifest.js,1764292948075,02dbc1aeab6ef0a6ff2ff9a1643158cf9bb38929945eaa343a3627dee9ba6778
debHSXN92soAOPU1HZgYF/_clientMiddlewareManifest.json,1764292945829,6731668a37f6a3ed10d77860e21c7ba693c377a061571ffa58564d1d699c1798
debHSXN92soAOPU1HZgYF/_buildManifest.js,1764292945829,615bff88115b95e28f767491701e61ecffa7e94106127d229aa17a5905c320ed
chunks/f9cb1844dfa45255.js,1764292945340,01c00f2411e0e3e097e7018d72b70b2b51875d8d225caaf29640ce25a2a544f6
chunks/f7f1f72136b370ca.js,1764292945327,01a104bc08fcc0604c0ec1a806fca89dcf30e910b371b53deef33ca6265eecde
chunks/ebe72e5be9ee5ad9.js,1764292945628,daae931739a356f539b4ad0ca419f1eb5b4bb3778cbb61dd7caf61a1d9865f2a
chunks/f5e1eb514e39cc88.js,1764292944956,e4565dc0670c37ec0f447d2b5b70d452743abe10a95fed3845c1cce4abb48b4c
chunks/f2800e0af697fdd0.js,1764292945328,6ce375e87966595708eadaf758ada294509c58dfac5ed8c59f70f616721c00c3
chunks/e4262adb08a1c2bf.js,1764292945289,d4460e79d3dc59fc127276a44923f64f9a58a4265dabfec5271f2558d9fd2bc6
chunks/turbopack-9ca93d673567d695.js,1764292945039,8192a0d1a0740e6d887b97a7403a2a7f247ef214e8920cab730ff87fed58437b
chunks/ddebd270303d8e52.js,1764292945615,8c3623313303c5d58f40c3d5a8507a7f26b878129e1a322a4c49cc1abeb27a22
chunks/e3204003115c48b3.js,1764292945452,4f1d81d5502dc30584b94916d1192cca4ad31abc0c9ec90a41b8a086d2ad36d8
chunks/f44a1b0e13fe130e.js,1764292945521,bab4ad594f82e40e230885b71ad2e66bbdcc9adf0a06e5dabbb9881b3f090bfd
chunks/d8fb8fcccb9ed575.js,1764292945641,88dc104238c97d623ed03c0457896bf0e5576f0e555b0521429514968254db72
chunks/d53fe979bca22d91.js,1764292945292,7e0b70993217191be5b9c1cbcb8eae8e93f77d2ffc8b8c47260dddf45531b0bf
chunks/e9d7f43cc4a2ffde.js,1764292944977,f3b75db76ea5f0e0897a6643c7eac52ef942c66e06c9e923d8adf0842b6102df
chunks/c8f249a29afd3371.js,1764292945174,cf90afbb172a0e61b4bb1eba8b903cdf008a5b5dcbb22fbeaad4bdf99a1aa1a7
chunks/d37715a4848800df.js,1764292945614,3c838423e38372ac0fbf00a2d2a665bd128b7b3bb282fcc3faddbf119821529c
chunks/cc3b7acd0b8a8ed0.js,1764292945378,63c43444cf37250c0265200f90da944fe7cb30cbf4712ca78320313cc28ae2c0
chunks/e930ad9e05eaf62d.js,1764292944935,59ebe62acad73a7571001179dfdb02a87205e2b822ca22262e97b8a6582fe5cb
media/favicon.0b3bf435.ico,1764292945309,04614fc32690cb60b39e472119b7f7aa91d88eaeb8511a7489f8cbe1552e6e59
chunks/b7d3d522b141a153.js,1764292945341,2f7e530895f432df1e996fe86ed3299a10f7ba8585e1b0d9d206f93cc228bb18
chunks/b2d11888d122e656.js,1764292945620,f318502788773401c1f1f95699d1d3b62a6e021a53f7e2bff8fdd9a2c6cee3b7
chunks/b297c493e9d2a547.js,1764292945333,562b883b63a596c2de501948be11263522bf0ba4c16cf2e779c6cd74cad7dcf9
chunks/b678db9b7a6233a6.js,1764292945451,5930f7e3b147761c765cf03570bf989a4376adc16294f8e7f63041b910145c21
chunks/aee0a3aab75c6656.js,1764292945645,74ef1a854f40fccfc42849f0eff252e9a9a099dac0fb4afa98b17e3aa773d361
chunks/a65fa752112154b2.js,1764292945316,695a77ee322b9e0e9597b24b6efd71d653f3651aa579b479f73bf36b93c4211d
chunks/9c2f2a94801db6cd.js,1764292945267,6bde7c92d8ae55b0bdb2f9cc508671a69314ff01c824d16ffae15b41203123a3
chunks/8f12ba4a400d0818.js,1764292945640,9d69d3faa9752f8dcc197c2e62ea5c9a843e2431de85570ec152260f0606a64e
chunks/9d593509176d2bdc.js,1764292945182,58f2e77ebd69f17e0e850f3dff502266bac2f51f6a66e067459ee2344c4c1823
chunks/8f647170168e8688.js,1764292945436,6d91c1f674b325e03565f00bb0ee8f017c247a836b170067b7271c4184a76368
chunks/8269db69d7104eaf.js,1764292945289,46b9e7e465ed39ef4b9030e292173ca01e4141f67d7290d9a8df5cb8cfdb94a4
chunks/a3751053cf95bf66.js,1764292944936,a1864f7575f28ae1495943736f439149ff7ca641c48f352a27b8241e1c5b1895
chunks/7f6ce89234677f07.js,1764292945596,d028183aa6747e759e6e2e2f94012efeb3c01edd9eab81cffa463ca884daa9fb
chunks/7a5de61b06aada33.js,1764292945448,4de9756e37dc8195f5d3ab68b7d70a1228b3ef0c14fa376f7d4a41bdfdde1a4b
chunks/7e6878fd487d3e54.js,1764292945013,f13804b8190486b356af32ba87000b503316d82c8dce6a5bca9e65e39189605d
chunks/b72884fc3dd51b08.js,1764292944972,83adbd329c23f79df34e0bd3d9d52f2f8f888c6d6a3ff45698a67e90a3b1e485
media/bbc41e54d2fcbd21-s.799d8ef8.woff2,1764292945043,396955195c54144bce504511dd89d0c74a3f6b453d73823073be1a2cbe00e6de
chunks/c4e22d55290821bf.js,1764292945037,27669ee06cf5e963c5a0e12977384e4c2894b9befa9bc0d13ccc4ecc3dc2d43b
media/caa3a2e1cccd8315-s.p.853070df.woff2,1764292945300,d38dd3d36107934ef290b2449c29728caa7bcceeb4750b0a2bec2042fac4c601
media/797e433ab948586e-s.p.dbea232f.woff2,1764292945245,d4a2afa79a272709433753cffe4f64c13e37ae2fdfa1ded22b38c83f978b78a4
chunks/8decf5edbe5dd12a.js,1764292945026,a9150cd9cf27455e4190ae18a8db7fca07bc34a3a8b689d1d5cb3524b13c0880
chunks/ef5b2d67ab809f64.css,1764292945059,90165d549a46ffb7036763cb03adfd897952fc93c25201d746605436ef5ea46b
chunks/d0f2cbc50b9d061e.js,1764292945736,3c74944275ad985170b8011410d2a5ccc6c6026cf0d4c96951b128dfb8661833
chunks/767a4a5f6aabf6e2.js,1764292945172,bbbb2624994ac119eb5bc7a692eb7b97d3ffba0d4d8160a406aee89a81c212a8
chunks/74c6d21fb44e33e9.js,1764292945162,7a886154420672f0f8395bdf535bd17f52063a455ede9987e3b6ea7d5bc91479
chunks/78680bef0dd9c8e5.js,1764292945077,ec8a5a1356c00f3dbb68f2a7655a6e7ba711212a4f5ced7994a82331eb94c32a
chunks/67c396666365a0f8.js,1764292945315,63772f51138d6a11e29d5f270f1c2c7de60f1cb1218c0d5a51488272d01f265e
chunks/6c4b3aa006ad826c.js,1764292945616,523294bac6d6a537fc1dbfe5287ae46897908edc7e56b0fbac214b9ac3d4d6cc
chunks/6eca49992d798c82.js,1764292944887,12d6b568615b35975b20c73435dcdc7c11a27c92ca224cae23ddd1d5461c3544
chunks/74e1fd68a7e0896e.js,1764292944962,05e56d1e6e1fca0c5ed8e0957cf019f861c764e7221f33d0ef52116238f814bc
chunks/5e558e7e27d2aa84.js,1764292945458,5f105cc0e1577cc8ebb66e71f6c5b8e564dd34db3a3542ada5f302e8a23e3b58
chunks/58f8c6398723d54a.js,1764292945344,8673d8f45265a904abbae8551f71c5a5521c0725b93da73661b93af6a6583991
chunks/5d0b6a3739039b40.js,1764292944980,bd68dfe5373212a24cb45220c93568159d5341b3198997a613fb6fd193880f98
chunks/5db6f063644758f9.js,1764292944959,c80f1a857f8d76e5634e745e8bd7d8994e78eb33961668431cb366851cb16d5e
chunks/523ae041bd709184.js,1764292945042,ef06a0e01cf2cc8cf4883811ef022a392f335d1d38e9a989cf62bae079deea60
chunks/424c4036add0df26.js,1764292945340,b3e7b7f805682f0b0436474ecded19ccce5b30dcb76a8495d8bee88eede11be0
chunks/3d3465d604d848a9.js,1764292945339,92abaf6ffd0e336dee0fdfcf6fb6243aac311a212e834594b3e7de146e5d3f6e
chunks/334c0b45eeee04d3.js,1764292945574,874d6b35af5399e08a2cb7cc1b45cec6a7929205ae6371ef800dd924c20bc293
chunks/3cf25d104286385c.js,1764292944988,7529dca0fda4234018cdbe2a24db01affdbf5bbe7ca6f97da46d3c1fa97ea8d4
chunks/3e4ff1ad25a3aee4.js,1764292945339,234650a4582a73748c2f6e6d0ddc8a9aa3eb664cae80464126a35aa95f295615
chunks/2fd974473265b3b8.js,1764292945207,fc5a8fab93123a055f6f1ca470cbb2ef213e4f075658f13038f265533887cdb8
chunks/36fc507596e706a4.js,1764292944943,0f382cd8c81102e98bd414ca12d0e1951449a8dcaf734435e973e4e030b6a920
chunks/211e6519dff5166f.js,1764292945000,388888d4e8a2df019664930f161a592dd7c676134ae9a2760abab30f10a1c12c
chunks/1f21d91f935fa2f4.js,1764292945414,3bcfd338aac600b69f6c50d060739432e3c8de64fb22ef7db4e2fbc88d35199f
chunks/483a049d7197220c.js,1764292944995,8f6f500d3b55f867e389a55ffc2a5808a01f75422e2bae4bc07e231d9f70d6f5
chunks/1f6d845154a92f55.js,1764292945060,b9ac0f84700799143de73d09457b4973bb43f4ee0bf57ff742ef83491eef2aa5
chunks/1f3d32af4b7e9fce.js,1764292944988,b589322566402033b3b58dd2ddba216b1819f82a8643a08877c4402c89de8cb7
chunks/1ad9158bace97ad1.js,1764292945642,9ea54650308e293190ed8a7d7ef942e1029cc2cda2d2b03c1759fcd9b175c4e4
chunks/0924dac1a36a5d4f.js,1764292945341,a823c1c585aea754343d4947d1c35350eec6544b9772486515756ec252992cb8
chunks/22798aa879c2d479.js,1764292945062,a84d48fd0cb3fa97a0689f059806866fc2fe685e4c13b61b936bb13b7b729dc2
chunks/01de74e34c8191ad.js,1764292945439,0f86a0b77fcffaa64a1869842351812295bca22dcdccf7a89616a0fe4a812848
chunks/13c76ac4c576ebea.js,1764292945064,864fb695e806c8fd95eab7ff98fdf24c7fcab284d2eec9a2b030edea5ccd04a1
chunks/a6dad97d9634a72d.js,1764292945738,bea630d9824beca22855271c757404b58bb7b410c52a5e7d58d69ff26d9ddd0b
chunks/055808b7b4395593.js,1764292945729,5a06bd47cb2c83a7b4363f3d7b02796224575ffce0dc51fb66157df312ea6239
chunks/051191fc7c032fe7.js,1764292945459,5d675199d64b330bc32091e0d807a5d807c559ebbeb535d3b81e46d9ac0beba4
chunks/5ba52f526366ce3d.js,1764292945252,94e2ba60b4d2d276adc47cc684fe3b41b7130b438a22f13fb03240cd3079b9c1
chunks/0839f6c03dd07402.js,1764292945458,9b607c0411e2db92fea878d5d9450aceae119f0bb4f86eef757b7f012e353ba7
chunks/02cfabe42ac75354.js,1764292945726,edd9cc50162ed881e6f63569e677d1f330cf09a782c74dd2af3183ac20cf23ed
chunks/770045fdeaa29947.js,1764292945315,fbff4e91fe383eb226ec79f5d7e557d3f1f798d724a8cd0da9e2606ecab997d5
chunks/da99455a9bebb11a.js,1764292945437,96396c6b5792f967eb51bfdb0a59b2c10ef6c9cac6bab6ec346832219504f8c5
chunks/667df385421d23bd.js,1764292945329,0f4ba7a21ad0772c31d49384e853c4f5cde23a277a76e93e14c6ffa8a4b00f1d
media/icon.69668ad2.png,1764292945757,a9312b012897c18eb945ecc474445181c063a6ff2363fefdd295bca3e7f17a70

View File

@@ -1,10 +0,0 @@
window.svg,1762902124964,11deaca6eadbb148caace8a5fe4a67353112de0afc5da83005d4797e403ab4f1
vobn-favicon.png,1763082657981,9051755f781b64be5155a8ef6b1846afae7ed12a942ce1fca217cde1fe0a4f09
vibn-sqaure-black-logo.png,1763083818226,2cd39bf33b13110575f3a2b02b4558c5dd157d96506783526d11988a01dbe249
vibn-logo-circle.png,1763083818571,b24c20ee6505547a3cd03a492681b281bf49c8e95eed78872e87581b5218eee2
vibn-black-circle-logo.png,1763083818322,a9312b012897c18eb945ecc474445181c063a6ff2363fefdd295bca3e7f17a70
vibn-2-logo.png,1763081817627,475fcbd3e4fa36dc5c63191220b5090ae90ae36d74d968240af25464829286fa
vercel.svg,1762902124964,9a61e768442ba3450026d0d69421315044931cbffaf8f6019f856ea82dd91e4e
next.svg,1762902124964,33c5c6ad1d08bb69d8026289530e377b4d6e2a96f24562e209fd1e1e9ccee64a
globe.svg,1762902124964,ffe166407c928caa4d1640e2786d3385468043b3b9e6ea2282d4a3e370b3bc23
file.svg,1762902124964,154a8c2948836a88c695a789045bc44cc74c3d8958d5785a531d26324bc42cb1

View File

@@ -1,6 +0,0 @@
{
"projects": {
"default": "gen-lang-client-0980079410"
}
}

View File

@@ -0,0 +1,447 @@
import React, { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { WizardTop, WizardBody, WizardQ, LANE_LABELS } from "./onboarding-primitives";
// 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 ───────────────────────────────────────────────────────────
export 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 <svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 8h10M9 4l4 4-4 4"/></svg>
</button>
)}
</div>
</div>
</main>
</>
);
}
// ── Ready screen ───────────────────────────────────────────────────────────
export 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 <svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 8h10M9 4l4 4-4 4"/></svg>
</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" },
];
}

View File

@@ -0,0 +1,296 @@
import React, { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { WizardTop, WizardBody, WizardQ, WizardFooter, Label, LANE_LABELS, PresetGroup, Field } from "./onboarding-primitives";
// Consultant path — 4 steps for freelancers building for a client.
const CONS_TOTAL = 4;
const CONS_STEP_NAMES = ["Client", "Brief", "Scope", "Handoff"];
export function ConsClient({ clientName, industry, contact, onChange }) {
return (
<>
<WizardQ
title="Who are you building for?"
sub="Used to brand the preview and the handoff doc."
/>
<Field label="Client / company">
<input
className="wiz-input"
placeholder="Rivera & Co Roofing"
value={clientName}
onChange={(e) => onChange({ clientName: e.target.value })}
autoFocus
/>
</Field>
<Field label="What they do">
<input
className="wiz-input"
placeholder="Residential roofing, mostly insurance jobs"
value={industry}
onChange={(e) => onChange({ industry: e.target.value })}
/>
</Field>
<Field label="Your point of contact" optional hint="So Vibn can address them in the handoff.">
<input
className="wiz-input"
placeholder="Marisol Rivera, Owner"
value={contact}
onChange={(e) => onChange({ contact: e.target.value })}
/>
</Field>
</>
);
}
const BRIEF_TEMPLATES = [
{ id: "quote_tool", label: "Quote tool",
body: "Customers request a quote with a few photos and a project description. The team reviews, sends a polished PDF, customer signs and pays a deposit online." },
{ id: "booking", label: "Booking system",
body: "Customers see real availability, book a service window, and get reminders. The team has a daily view of jobs with addresses and contact info." },
{ id: "portal", label: "Customer portal",
body: "Logged-in customers see past jobs, invoices, documents, and can message the business. The business sees a single page per customer." },
{ id: "internal", label: "Internal ops",
body: "Replace the spreadsheets the team is currently using. CRUD on jobs/customers, simple reports, role-based access, export to accounting." },
];
function ConsBrief({ brief, onChange }) {
return (
<>
<WizardQ
title="What did they ask for?"
sub="Paste the brief, or start from a template and edit."
/>
<Field label="Start from a template" optional>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{BRIEF_TEMPLATES.map((t) => (
<button
key={t.id}
type="button"
className="preset-chip"
style={{ padding: "8px 14px", fontSize: 13 }}
onClick={() => onChange(t.body)}
>
{t.label}
</button>
))}
</div>
</Field>
<Field label="Brief">
<textarea
className="wiz-input"
style={{ minHeight: 160 }}
placeholder="The client wants…"
value={brief}
onChange={(e) => onChange(e.target.value)}
/>
</Field>
</>
);
}
const SCOPE_GROUPS = [
{ label: "Foundations", items: ["Auth & accounts", "Roles & permissions", "Database & admin", "Hosting & domain"] },
{ label: "Customer-facing", items: ["Marketing site", "Booking / scheduling", "Quote requests", "Customer portal", "Self-serve forms"] },
{ label: "Money", items: ["Stripe / payments", "Invoices & receipts", "Subscriptions", "Refunds & disputes"] },
{ label: "Ops", items: ["Dashboards & reports", "CSV import / export", "Email + SMS notifs", "Audit log"] },
];
function ConsScope({ scope, onChange }) {
const toggle = (item) => {
if (scope.includes(item)) onChange(scope.filter((x) => x !== item));
else onChange([...scope, item]);
};
return (
<>
<WizardQ
title="What's in scope?"
sub="Tick what you've signed up to deliver. The rest you can add later — billable."
/>
<div
style={{
display: "grid",
gap: 12,
gridTemplateColumns: "repeat(2, 1fr)",
}}
>
{SCOPE_GROUPS.map((g) => (
<div
key={g.label}
style={{
padding: "14px 14px 10px",
borderRadius: 10,
border: "1px solid var(--hairline)",
background: "oklch(0.18 0.009 60 / 0.6)",
}}
>
<div
className="mono"
style={{
fontSize: 10.5, letterSpacing: "0.12em", textTransform: "uppercase",
color: "var(--fg-mute)", marginBottom: 10,
}}
>
{g.label}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{g.items.map((it) => {
const active = scope.includes(it);
return (
<button
key={it}
type="button"
onClick={() => toggle(it)}
style={{
display: "flex", alignItems: "center", gap: 10,
textAlign: "left",
padding: "6px 4px",
color: active ? "var(--fg)" : "var(--fg-dim)",
borderRadius: 6,
fontSize: 13.5,
}}
>
<span
style={{
width: 16, height: 16,
flexShrink: 0,
borderRadius: 4,
border: `1px solid ${active ? "var(--accent)" : "var(--hairline-2)"}`,
background: active ? "var(--accent)" : "transparent",
color: "var(--accent-fg)",
display: "grid", placeItems: "center",
}}
>
{active && (
<svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor"
strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m3 8.5 3.2 3.2L13 5"/>
</svg>
)}
</span>
{it}
</button>
);
})}
</div>
</div>
))}
</div>
</>
);
}
function ConsHandoff({ data, onChange }) {
return (
<>
<WizardQ
title="And finally — delivery."
sub="Where it lives, how you bill. Change later from settings."
/>
<Field label="Brand colors" optional hint="Hex, or just describe it. Paste a link, drop a screenshot — Vibn will figure it out.">
<input
className="wiz-input"
placeholder="#1B4D3E and #F2E2C4 — matches their truck wraps"
value={data.brand || ""}
onChange={(e) => onChange({ brand: e.target.value })}
/>
</Field>
<Field label="Where should it live?">
<PresetGroup
options={[
{ id: "subdomain", label: "Vibn subdomain", desc: "client-name.vibn.app — fastest." },
{ id: "custom", label: "Their custom domain", desc: "We'll walk you through DNS." },
{ id: "transfer", label: "Transfer ownership", desc: "They own it. You stay billable as editor." },
]}
value={data.handoff}
onChange={(v) => onChange({ handoff: v })}
columns={1}
/>
</Field>
<Field label="Your hourly rate" optional hint="Used on the handoff doc & invoice template.">
<div style={{ position: "relative" }}>
<span
className="mono"
style={{
position: "absolute", left: 14, top: "50%", transform: "translateY(-50%)",
color: "var(--fg-faint)", fontSize: 14.5, pointerEvents: "none",
}}
>$</span>
<input
className="wiz-input"
type="number"
min="0"
placeholder="120"
value={data.rate || ""}
onChange={(e) => onChange({ rate: e.target.value })}
style={{ paddingLeft: 26, paddingRight: 58 }}
/>
<span
className="mono"
style={{
position: "absolute", right: 14, top: "50%", transform: "translateY(-50%)",
color: "var(--fg-faint)", fontSize: 11.5, letterSpacing: "0.04em",
pointerEvents: "none",
}}
>/ hour</span>
</div>
</Field>
</>
);
}
// ── Path wrapper ───────────────────────────────────────────────────────────
export function ConsultantPath({ data, onUpdate, onBack, onClose, onComplete, onJumpToStep, step }) {
const next = () => {
if (step < CONS_TOTAL - 1) onJumpToStep(step + 1);
else onComplete();
};
const back = () => {
if (step === 0) onBack();
else onJumpToStep(step - 1);
};
let body, canNext;
if (step === 0) {
body = (
<ConsClient
clientName={data.clientName || ""}
industry={data.industry || ""}
contact={data.contact || ""}
onChange={onUpdate}
/>
);
canNext = (data.clientName || "").trim().length >= 2 && (data.industry || "").trim().length >= 3;
} else if (step === 1) {
body = <ConsBrief brief={data.brief || ""} onChange={(v) => onUpdate({ brief: v })} />;
canNext = (data.brief || "").trim().length >= 30;
} else if (step === 2) {
body = <ConsScope scope={data.scope || []} onChange={(v) => onUpdate({ scope: v })} />;
canNext = (data.scope || []).length >= 2;
} else {
body = <ConsHandoff data={data} onChange={onUpdate} />;
canNext = !!data.handoff;
}
return (
<>
<WizardTop
onBack={back}
onClose={onClose}
lane={LANE_LABELS.consultant}
stepText={CONS_STEP_NAMES[step]}
current={step + 2}
total={5}
/>
<WizardBody width={step === 2 ? "wide" : null}>
{body}
<WizardFooter
onNext={next}
canNext={canNext}
nextLabel={step === CONS_TOTAL - 1 ? "Spin up project →" : "Continue"}
hint={canNext ? "⌘↵" : null}
/>
</WizardBody>
</>
);
}

View File

@@ -0,0 +1,276 @@
import React, { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { WizardTop, WizardBody, WizardQ, WizardFooter, Label, LANE_LABELS, ChipGroup, PresetGroup, Field } from "./onboarding-primitives";
// Entrepreneur path — 4 steps. Each step is a focused question.
const ENTREP_TOTAL = 4;
const ENTREP_STEP_NAMES = ["Idea", "Audience", "Goal", "Look"];
const IDEA_PROMPTS = [
"A community for indie game devs to swap playtesters, with weekly demo nights",
"An AI tool that turns my handwritten recipe notes into a clean cookbook for my family",
"A waitlist + scheduler for my pottery studio — small classes, six people max",
"A subscription box service for cold-brew enthusiasts, with monthly tasting cards",
"A simple tool that turns my Strava data into framed art prints I can sell",
];
export function EntrepIdea({ value, onChange }) {
const [phIdx, setPhIdx] = React.useState(0);
const [phChars, setPhChars] = React.useState(0);
const [deleting, setDeleting] = React.useState(false);
React.useEffect(() => {
if (value.length > 0) return undefined;
const full = IDEA_PROMPTS[phIdx];
const speed = deleting ? 18 : 38;
const t = setTimeout(() => {
if (!deleting) {
if (phChars < full.length) setPhChars(phChars + 1);
else setTimeout(() => setDeleting(true), 1500);
} else {
if (phChars > 0) setPhChars(phChars - 1);
else { setDeleting(false); setPhIdx((phIdx + 1) % IDEA_PROMPTS.length); }
}
}, speed);
return () => clearTimeout(t);
}, [value, phIdx, phChars, deleting]);
return (
<>
<WizardQ
title="What are you building?"
sub="One paragraph is enough. Talk like you would to a friend."
/>
<div style={{ position: "relative" }}>
<textarea
className="wiz-input"
style={{ minHeight: 140, fontSize: 15 }}
value={value}
onChange={(e) => onChange(e.target.value)}
autoFocus
aria-label="Describe your idea"
/>
{value.length === 0 && (
<div
style={{
position: "absolute", top: 12, left: 14, right: 14,
pointerEvents: "none",
color: "var(--fg-faint)",
font: "14.5px/1.5 var(--font-sans)",
}}
>
{IDEA_PROMPTS[phIdx].slice(0, phChars)}
<span
style={{
display: "inline-block",
width: 7, height: 14, verticalAlign: "-2px",
background: "var(--accent)", marginLeft: 1,
animation: "blink 1s steps(2) infinite",
boxShadow: "0 0 10px var(--accent-glow)",
}}
/>
</div>
)}
</div>
<div
className="mono"
style={{ fontSize: 11, color: "var(--fg-faint)", letterSpacing: "0.06em", marginTop: -16 }}
>
{value.length} chars · be specific where it matters
</div>
</>
);
}
const AUDIENCE_PRESETS = [
"Me and people like me",
"A small community I'm part of",
"Local people in my city",
"Anyone searching for this",
"Other small businesses",
"Hobbyists in a niche I love",
];
function EntrepAudience({ value, onChange }) {
const isPreset = AUDIENCE_PRESETS.includes(value);
return (
<>
<WizardQ
title="Who is it for?"
sub="The clearer your audience, the better the copy Vibn writes for it."
/>
<ChipGroup
options={AUDIENCE_PRESETS}
values={value ? [value] : []}
onChange={(arr) => onChange(arr[arr.length - 1] || "")}
/>
<Field label="Or describe them in your own words" optional>
<input
className="wiz-input"
placeholder="e.g. dog owners in Brooklyn who walk before work"
value={!isPreset ? value : ""}
onChange={(e) => onChange(e.target.value)}
/>
</Field>
</>
);
}
const GOALS = [
{ id: "first_customer", icon: "🎯", label: "First real customer",
desc: "Someone I don't know pays me. Even once." },
{ id: "ten_users", icon: "👥", label: "Ten weekly users",
desc: "A signal the thing actually does something useful." },
{ id: "mrr_1k", icon: "📈", label: "$1k MRR",
desc: "Enough to take it seriously." },
{ id: "side_quit", icon: "🚪", label: "Replace my day job",
desc: "The long road. Make this the main thing." },
{ id: "audience", icon: "📣", label: "Build a tiny audience",
desc: "200 emails, a community, something I can talk to." },
{ id: "ship_it", icon: "🚀", label: "Just ship it",
desc: "I want the thing to exist." },
];
function EntrepGoal({ value, onChange }) {
return (
<>
<WizardQ
title="What does “working” look like?"
sub="Helps Vibn decide what to build first — a landing page that converts, or a tool that retains."
/>
<PresetGroup
options={GOALS.map((g) => ({
id: g.id, label: g.label, desc: g.desc,
icon: <span style={{ fontSize: 14 }}>{g.icon}</span>,
}))}
value={value}
onChange={onChange}
columns={2}
/>
</>
);
}
const VIBES = [
{ id: "warm", name: "Warm coral", swatch: "linear-gradient(135deg, #E27855, #B33B2A)",
desc: "Confident, hand-built, warm." },
{ id: "ink", name: "Ink & paper", swatch: "linear-gradient(135deg, #1d1d1d, #4a4a4a)",
desc: "Editorial, serif, quiet." },
{ id: "sage", name: "Sage matte", swatch: "linear-gradient(135deg, #7BA890, #3F6B57)",
desc: "Calm, modern, slightly herbal." },
{ id: "neon", name: "Neon arcade", swatch: "linear-gradient(135deg, #5B6CFF, #FF3DDB)",
desc: "Loud, fun, late-night." },
{ id: "cream", name: "Cream linen", swatch: "linear-gradient(135deg, #F2E7D5, #C9A977)",
desc: "Cozy and beige." },
{ id: "later", name: "Decide later", swatch: "repeating-linear-gradient(45deg, oklch(0.30 0.010 60), oklch(0.30 0.010 60) 6px, oklch(0.22 0.010 60) 6px, oklch(0.22 0.010 60) 12px)",
desc: "Vibn picks one that fits." },
];
function EntrepVibe({ value, onChange }) {
return (
<>
<WizardQ
title="Pick a starting vibe."
sub="Every color and font is a tweak away once the site is live."
/>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 10,
}}
>
{VIBES.map((v) => {
const active = value === v.id;
return (
<button
key={v.id}
type="button"
onClick={() => onChange(v.id)}
style={{
padding: "10px 10px 10px",
borderRadius: 11,
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",
textAlign: "left",
color: "var(--fg)",
display: "flex", flexDirection: "column", gap: 8,
transition: "border-color .15s, background .15s",
}}
>
<span
style={{
height: 52, borderRadius: 7,
background: v.swatch,
border: "1px solid oklch(1 0 0 / 0.08)",
boxShadow: "inset 0 1px 0 oklch(1 0 0 / 0.18)",
}}
/>
<span style={{ fontSize: 13, fontWeight: 500, letterSpacing: "-0.005em" }}>
{v.name}
</span>
<span style={{ fontSize: 11.5, color: "var(--fg-mute)", lineHeight: 1.4 }}>
{v.desc}
</span>
</button>
);
})}
</div>
</>
);
}
// ── Path wrapper ───────────────────────────────────────────────────────────
export function EntrepreneurPath({ data, onUpdate, onBack, onClose, onComplete, onJumpToStep, step }) {
const next = () => {
if (step < ENTREP_TOTAL - 1) onJumpToStep(step + 1);
else onComplete();
};
const back = () => {
if (step === 0) onBack();
else onJumpToStep(step - 1);
};
let body, canNext, onSkip = null;
if (step === 0) {
body = <EntrepIdea value={data.idea || ""} onChange={(v) => onUpdate({ idea: v })} />;
canNext = (data.idea || "").trim().length >= 8;
} else if (step === 1) {
body = <EntrepAudience value={data.audience || ""} onChange={(v) => onUpdate({ audience: v })} />;
canNext = (data.audience || "").trim().length >= 3;
} else if (step === 2) {
body = <EntrepGoal value={data.goal} onChange={(v) => onUpdate({ goal: v })} />;
canNext = !!data.goal;
} else {
body = <EntrepVibe value={data.vibe} onChange={(v) => onUpdate({ vibe: v })} />;
canNext = !!data.vibe;
onSkip = () => { onUpdate({ vibe: "later" }); next(); };
}
// 5 total: fork(1) + 4 path steps
return (
<>
<WizardTop
onBack={back}
onClose={onClose}
lane={LANE_LABELS.entrepreneur}
stepText={ENTREP_STEP_NAMES[step]}
current={step + 2}
total={5}
/>
<WizardBody width={step === 2 || step === 3 ? "wide" : null}>
{body}
<WizardFooter
onNext={next}
canNext={canNext}
nextLabel={step === ENTREP_TOTAL - 1 ? "Build →" : "Continue"}
hint={canNext ? "⌘↵" : null}
onSkip={onSkip}
skipLabel="Pick for me"
/>
</WizardBody>
</>
);
}

View File

@@ -0,0 +1,136 @@
import React, { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { WizardTop, WizardBody, WizardQ, WizardFooter, Label } from "./onboarding-primitives";
// Step 1: the only branching question — "which describes you?"
// Quiet radio-style cards. No quotes, no marketing, no glow theatrics.
const FORKS = [
{
id: "entrepreneur",
label: "I'm building my own thing",
hint: "Idea → live → first customer. You're the founder.",
icon: (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="9" cy="9" r="3"/>
<path d="M9 2.5v2M9 13.5v2M2.5 9h2M13.5 9h2"/>
</svg>
),
},
{
id: "owner",
label: "I run a business",
hint: "Replace the stack of tools you currently rent.",
icon: (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M3 6h12l-1 9H4L3 6Z"/>
<path d="M6 6V4.5a3 3 0 0 1 6 0V6"/>
</svg>
),
},
{
id: "consultant",
label: "I build for clients",
hint: "A workspace per client. Bill for the system, not the hours.",
icon: (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M2.5 15 9 3l6.5 12"/>
<path d="M5.5 12h7"/>
</svg>
),
},
];
export function ForkScreen({ name, value, onChange, onClose, onNext }) {
return (
<>
<WizardTop
onBack={null}
onClose={onClose}
stepText="Pick your lane"
current={1}
total={5}
/>
<WizardBody>
<WizardQ
title={name ? `Welcome, ${name}. Which sounds like you?` : "Which one sounds like you?"}
sub="Vibn asks different questions on the next screens depending on the answer. You can change this later."
/>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{FORKS.map((f) => {
const active = value === f.id;
return (
<button
key={f.id}
type="button"
onClick={() => onChange(f.id)}
onDoubleClick={() => { onChange(f.id); onNext(); }}
style={{
display: "flex", alignItems: "center", gap: 14,
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",
textAlign: "left",
color: "var(--fg)",
transition: "border-color .15s, background .15s",
cursor: "pointer",
}}
>
<span style={{
width: 36, height: 36, flexShrink: 0,
borderRadius: 9,
background: active ? "oklch(0.74 0.175 35 / 0.18)" : "oklch(0.22 0.011 60)",
border: "1px solid var(--hairline)",
color: active ? "var(--accent)" : "var(--fg-mute)",
display: "grid", placeItems: "center",
}}>
{f.icon}
</span>
<span style={{ display: "flex", flexDirection: "column", gap: 2, flex: 1 }}>
<span style={{ fontSize: 15, fontWeight: 500, letterSpacing: "-0.008em" }}>
{f.label}
</span>
<span style={{ fontSize: 13, color: "var(--fg-mute)", lineHeight: 1.4 }}>
{f.hint}
</span>
</span>
<span
style={{
width: 18, height: 18, flexShrink: 0,
borderRadius: "50%",
border: `1.5px solid ${active ? "var(--accent)" : "var(--hairline-2)"}`,
background: active ? "var(--accent)" : "transparent",
display: "grid", placeItems: "center",
color: "var(--accent-fg)",
transition: "border-color .15s, background .15s",
}}
>
{active && (
<svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor"
strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m3 8.5 3.2 3.2L13 5"/>
</svg>
)}
</span>
</button>
);
})}
</div>
<WizardFooter
canNext={!!value}
onNext={onNext}
nextLabel="Continue"
hint={value ? "Press ⌘↵" : null}
/>
</WizardBody>
</>
);
}

View File

@@ -0,0 +1,264 @@
import React, { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { WizardTop, WizardBody, WizardQ, WizardFooter, Label, LANE_LABELS, ChipGroup, PresetGroup, Slider, Field } from "./onboarding-primitives";
// Owner path — 4 steps for small-business owners replacing their stack.
const OWNER_TOTAL = 4;
const OWNER_STEP_NAMES = ["Business", "Stack", "First fix", "Scale"];
const BIZ_KINDS = [
{ id: "service", icon: "🛠", label: "Trades / home services", desc: "Plumbing, HVAC, landscaping, cleaning" },
{ id: "retail", icon: "🛍", label: "Retail / shop", desc: "Vintage, boutique, market, online" },
{ id: "food", icon: "🥐", label: "Food & drink", desc: "Café, bakery, food truck, catering" },
{ id: "appointments", icon: "💈", label: "Appointment-based", desc: "Salon, studio, clinic, tutoring" },
{ id: "events", icon: "🎟", label: "Events / hospitality", desc: "Venue, rental, planning" },
{ id: "other", icon: "✦", label: "Something else", desc: "We'll learn from your answers" },
];
export function OwnerBiz({ value, name, onChange, onNameChange }) {
return (
<>
<WizardQ
title="What does your business do?"
sub="Roughly. We tailor the next screens to match."
/>
<PresetGroup
options={BIZ_KINDS.map((b) => ({
id: b.id, label: b.label, desc: b.desc,
icon: <span style={{ fontSize: 14 }}>{b.icon}</span>,
}))}
value={value}
onChange={onChange}
columns={2}
/>
<Field label="Business name">
<input
className="wiz-input"
placeholder="Sunrise Plumbing, Pearl Lane Bakery…"
value={name}
onChange={(e) => onNameChange(e.target.value)}
/>
</Field>
</>
);
}
const STACK_TOOLS = [
"Square / POS",
"Stripe",
"Calendly",
"Acuity",
"Shopify",
"QuickBooks",
"Mailchimp",
"Instagram",
"Google Sheets",
"Notion / Airtable",
"Wix / Squarespace",
"WhatsApp / Slack",
"A printed binder",
"Head + notepad",
];
function OwnerStack({ tools, spend, onToolsChange, onSpendChange }) {
return (
<>
<WizardQ
title="What are you renting right now?"
sub="Tap everything you pay for. Approximate is fine."
/>
<Field label="Tools & subscriptions">
<ChipGroup
options={STACK_TOOLS}
values={tools || []}
onChange={onToolsChange}
allowOther
/>
</Field>
<Field label="About how much per month?" hint="Across all your software, ballpark.">
<Slider
min={0} max={1500} step={25}
value={spend ?? 250}
onChange={onSpendChange}
format={(v) => v === 0 ? "$0" : v === 1500 ? "$1.5k+" : `$${v}`}
/>
</Field>
{(tools || []).length > 0 && (
<div
style={{
padding: "12px 14px",
borderRadius: 10,
border: "1px solid var(--hairline)",
background: "oklch(0.18 0.009 60 / 0.6)",
fontSize: 13.5,
lineHeight: 1.5,
color: "var(--fg-dim)",
display: "flex", gap: 12, alignItems: "flex-start",
}}
>
<span style={{
width: 6, height: 6, borderRadius: "50%",
background: "var(--accent)", boxShadow: "0 0 10px var(--accent-glow)",
marginTop: 7, flexShrink: 0,
}} />
<span>
<b style={{ color: "var(--fg)", fontWeight: 500 }}>{tools.length} tool{tools.length === 1 ? "" : "s"}</b>
{spend ? <> · ~<b style={{ color: "var(--fg)", fontWeight: 500 }}>${spend}/mo</b></> : null}.
Replaced by one workspace, owned by you.
</span>
</div>
)}
</>
);
}
const OWNER_FIRST_THINGS = [
{ id: "booking", icon: "📅", label: "Bookings & scheduling", desc: "Customers book themselves." },
{ id: "invoicing", icon: "🧾", label: "Quotes, invoices, payments", desc: "Send a quote, get paid, no chasing." },
{ id: "customers", icon: "👥", label: "Customer history & portal", desc: "One place per customer." },
{ id: "inventory", icon: "📦", label: "Inventory & orders", desc: "Track stock, sales, suppliers." },
{ id: "team", icon: "🪪", label: "Team & job dispatch", desc: "Assign jobs, log hours." },
{ id: "marketing", icon: "📣", label: "Website + email + reviews", desc: "A site that converts, list that follows up." },
];
function OwnerFirstThing({ value, onChange }) {
return (
<>
<WizardQ
title="What's burning first?"
sub="The one workflow you wish was already replaced. Vibn builds it on day one."
/>
<PresetGroup
options={OWNER_FIRST_THINGS.map((f) => ({
id: f.id, label: f.label, desc: f.desc,
icon: <span style={{ fontSize: 14 }}>{f.icon}</span>,
}))}
value={value}
onChange={onChange}
columns={2}
/>
</>
);
}
const OWNER_HOW_LONG = [
{ id: "starting", label: "Just starting" },
{ id: "1_3", label: "13 years" },
{ id: "3_10", label: "310 years" },
{ id: "10_plus", label: "10+ years" },
];
function OwnerScale({ customers, team, howLong, onCustomers, onTeam, onHowLong }) {
return (
<>
<WizardQ
title="A little about scale."
sub="Sensible defaults — table view vs. cards, daily vs. monthly reports."
/>
<Field label="Customers per month">
<Slider
min={0} max={2000} step={10}
value={customers ?? 50}
onChange={onCustomers}
format={(v) => v === 0 ? "0" : v >= 2000 ? "2k+" : v.toLocaleString()}
/>
</Field>
<Field label="Team size (incl. you)">
<Slider
min={1} max={50} step={1}
value={team ?? 1}
onChange={onTeam}
format={(v) => v >= 50 ? "50+" : `${v}`}
/>
</Field>
<Field label="How long have you been at this?">
<div className="chips">
{OWNER_HOW_LONG.map((h) => (
<button
key={h.id}
type="button"
className={"chip" + (howLong === h.id ? " active" : "")}
onClick={() => onHowLong(h.id)}
>
{h.label}
</button>
))}
</div>
</Field>
</>
);
}
// ── Path wrapper ───────────────────────────────────────────────────────────
export function OwnerPath({ data, onUpdate, onBack, onClose, onComplete, onJumpToStep, step }) {
const next = () => {
if (step < OWNER_TOTAL - 1) onJumpToStep(step + 1);
else onComplete();
};
const back = () => {
if (step === 0) onBack();
else onJumpToStep(step - 1);
};
let body, canNext;
if (step === 0) {
body = (
<OwnerBiz
value={data.biz}
name={data.bizName || ""}
onChange={(v) => onUpdate({ biz: v })}
onNameChange={(v) => onUpdate({ bizName: v })}
/>
);
canNext = !!data.biz && (data.bizName || "").trim().length >= 2;
} else if (step === 1) {
body = (
<OwnerStack
tools={data.tools || []}
spend={data.spend}
onToolsChange={(v) => onUpdate({ tools: v })}
onSpendChange={(v) => onUpdate({ spend: v })}
/>
);
canNext = (data.tools || []).length >= 1;
} else if (step === 2) {
body = <OwnerFirstThing value={data.firstThing} onChange={(v) => onUpdate({ firstThing: v })} />;
canNext = !!data.firstThing;
} else {
body = (
<OwnerScale
customers={data.customers}
team={data.team}
howLong={data.howLong}
onCustomers={(v) => onUpdate({ customers: v })}
onTeam={(v) => onUpdate({ team: v })}
onHowLong={(v) => onUpdate({ howLong: v })}
/>
);
canNext = !!data.howLong;
}
return (
<>
<WizardTop
onBack={back}
onClose={onClose}
lane={LANE_LABELS.owner}
stepText={OWNER_STEP_NAMES[step]}
current={step + 2}
total={5}
/>
<WizardBody width={step === 0 || step === 2 ? "wide" : null}>
{body}
<WizardFooter
onNext={next}
canNext={canNext}
nextLabel={step === OWNER_TOTAL - 1 ? "Build my workspace →" : "Continue"}
hint={canNext ? "⌘↵" : null}
/>
</WizardBody>
</>
);
}

View File

@@ -0,0 +1,459 @@
import React, {
useState,
useEffect,
useRef,
useMemo,
useCallback,
} from "react";
// Shared building blocks for the onboarding flow.
// All <style> belongs in onboarding.css; this file is JSX only.
// ── Wizard top bar ─────────────────────────────────────────────────────────
// Sticky, thin. Holds: back arrow · vibn mark · centered step label · close.
// A 2px progress bar runs along its bottom edge.
export function WizardTop({
onBack,
onClose,
lane, // "Solo / quiet entrepreneur" etc.
stepText, // "Idea" or "Pick your lane"
current,
total, // 1-indexed
progress, // 0..1 (optional override)
}) {
const pct =
typeof progress === "number"
? Math.max(0, Math.min(1, progress))
: typeof current === "number" && typeof total === "number"
? Math.max(0, Math.min(1, current / total))
: 0;
return (
<header className="wiz-top">
<div className="wiz-top-row">
<button
type="button"
className="wiz-iconbtn"
onClick={onBack}
disabled={!onBack}
aria-label="Back"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M13 8H3M7 4 3 8l4 4" />
</svg>
</button>
<a href="index.html" className="wiz-logo" aria-label="vibn — home">
<div
style={{
width: 22,
height: 22,
borderRadius: 6,
overflow: "hidden",
display: "inline-block",
}}
>
<img
src="/vibn-black-circle-logo.png"
alt="VIBN"
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
</div>
<span>vibn</span>
</a>
<div className="wiz-step">
{lane && <span className="lane">{lane}</span>}
{lane && stepText && <span className="dot" />}
{stepText && (
<span>
{typeof current === "number" && typeof total === "number" && (
<>
<b>{current}</b>{" "}
<span style={{ opacity: 0.6 }}>/ {total}</span>
{" · "}
</>
)}
{stepText}
</span>
)}
</div>
<button
type="button"
className="wiz-iconbtn"
onClick={onClose}
aria-label="Save & exit"
title="Save & exit"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="m4 4 8 8M12 4l-8 8" />
</svg>
</button>
</div>
<div className="wiz-progress">
<div className="wiz-progress-fill" style={{ width: `${pct * 100}%` }} />
</div>
</header>
);
}
// ── Wizard body wrapper ────────────────────────────────────────────────────
export function WizardBody({ children, width }) {
const cls =
"wiz-card" +
(width === "wide" ? " wide" : width === "xwide" ? " xwide" : "");
return (
<main className="wiz-body">
<div className={cls}>{children}</div>
</main>
);
}
// ── Question heading ───────────────────────────────────────────────────────
export function WizardQ({ title, sub }) {
return (
<div className="wiz-q">
<h2>{title}</h2>
{sub && <p>{sub}</p>}
</div>
);
}
// ── Footer (back / hint / continue) ────────────────────────────────────────
export function WizardFooter({
onBack,
onNext,
canNext = true,
nextLabel = "Continue",
hint,
onSkip,
skipLabel = "Skip",
}) {
return (
<div className="wiz-foot">
<div className="wiz-foot-left">
{onSkip && (
<button type="button" className="wiz-skip" onClick={onSkip}>
{skipLabel}
</button>
)}
</div>
<div className="wiz-foot-right">
{hint && <span className="wiz-hint">{hint}</span>}
<button
type="button"
className="btn btn-primary btn-wiz"
disabled={!canNext}
onClick={() => canNext && onNext && onNext()}
>
{nextLabel} <svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 8h10M9 4l4 4-4 4"/></svg>
</button>
</div>
</div>
);
}
// ── Field wrappers (wizard variants) ───────────────────────────────────────
export function Field({ label, hint, children, optional }) {
return (
<label className="wiz-field">
{label && (
<span className="wiz-field-label">
{label}
{optional && (
<span
style={{
color: "var(--fg-faint)",
fontWeight: 400,
marginLeft: 8,
fontSize: 12,
}}
>
optional
</span>
)}
</span>
)}
{children}
{hint && <span className="wiz-field-hint">{hint}</span>}
</label>
);
}
// ── Chip group (multi-select) ──────────────────────────────────────────────
export function ChipGroup({ options, values, onChange, allowOther = false }) {
const [other, setOther] = React.useState("");
const customs = (values || []).filter((v) => !options.includes(v));
const toggle = (v) => {
if (!onChange) return;
if (values.includes(v)) onChange(values.filter((x) => x !== v));
else onChange([...values, v]);
};
return (
<div>
<div className="chips">
{options.map((opt) => (
<button
type="button"
key={opt}
className={"chip" + (values.includes(opt) ? " active" : "")}
onClick={() => toggle(opt)}
>
{opt}
</button>
))}
{customs.map((c) => (
<button
type="button"
key={c}
className="chip active"
onClick={() => toggle(c)}
title="Click to remove"
>
{c} <span style={{ marginLeft: 4, opacity: 0.6 }}>×</span>
</button>
))}
</div>
{allowOther && (
<form
onSubmit={(e) => {
e.preventDefault();
const v = other.trim();
if (v && !values.includes(v)) onChange([...values, v]);
setOther("");
}}
style={{ marginTop: 10, display: "flex", gap: 8 }}
>
<input
type="text"
className="wiz-input"
placeholder="Add your own…"
value={other}
onChange={(e) => setOther(e.target.value)}
style={{ flex: 1 }}
/>
<button
type="submit"
className="btn btn-ghost"
style={{
height: 42,
padding: "0 14px",
fontSize: 13,
borderRadius: 10,
}}
disabled={!other.trim()}
>
Add
</button>
</form>
)}
</div>
);
}
// ── Preset group (single-select cards) ─────────────────────────────────────
export function PresetGroup({ options, value, onChange, columns = 1 }) {
return (
<div
style={{
display: "grid",
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
gap: 8,
width: "100%",
}}
>
{options.map((opt) => {
const active = value === opt.id;
return (
<button
key={opt.id}
type="button"
onClick={() => onChange(opt.id)}
style={{
textAlign: "left",
padding: "12px 14px",
borderRadius: 10,
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)",
display: "flex",
alignItems: "flex-start",
gap: 12,
}}
>
{opt.icon && (
<span
style={{
width: 28,
height: 28,
flexShrink: 0,
borderRadius: 8,
background: active
? "oklch(0.74 0.175 35 / 0.18)"
: "oklch(0.22 0.011 60)",
border: "1px solid var(--hairline)",
color: active ? "var(--accent)" : "var(--fg-mute)",
display: "grid",
placeItems: "center",
fontSize: 14,
marginTop: 1,
}}
>
{opt.icon}
</span>
)}
<span
style={{
display: "flex",
flexDirection: "column",
gap: 2,
flex: 1,
minWidth: 0,
}}
>
<span
style={{
fontSize: 14,
fontWeight: 500,
letterSpacing: "-0.005em",
}}
>
{opt.label}
</span>
{opt.desc && (
<span
style={{
fontSize: 12.5,
color: "var(--fg-mute)",
lineHeight: 1.45,
}}
>
{opt.desc}
</span>
)}
</span>
{active && (
<span
style={{
width: 16,
height: 16,
borderRadius: "50%",
background: "var(--accent)",
display: "grid",
placeItems: "center",
color: "var(--accent-fg)",
flexShrink: 0,
marginTop: 6,
}}
>
<svg
width="9"
height="9"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="m3 8.5 3.2 3.2L13 5" />
</svg>
</span>
)}
</button>
);
})}
</div>
);
}
// ── Slider ─────────────────────────────────────────────────────────────────
export function Slider({ min, max, step = 1, value, onChange, format }) {
return (
<div style={{ width: "100%" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
}}
>
<span
className="mono"
style={{
fontSize: 11,
color: "var(--fg-faint)",
letterSpacing: "0.04em",
}}
>
{format ? format(min) : min}
</span>
<span
className="mono"
style={{
fontSize: 18,
color: "var(--fg)",
letterSpacing: "-0.01em",
fontWeight: 500,
}}
>
{format ? format(value) : value}
</span>
<span
className="mono"
style={{
fontSize: 11,
color: "var(--fg-faint)",
letterSpacing: "0.04em",
}}
>
{format ? format(max) : max}
</span>
</div>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
style={{ width: "100%", marginTop: 6, accentColor: "var(--accent)" }}
/>
</div>
);
}
// Lane labels — used by WizardTop and elsewhere.
export const LANE_LABELS = {
entrepreneur: "Solo entrepreneur",
owner: "Small business owner",
consultant: "Building for clients",
};

View File

@@ -0,0 +1,677 @@
/* Onboarding shared styles — same tokens as the rest of the site. */
:root {
--bg: oklch(0.155 0.008 60);
--bg-1: oklch(0.185 0.009 60);
--bg-2: oklch(0.225 0.010 60);
--hairline: oklch(0.32 0.010 60 / 0.55);
--hairline-2: oklch(0.40 0.012 60 / 0.35);
--fg: oklch(0.97 0.005 80);
--fg-dim: oklch(0.78 0.006 80);
--fg-mute: oklch(0.58 0.006 80);
--fg-faint: oklch(0.42 0.006 80);
--accent: oklch(0.74 0.175 35);
--accent-soft: oklch(0.74 0.175 35 / 0.18);
--accent-glow: oklch(0.74 0.175 35 / 0.35);
--accent-fg: #1a0f0a;
--ok: oklch(0.78 0.16 155);
--font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; min-height: 100%; }
body {
background: var(--bg);
color: var(--fg);
font-family: var(--font-sans);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow-x: hidden;
}
body::before {
content: "";
position: fixed; inset: 0;
background-image:
linear-gradient(to right, oklch(0.30 0.01 60 / 0.10) 1px, transparent 1px),
linear-gradient(to bottom, oklch(0.30 0.01 60 / 0.10) 1px, transparent 1px);
background-size: 56px 56px;
mask-image: radial-gradient(ellipse 80% 80% at 50% 40%, #000 30%, transparent 80%);
-webkit-mask-image: radial-gradient(ellipse 80% 80% at 50% 40%, #000 30%, transparent 80%);
pointer-events: none;
z-index: 0;
}
body::after {
content: "";
position: fixed; inset: 0;
pointer-events: none;
z-index: 1;
opacity: 0.035;
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%25' height='100%25' filter='url(%23n)' opacity='0.85'/></svg>");
}
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
h1, h2, h3 { margin: 0; font-weight: 500; letter-spacing: -0.02em; line-height: 1.05; }
p { margin: 0; }
::selection { background: var(--accent); color: var(--accent-fg); }
.mono { font-family: var(--font-mono); }
/* App shell */
.app {
position: relative;
z-index: 2;
min-height: 100dvh;
display: flex; flex-direction: column;
}
.app-bar {
position: relative; z-index: 5;
padding: 20px clamp(20px, 4vw, 48px);
display: flex; align-items: center; justify-content: space-between;
border-bottom: 1px solid transparent;
}
.app-bar-left { display: flex; align-items: center; gap: 24px; }
.app-step {
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-faint);
letter-spacing: 0.12em;
text-transform: uppercase;
display: inline-flex; align-items: center; gap: 8px;
}
.app-step::before {
content: "";
width: 5px; height: 5px; border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 12px var(--accent-glow);
}
.app-bar-right {
display: flex; gap: 18px; align-items: center;
}
.app-bar-right a, .app-bar-right button {
font-size: 13px; color: var(--fg-mute);
}
.app-bar-right a:hover, .app-bar-right button:hover { color: var(--fg); }
/* Logo */
.logo {
display: inline-flex; align-items: center; gap: 9px;
font-weight: 600; font-size: 17px; letter-spacing: -0.02em;
color: var(--fg);
}
.logo-mark {
width: 26px; height: 26px; border-radius: 50%;
background: linear-gradient(135deg, var(--accent) 0%, oklch(0.65 0.20 18) 100%);
box-shadow: 0 0 22px var(--accent-glow), inset 0 1px 0 oklch(1 0 0 / 0.25);
display: grid; place-items: center;
color: var(--accent-fg);
flex-shrink: 0;
}
.logo-mark svg { display: block; }
.logo-caret { animation: caret-blink 1.4s steps(2) infinite; }
@keyframes caret-blink { 50% { opacity: 0.25; } }
/* Main */
.screen {
flex: 1;
position: relative;
padding: clamp(40px, 7vh, 80px) clamp(20px, 4vw, 48px) clamp(40px, 6vh, 60px);
display: flex; flex-direction: column;
align-items: center;
text-align: center;
}
.screen-wide {
align-items: stretch;
text-align: left;
}
.screen-content {
position: relative; z-index: 2;
width: 100%;
max-width: 720px;
display: flex; flex-direction: column;
align-items: center; text-align: center;
}
.screen-content-wide {
max-width: 1100px;
align-items: stretch; text-align: left;
}
/* Ambient glows */
.glow {
position: absolute;
pointer-events: none;
filter: blur(20px);
z-index: 0;
}
/* Typography */
.eyebrow {
display: inline-flex; align-items: center; gap: 10px;
font-family: var(--font-mono);
font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase;
color: var(--fg-mute);
}
.eyebrow::before {
content: ""; width: 5px; height: 5px; border-radius: 50%;
background: var(--accent); box-shadow: 0 0 12px var(--accent-glow);
}
.eyebrow-accent { color: var(--accent); }
.h1 {
margin-top: 20px;
font-size: clamp(36px, 5.4vw, 64px);
font-weight: 500; letter-spacing: -0.03em; line-height: 1.04;
text-wrap: balance;
}
.h1 em {
font-style: normal;
color: var(--accent);
text-shadow: 0 0 30px var(--accent-glow);
}
.sub {
margin-top: 18px;
font-size: clamp(15px, 1.55vw, 18px);
color: var(--fg-mute);
line-height: 1.55;
text-wrap: balance;
max-width: 540px;
}
.sub b { color: var(--fg); font-weight: 500; }
.tagline {
display: inline-flex; align-items: center; gap: 14px;
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.06em;
color: var(--fg-faint);
margin-bottom: 8px;
}
.tagline::before, .tagline::after {
content: ""; width: 28px; height: 1px;
background: linear-gradient(90deg, transparent, var(--hairline), transparent);
}
/* Buttons */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 10px;
height: 50px; padding: 0 22px;
border-radius: 999px;
font-weight: 500;
font-size: 15px;
transition: transform .12s, box-shadow .2s, background .2s, border-color .15s;
white-space: nowrap;
}
.btn-primary {
background: var(--accent);
color: var(--accent-fg);
box-shadow:
0 0 0 1px oklch(0.84 0.16 35 / 0.5) inset,
0 10px 40px -10px var(--accent-glow),
0 0 40px -8px var(--accent-glow);
}
.btn-primary:hover { transform: translateY(-1px); }
.btn-primary[disabled] { opacity: .55; cursor: not-allowed; transform: none; }
.btn-primary .arrow { transition: transform .15s; }
.btn-primary:hover .arrow { transform: translateX(3px); }
.btn-ghost {
background: oklch(0.20 0.009 60 / 0.6);
border: 1px solid var(--hairline);
color: var(--fg-dim);
}
.btn-ghost:hover { color: var(--fg); border-color: var(--hairline-2); background: oklch(0.22 0.010 60 / 0.8); }
.link-quiet {
font-size: 13px;
color: var(--fg-mute);
display: inline-flex; align-items: center; gap: 6px;
border-bottom: 1px dashed var(--hairline);
padding-bottom: 2px;
}
.link-quiet:hover { color: var(--fg); border-color: var(--accent); }
/* Or divider */
.or-divider {
display: flex; align-items: center; gap: 14px;
margin: 28px 0 18px;
width: 100%; max-width: 360px;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--fg-faint);
}
.or-divider::before, .or-divider::after {
content: ""; flex: 1; height: 1px; background: var(--hairline);
}
/* Form */
.field {
width: 100%;
display: flex; flex-direction: column; gap: 8px;
margin-top: 24px;
text-align: left;
}
.field-label {
font-size: 15px;
font-weight: 500;
color: var(--fg);
letter-spacing: -0.005em;
}
.field-hint {
font-size: 13px;
color: var(--fg-mute);
line-height: 1.5;
}
.input {
width: 100%;
padding: 14px 16px;
background: oklch(0.16 0.008 60 / 0.8);
border: 1px solid var(--hairline);
border-radius: 12px;
color: var(--fg);
font: 15px/1.5 var(--font-sans);
outline: none;
transition: border-color .15s, background .15s, box-shadow .15s;
resize: vertical;
}
.input::placeholder { color: var(--fg-faint); }
.input:focus {
border-color: oklch(0.74 0.175 35 / 0.65);
background: oklch(0.18 0.009 60 / 0.95);
box-shadow: 0 0 0 3px oklch(0.74 0.175 35 / 0.12), 0 0 30px -10px var(--accent-glow);
}
.input-textarea { min-height: 110px; resize: vertical; }
.input-large { padding: 20px 22px; font-size: 17px; border-radius: 16px; }
/* Hero prompt input */
.prompt {
width: 100%;
position: relative;
margin-top: 24px;
}
.prompt-frame {
position: relative;
border-radius: 22px;
padding: 1px;
background: linear-gradient(180deg,
oklch(0.50 0.06 35 / 0.6),
oklch(0.30 0.012 60 / 0.4) 40%,
oklch(0.25 0.012 60 / 0.4));
box-shadow:
0 30px 80px -20px oklch(0 0 0 / 0.6),
0 0 80px -20px var(--accent-glow);
}
.prompt-inner {
background: linear-gradient(180deg, oklch(0.19 0.009 60 / 0.92), oklch(0.17 0.008 60 / 0.92));
border-radius: 21px;
padding: 18px 20px 14px;
backdrop-filter: blur(20px);
display: flex; flex-direction: column;
gap: 12px;
}
.prompt-inner textarea {
width: 100%;
min-height: 92px;
background: transparent;
border: 0;
color: var(--fg);
font: 17px/1.5 var(--font-sans);
resize: none;
outline: none;
padding: 4px;
}
.prompt-typed {
position: absolute;
top: 22px; left: 24px; right: 24px;
pointer-events: none;
color: var(--fg-faint);
font: 17px/1.5 var(--font-sans);
text-align: left;
}
.prompt-typed::after {
content: "";
display: inline-block;
width: 8px; height: 18px;
background: var(--accent);
vertical-align: -3px;
margin-left: 2px;
animation: blink 1s steps(2) infinite;
box-shadow: 0 0 12px var(--accent-glow);
}
@keyframes blink { 50% { opacity: 0; } }
.prompt-bar {
display: flex; align-items: center; justify-content: space-between;
padding-top: 8px;
border-top: 1px solid var(--hairline);
}
.prompt-hint {
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-faint);
letter-spacing: 0.02em;
}
/* Chip / option grid */
.chips {
display: flex; flex-wrap: wrap; gap: 8px;
}
.chip {
padding: 9px 14px;
border-radius: 999px;
border: 1px solid var(--hairline);
background: oklch(0.20 0.009 60 / 0.5);
color: var(--fg-dim);
font-size: 13.5px;
transition: border-color .15s, color .15s, background .15s, transform .12s;
}
.chip:hover { border-color: var(--hairline-2); color: var(--fg); transform: translateY(-1px); }
.chip.active {
border-color: var(--accent);
background: oklch(0.20 0.04 35 / 0.4);
color: var(--fg);
}
/* Preset chips */
.preset-row {
display: flex; gap: 8px; flex-wrap: wrap;
margin-top: 4px;
}
.preset-chip {
padding: 11px 18px;
border-radius: 12px;
border: 1px solid var(--hairline);
background: oklch(0.18 0.009 60 / 0.6);
color: var(--fg-dim);
font: 500 14.5px var(--font-mono);
letter-spacing: -0.005em;
transition: all .15s;
}
.preset-chip:hover { border-color: var(--hairline-2); color: var(--fg); }
.preset-chip.active {
border-color: var(--accent);
background: oklch(0.20 0.04 35 / 0.4);
color: var(--fg);
box-shadow: 0 0 0 3px oklch(0.74 0.175 35 / 0.1);
}
/* Trust strip */
.trust {
margin-top: 36px;
display: flex; gap: 14px; justify-content: center; align-items: center;
flex-wrap: wrap;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.03em;
color: var(--fg-faint);
}
.trust .sep { opacity: 0.5; }
/* CTA row */
.cta-row {
margin-top: 36px;
display: flex; gap: 14px; align-items: center; flex-wrap: wrap;
justify-content: center;
}
/* Spinner */
.spinner {
width: 16px; height: 16px; border-radius: 50%;
border: 2px solid oklch(0 0 0 / 0.2);
border-top-color: var(--accent-fg);
animation: spin .9s linear infinite;
display: inline-block;
}
.spinner-line {
width: 12px; height: 12px;
border-color: var(--hairline);
border-top-color: var(--accent);
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Surface card */
.surface {
background: linear-gradient(180deg, oklch(0.20 0.009 60 / 0.55), oklch(0.17 0.008 60 / 0.55));
border: 1px solid var(--hairline);
border-radius: 18px;
}
/* ── Wizard chrome ───────────────────────────────────────────────────── */
/* The persistent top strip with progress bar + back + step text + close. */
.wiz-top {
position: sticky; top: 0; z-index: 50;
background: oklch(0.155 0.008 60 / 0.85);
backdrop-filter: blur(14px) saturate(140%);
-webkit-backdrop-filter: blur(14px) saturate(140%);
border-bottom: 1px solid var(--hairline);
}
.wiz-top-row {
height: 54px;
padding: 0 clamp(16px, 3vw, 28px);
display: flex; align-items: center; gap: 14px;
}
.wiz-iconbtn {
width: 32px; height: 32px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 8px;
color: var(--fg-mute);
border: 1px solid transparent;
transition: color .15s, border-color .15s, background .15s;
flex-shrink: 0;
}
.wiz-iconbtn:hover {
color: var(--fg);
background: oklch(0.20 0.009 60 / 0.6);
border-color: var(--hairline);
}
.wiz-iconbtn[disabled] { opacity: 0; pointer-events: none; }
.wiz-logo {
display: inline-flex; align-items: center; gap: 8px;
font-weight: 500; font-size: 14px; letter-spacing: -0.01em;
color: var(--fg);
flex-shrink: 0;
}
.wiz-logo .logo-mark { width: 22px; height: 22px; }
.wiz-step {
flex: 1;
display: flex; align-items: center; gap: 10px;
min-width: 0;
justify-content: center;
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--fg-mute);
letter-spacing: 0.04em;
overflow: hidden;
}
.wiz-step b { color: var(--fg); font-weight: 500; }
.wiz-step .dot {
width: 4px; height: 4px; border-radius: 50%;
background: var(--fg-faint);
flex-shrink: 0;
}
.wiz-step .lane {
color: var(--accent);
letter-spacing: 0.08em;
text-transform: uppercase;
font-size: 10.5px;
display: inline-flex; align-items: center; gap: 6px;
}
.wiz-step .lane::before {
content: ""; width: 5px; height: 5px; border-radius: 50%;
background: var(--accent); box-shadow: 0 0 10px var(--accent-glow);
}
.wiz-progress {
position: relative;
height: 2px;
background: oklch(0.30 0.010 60 / 0.35);
}
.wiz-progress-fill {
position: absolute; left: 0; top: 0; bottom: 0;
background: var(--accent);
box-shadow: 0 0 14px var(--accent-glow);
transition: width .35s cubic-bezier(.4,0,.2,1);
}
@media (max-width: 640px) {
.wiz-step .lane { display: none; }
.wiz-step .dot:first-of-type { display: none; }
}
/* ── Wizard body ─────────────────────────────────────────────────────── */
.wiz-body {
flex: 1;
position: relative;
padding: clamp(40px, 7vh, 88px) clamp(20px, 4vw, 32px) clamp(40px, 6vh, 64px);
display: flex; flex-direction: column;
align-items: center;
}
.wiz-card {
width: 100%;
max-width: 520px;
display: flex; flex-direction: column;
gap: 28px;
}
.wiz-card.wide { max-width: 760px; }
.wiz-card.xwide { max-width: 1040px; }
/* Question heading — quiet, one line, no em accents */
.wiz-q { display: flex; flex-direction: column; gap: 10px; }
.wiz-q h2 {
font-size: clamp(22px, 2.4vw, 28px);
font-weight: 500;
letter-spacing: -0.018em;
line-height: 1.22;
color: var(--fg);
text-wrap: balance;
}
.wiz-q p {
font-size: 14.5px;
color: var(--fg-mute);
line-height: 1.55;
max-width: 460px;
}
/* Footer with back/continue */
.wiz-foot {
display: flex; align-items: center; justify-content: space-between;
gap: 14px;
margin-top: 8px;
}
.wiz-foot-left {
display: flex; align-items: center; gap: 10px;
font-size: 13px;
color: var(--fg-mute);
}
.wiz-foot-right {
display: flex; align-items: center; gap: 12px;
}
.wiz-hint {
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-faint);
letter-spacing: 0.06em;
}
.wiz-skip {
font-size: 13.5px;
color: var(--fg-mute);
padding: 8px 12px;
border-radius: 8px;
}
.wiz-skip:hover { color: var(--fg); background: oklch(0.20 0.009 60 / 0.5); }
.btn-wiz {
height: 42px;
padding: 0 18px;
font-size: 14px;
border-radius: 10px;
}
/* Fields tightened up for wizard context */
.wiz-field {
display: flex; flex-direction: column; gap: 8px;
}
.wiz-field-label {
font-size: 13.5px;
font-weight: 500;
color: var(--fg-dim);
letter-spacing: -0.005em;
}
.wiz-field-hint {
font-size: 12.5px;
color: var(--fg-mute);
line-height: 1.5;
}
.wiz-input {
width: 100%;
padding: 12px 14px;
background: oklch(0.16 0.008 60 / 0.8);
border: 1px solid var(--hairline);
border-radius: 10px;
color: var(--fg);
font: 14.5px/1.5 var(--font-sans);
outline: none;
transition: border-color .15s, background .15s, box-shadow .15s;
}
.wiz-input::placeholder { color: var(--fg-faint); }
.wiz-input:focus {
border-color: oklch(0.74 0.175 35 / 0.6);
background: oklch(0.18 0.009 60 / 0.95);
box-shadow: 0 0 0 3px oklch(0.74 0.175 35 / 0.12);
}
textarea.wiz-input { min-height: 96px; resize: vertical; }
/* Debug navigator panel */
.debug {
position: fixed; bottom: 16px; right: 16px;
z-index: 1000;
font-family: var(--font-mono);
font-size: 11px;
display: flex; flex-direction: column; gap: 6px;
align-items: flex-end;
}
.debug-toggle {
padding: 8px 12px;
border-radius: 999px;
background: oklch(0.18 0.009 60 / 0.85);
border: 1px solid var(--hairline);
color: var(--fg-mute);
letter-spacing: 0.06em;
text-transform: uppercase;
backdrop-filter: blur(12px);
}
.debug-toggle:hover { color: var(--fg); border-color: var(--hairline-2); }
.debug-panel {
width: 240px;
padding: 12px;
background: oklch(0.16 0.008 60 / 0.95);
border: 1px solid var(--hairline);
border-radius: 12px;
backdrop-filter: blur(20px);
display: flex; flex-direction: column; gap: 4px;
max-height: 60vh; overflow-y: auto;
}
.debug-row {
display: flex; align-items: center; gap: 8px;
padding: 6px 8px;
border-radius: 6px;
color: var(--fg-mute);
cursor: pointer;
letter-spacing: 0.04em;
text-transform: uppercase;
font-size: 10px;
}
.debug-row:hover { background: oklch(0.20 0.009 60); color: var(--fg-dim); }
.debug-row.active {
background: oklch(0.74 0.175 35 / 0.18);
color: var(--accent);
}
.debug-row b { color: inherit; font-weight: 600; }

View File

@@ -0,0 +1,193 @@
"use client";
import React, { useState, useEffect, useMemo, Fragment } from "react";
import "./onboarding.css";
import { ForkScreen } from "./onboarding-fork";
import { EntrepreneurPath } from "./onboarding-entrepreneur";
import { OwnerPath } from "./onboarding-owner";
import { ConsultantPath } from "./onboarding-consultant";
import { BuildScreen } from "./onboarding-build";
import { ReadyScreen } from "./onboarding-build"; // Assuming ReadyScreen is exported from build
// Root onboarding app — owns the route state and the answers dict.
// Routes: fork → <path> → build → ready. A floating debug navigator (toggle
// in the lower-right) lets reviewers jump between any screen without
// filling out the form.
export default function OnboardingApp() {
const initialName = React.useMemo(() => {
try { return typeof window !== "undefined" ? localStorage.getItem("vibn:firstName") || "" : ""; } catch { return ""; }
}, []);
const [stage, setStage] = React.useState("fork"); // fork | path | build | ready
const [path, setPath] = React.useState(null); // entrepreneur | owner | consultant
const [forkChoice, setForkChoice] = React.useState(null);
const [step, setStep] = React.useState(0);
const [data, setData] = React.useState({});
const [debugOpen, setDebugOpen] = React.useState(false);
const update = (patch) => setData((d) => ({ ...d, ...patch }));
// ── transitions ──────────────────────────────────────────────────────
const confirmFork = () => {
if (!forkChoice) return;
setPath(forkChoice);
setStep(0);
setStage("path");
};
const backToFork = () => { setStage("fork"); setStep(0); };
const completePath = () => setStage("build");
const openWorkspace = () => setStage("ready");
const close = () => { if (typeof window !== "undefined") window.location.href = "/"; };
const openChat = () => { if (typeof window !== "undefined") window.location.href = "/"; };
// ⌘↵ advances on whatever the current primary action is
React.useEffect(() => {
const handler = (e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
const btn = document.querySelector(".btn-primary:not([disabled])") as HTMLElement;
if (btn) btn.click();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
// ── render ───────────────────────────────────────────────────────────
let body;
if (stage === "fork") {
body = (
<ForkScreen
name={initialName}
value={forkChoice}
onChange={setForkChoice}
onClose={close}
onNext={confirmFork}
/>
);
} else if (stage === "path") {
const props = {
data, onUpdate: update,
onBack: backToFork,
onClose: close,
onComplete: completePath,
onJumpToStep: setStep,
step,
};
if (path === "entrepreneur") body = <EntrepreneurPath {...props} />;
else if (path === "owner") body = <OwnerPath {...props} />;
else body = <ConsultantPath {...props} />;
} else if (stage === "build") {
body = (
<BuildScreen
path={path} data={data}
onBack={() => setStage("path")}
onClose={close}
onOpen={openWorkspace}
/>
);
} else {
body = <ReadyScreen path={path} data={data} onClose={close} onOpenChat={openChat} />;
}
return (
<div className="app">
{body}
<DebugNav
open={debugOpen} setOpen={setDebugOpen}
stage={stage} path={path} step={step}
onJump={(s, p, idx) => {
if (s === "fork") setStage("fork");
else if (s === "build") { setPath(p); setStage("build"); }
else if (s === "ready") { setPath(p); setStage("ready"); }
else { setPath(p); setStep(idx); setStage("path"); }
}}
/>
</div>
);
}
// ── Debug navigator ──────────────────────────────────────────────────────
function DebugNav({ open, setOpen, stage, path, step, onJump }) {
const groups = [
{ title: "Start", rows: [
{ label: "01 · Fork", active: stage === "fork", go: () => onJump("fork") },
]},
{ title: "Entrepreneur", rows: [
{ label: "02 · Idea", active: stage === "path" && path === "entrepreneur" && step === 0, go: () => onJump("path", "entrepreneur", 0) },
{ label: "03 · Audience", active: stage === "path" && path === "entrepreneur" && step === 1, go: () => onJump("path", "entrepreneur", 1) },
{ label: "04 · Goal", active: stage === "path" && path === "entrepreneur" && step === 2, go: () => onJump("path", "entrepreneur", 2) },
{ label: "05 · Vibe", active: stage === "path" && path === "entrepreneur" && step === 3, go: () => onJump("path", "entrepreneur", 3) },
]},
{ title: "Owner", rows: [
{ label: "02 · Business", active: stage === "path" && path === "owner" && step === 0, go: () => onJump("path", "owner", 0) },
{ label: "03 · Stack", active: stage === "path" && path === "owner" && step === 1, go: () => onJump("path", "owner", 1) },
{ label: "04 · First fix", active: stage === "path" && path === "owner" && step === 2, go: () => onJump("path", "owner", 2) },
{ label: "05 · Scale", active: stage === "path" && path === "owner" && step === 3, go: () => onJump("path", "owner", 3) },
]},
{ title: "Consultant", rows: [
{ label: "02 · Client", active: stage === "path" && path === "consultant" && step === 0, go: () => onJump("path", "consultant", 0) },
{ label: "03 · Brief", active: stage === "path" && path === "consultant" && step === 1, go: () => onJump("path", "consultant", 1) },
{ label: "04 · Scope", active: stage === "path" && path === "consultant" && step === 2, go: () => onJump("path", "consultant", 2) },
{ label: "05 · Handoff", active: stage === "path" && path === "consultant" && step === 3, go: () => onJump("path", "consultant", 3) },
]},
{ title: "Finish", rows: [
{ label: "Build · entrepreneur", active: stage === "build" && path === "entrepreneur", go: () => onJump("build", "entrepreneur") },
{ label: "Build · owner", active: stage === "build" && path === "owner", go: () => onJump("build", "owner") },
{ label: "Build · consultant", active: stage === "build" && path === "consultant", go: () => onJump("build", "consultant") },
{ label: "Ready", active: stage === "ready", go: () => onJump("ready", path || "entrepreneur") },
]},
];
return (
<div className="debug">
{open && (
<div className="debug-panel">
{groups.map((g) => (
<React.Fragment key={g.title}>
<div style={{
fontFamily: "var(--font-mono)",
fontSize: 9.5,
color: "var(--fg-faint)",
letterSpacing: "0.14em",
textTransform: "uppercase",
padding: "8px 8px 4px",
}}>{g.title}</div>
{g.rows.map((r) => (
<button
key={r.label}
type="button"
className={"debug-row" + (r.active ? " active" : "")}
onClick={r.go}
>
{r.active && <b> </b>}{r.label}
</button>
))}
</React.Fragment>
))}
<button
type="button"
className="debug-row"
onClick={() => setOpen(false)}
style={{ marginTop: 8, justifyContent: "center", color: "var(--fg-mute)" }}
>
Close
</button>
</div>
)}
<button
type="button"
className="debug-toggle"
onClick={() => setOpen((o) => !o)}
title="Designer navigator"
>
<span style={{ color: "var(--accent)", marginRight: 6 }}></span>
{stage === "path" ? `${path} · step ${step + 1}` : stage}
</button>
</div>
);
}

View File

@@ -5,7 +5,6 @@
import { ReactNode } from "react";
import { Toaster } from "sonner";
import { ProjectAssociationPrompt } from "@/components/project-association-prompt";
import { ChatPanel } from "@/components/vibn-chat/chat-panel";
export default async function ProjectShell({
@@ -22,7 +21,6 @@ export default async function ProjectShell({
<div style={pageWrap}>
<ChatPanel structural artifactSlot={children} />
</div>
<ProjectAssociationPrompt workspace={workspace} />
<Toaster position="top-center" />
</>
);

View File

@@ -1,7 +1,6 @@
"use client";
import { VIBNSidebar } from "@/components/layout/vibn-sidebar";
import { ProjectAssociationPrompt } from "@/components/project-association-prompt";
import { ReactNode } from "react";
import { useParams } from "next/navigation";
import { Toaster } from "sonner";
@@ -22,7 +21,6 @@ export default function ProjectsLayout({
{children}
</main>
</div>
<ProjectAssociationPrompt workspace={workspace} />
<Toaster position="top-center" />
</>
);

View File

@@ -1,145 +0,0 @@
/**
* Backfill endpoint: per-Vibn-project Coolify isolation.
*
* For each Vibn project in the caller's workspace, this:
* 1. Mints a dedicated `vibn-{ws}-{slug}` Coolify project (idempotent).
* 2. Records the project's existing linked Coolify resource (coolifyAppUuid
* / coolifyServiceUuid) in fs_project_resources.
*
* After backfill, `apps_list { projectId }` will only surface the user's
* actually-owned resources for that project, even when multiple projects
* legacy-share a single Coolify project.
*
* Safe to re-run; everything is idempotent.
*/
import { NextResponse } from 'next/server';
import { query } from '@/lib/db-postgres';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import { getOrCreateProvisionedWorkspace, type VibnWorkspace } from '@/lib/workspaces';
import {
ensureProjectCoolifyProject,
ensureProjectResourcesTable,
linkResourceToProject,
type ResourceType,
} from '@/lib/projects';
interface ProjectRow {
id: string;
slug: string;
data: any;
}
interface BackfillReport {
projectId: string;
projectName: string;
beforeCoolifyProjectUuid: string | null;
afterCoolifyProjectUuid: string | null;
linkedResources: Array<{ uuid: string; type: ResourceType }>;
warnings: string[];
}
export async function POST(request: Request) {
// Three accepted auth modes:
// 1. NextAuth session (browser)
// 2. Bearer vibn_sk_... workspace API key (matches /api/mcp)
// 3. Bearer <NEXTAUTH_SECRET> + ?email=<owner> (ops bootstrap so the
// maintainer can curl the backfill from a workstation without
// needing a session cookie or pre-minted API key)
let ws: VibnWorkspace | null = null;
const authHeader = request.headers.get('authorization') ?? '';
const bearer = authHeader.toLowerCase().startsWith('bearer ')
? authHeader.slice(7).trim()
: '';
const opsSecret = process.env.NEXTAUTH_SECRET;
const url = new URL(request.url);
const opsEmail = url.searchParams.get('email');
if (bearer && opsSecret && bearer === opsSecret && opsEmail) {
const users = await query<{ id: string }>(
`SELECT id FROM fs_users WHERE data->>'email' = $1 LIMIT 1`,
[opsEmail],
);
if (users.length === 0) {
return NextResponse.json({ error: `No fs_users row for ${opsEmail}` }, { status: 404 });
}
ws = await getOrCreateProvisionedWorkspace({
userId: users[0].id,
email: opsEmail,
displayName: opsEmail,
});
} else {
const principal = await requireWorkspacePrincipal(request);
if (principal instanceof NextResponse) return principal;
ws = principal.workspace;
}
if (!ws) {
return NextResponse.json({ error: 'Workspace not provisioned' }, { status: 503 });
}
await ensureProjectResourcesTable();
const projects = await query<ProjectRow>(
`SELECT id, slug, data
FROM fs_projects
WHERE vibn_workspace_id = $1 OR workspace = $2
ORDER BY created_at ASC`,
[ws.id, ws.slug],
);
const reports: BackfillReport[] = [];
for (const p of projects) {
const data = p.data || {};
const projectName = data.productName || data.name || data.title || p.slug;
const before = (data.coolifyProjectUuid as string) || null;
const warnings: string[] = [];
let after = before;
try {
// ensureProjectCoolifyProject is a no-op when already set.
const ensured = await ensureProjectCoolifyProject(p.id, ws, {
projectSlug: p.slug,
projectName,
});
if (ensured) after = ensured;
} catch (err: any) {
warnings.push(`coolify provision failed: ${err?.message ?? err}`);
}
// Record any pre-existing single-resource link.
const linked: Array<{ uuid: string; type: ResourceType }> = [];
const candidate: Array<[string | undefined, ResourceType]> = [
[data.coolifyServiceUuid, 'service'],
[data.coolifyAppUuid, 'application'],
[data.coolifyDatabaseUuid, 'database'],
];
for (const [uuid, type] of candidate) {
if (typeof uuid === 'string' && uuid.length > 0) {
try {
await linkResourceToProject(p.id, ws.slug, uuid, type);
linked.push({ uuid, type });
} catch (err: any) {
warnings.push(`link ${type}=${uuid} failed: ${err?.message ?? err}`);
}
}
}
reports.push({
projectId: p.id,
projectName,
beforeCoolifyProjectUuid: before,
afterCoolifyProjectUuid: after,
linkedResources: linked,
warnings,
});
}
return NextResponse.json({
workspace: ws.slug,
processed: reports.length,
reports,
});
}

View File

@@ -1,311 +0,0 @@
/**
* POST /api/admin/migrate
*
* One-shot migration endpoint. Requires the ADMIN_MIGRATE_SECRET env var
* to be set and passed as x-admin-secret header (or ?secret= query param).
*
* Idempotent — safe to call multiple times (all statements use IF NOT EXISTS).
*
* curl -X POST https://vibnai.com/api/admin/migrate \
* -H "x-admin-secret: <ADMIN_MIGRATE_SECRET>"
*/
import { NextRequest, NextResponse } from "next/server";
import { query } from "@/lib/db-postgres";
import { readFileSync } from "fs";
import { join } from "path";
import { timingSafeStringEq } from "@/lib/server/timing-safe";
export async function POST(req: NextRequest) {
const secret = process.env.ADMIN_MIGRATE_SECRET ?? "";
if (!secret) {
return NextResponse.json(
{
error:
"ADMIN_MIGRATE_SECRET env var not set — migration endpoint disabled",
},
{ status: 403 },
);
}
const incoming =
req.headers.get("x-admin-secret") ??
new URL(req.url).searchParams.get("secret") ??
"";
if (!incoming || !timingSafeStringEq(secret, incoming)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const results: Array<{ statement: string; ok: boolean; error?: string }> = [];
// Inline the DDL so this works even if the SQL file isn't on the runtime fs
const statements = [
`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`,
`CREATE TABLE IF NOT EXISTS fs_users (
id TEXT PRIMARY KEY,
user_id TEXT,
data JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`,
`CREATE INDEX IF NOT EXISTS fs_users_email_idx ON fs_users ((data->>'email'))`,
`CREATE INDEX IF NOT EXISTS fs_users_user_id_idx ON fs_users (user_id)`,
`CREATE TABLE IF NOT EXISTS fs_projects (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
workspace TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
data JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`,
`CREATE INDEX IF NOT EXISTS fs_projects_user_idx ON fs_projects (user_id)`,
`CREATE INDEX IF NOT EXISTS fs_projects_workspace_idx ON fs_projects (workspace)`,
`CREATE TABLE IF NOT EXISTS fs_sessions (
id TEXT PRIMARY KEY,
user_id TEXT,
data JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`,
`CREATE INDEX IF NOT EXISTS fs_sessions_user_idx ON fs_sessions (user_id)`,
`CREATE INDEX IF NOT EXISTS fs_sessions_project_idx ON fs_sessions ((data->>'projectId'))`,
// agent_sessions uses TEXT for project_id to match fs_projects.id
`CREATE TABLE IF NOT EXISTS agent_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id TEXT NOT NULL,
app_name TEXT NOT NULL,
app_path TEXT NOT NULL,
task TEXT NOT NULL,
plan JSONB,
status TEXT NOT NULL DEFAULT 'pending',
output JSONB NOT NULL DEFAULT '[]',
changed_files JSONB NOT NULL DEFAULT '[]',
error TEXT,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`,
`CREATE INDEX IF NOT EXISTS agent_sessions_project_idx ON agent_sessions (project_id, created_at DESC)`,
`CREATE INDEX IF NOT EXISTS agent_sessions_status_idx ON agent_sessions (status)`,
`CREATE TABLE IF NOT EXISTS agent_session_events (
id BIGSERIAL PRIMARY KEY,
session_id UUID NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
project_id TEXT NOT NULL,
seq INT NOT NULL,
ts TIMESTAMPTZ NOT NULL,
type TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
client_event_id UUID UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(session_id, seq)
)`,
`CREATE INDEX IF NOT EXISTS agent_session_events_session_seq_idx ON agent_session_events (session_id, seq)`,
// NextAuth / Prisma tables
`CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
name TEXT,
email TEXT UNIQUE,
email_verified TIMESTAMPTZ,
image TEXT
)`,
`CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL,
provider TEXT NOT NULL,
provider_account_id TEXT NOT NULL,
refresh_token TEXT,
access_token TEXT,
expires_at INTEGER,
token_type TEXT,
scope TEXT,
id_token TEXT,
session_state TEXT,
UNIQUE (provider, provider_account_id)
)`,
`CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
session_token TEXT UNIQUE NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires TIMESTAMPTZ NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS verification_tokens (
identifier TEXT NOT NULL,
token TEXT UNIQUE NOT NULL,
expires TIMESTAMPTZ NOT NULL,
UNIQUE (identifier, token)
)`,
// ── Vibn workspaces (logical tenancy on top of Coolify) ──────────
// One workspace per Vibn account. Holds a Coolify Project UUID
// (the team boundary inside Coolify) and a Gitea org name.
`CREATE TABLE IF NOT EXISTS vibn_workspaces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
owner_user_id TEXT NOT NULL,
coolify_project_uuid TEXT,
coolify_team_id INT,
gitea_org TEXT,
provision_status TEXT NOT NULL DEFAULT 'pending',
provision_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`,
`CREATE INDEX IF NOT EXISTS vibn_workspaces_owner_idx ON vibn_workspaces (owner_user_id)`,
`CREATE TABLE IF NOT EXISTS vibn_workspace_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES vibn_workspaces(id) ON DELETE CASCADE,
user_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (workspace_id, user_id)
)`,
`CREATE INDEX IF NOT EXISTS vibn_workspace_members_user_idx ON vibn_workspace_members (user_id)`,
`CREATE TABLE IF NOT EXISTS vibn_workspace_api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES vibn_workspaces(id) ON DELETE CASCADE,
name TEXT NOT NULL,
key_prefix TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
scopes JSONB NOT NULL DEFAULT '["workspace:*"]'::jsonb,
created_by TEXT NOT NULL,
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`,
`CREATE INDEX IF NOT EXISTS vibn_workspace_api_keys_workspace_idx ON vibn_workspace_api_keys (workspace_id)`,
// Tag projects with the workspace they belong to (nullable until backfill).
// The pre-existing fs_projects.workspace TEXT column stays for the legacy slug;
// this new UUID FK is the canonical link.
`ALTER TABLE fs_projects ADD COLUMN IF NOT EXISTS vibn_workspace_id UUID REFERENCES vibn_workspaces(id) ON DELETE SET NULL`,
`CREATE INDEX IF NOT EXISTS fs_projects_vibn_workspace_idx ON fs_projects (vibn_workspace_id)`,
// ── Per-workspace Gitea bot user (for direct AI access) ──────────
// Each workspace gets its own Gitea user with a PAT scoped to the
// workspace's org, so AI agents can `git clone` / push directly
// without ever touching the root admin token.
//
// Token is encrypted at rest with AES-256-GCM using VIBN_SECRETS_KEY.
// Layout: iv(12) || ciphertext || authTag(16), base64-encoded.
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS gitea_bot_username TEXT`,
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS gitea_bot_user_id INT`,
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS gitea_bot_token_encrypted TEXT`,
// ── Phase 4: workspace-owned deploy infra ────────────────────────
// Lets AI agents create Coolify applications/databases/services
// against a Gitea repo the bot can read, routed to the right
// server and Docker destination, and exposed under the workspace's
// own subdomain namespace.
//
// coolify_server_uuid — which Coolify server the workspace deploys to
// coolify_destination_uuid — Docker network / destination on that server
// coolify_environment_name — Coolify environment (default "production")
// coolify_private_key_uuid — workspace-wide SSH deploy key (Coolify-side UUID)
// gitea_bot_ssh_key_id — Gitea key id for the matching public key (for rotation)
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS coolify_server_uuid TEXT`,
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS coolify_destination_uuid TEXT`,
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS coolify_environment_name TEXT NOT NULL DEFAULT 'production'`,
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS coolify_private_key_uuid TEXT`,
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS gitea_bot_ssh_key_id INT`,
// ── Phase 5.1: domains (OpenSRS) + DNS + billing ledger ──────────
//
// vibn_domains — owned domains + their registration lifecycle
// vibn_domain_events — audit trail (register, attach, renew, expire)
// vibn_billing_ledger — money in/out at the workspace level
//
// Reg credentials for a domain (OpenSRS manage-user password) are
// encrypted at rest with AES-256-GCM using VIBN_SECRETS_KEY.
//
// Workspace residency preference for DNS:
// dns_provider = 'cloud_dns' (default, public records)
// dns_provider = 'cira_dzone' (strict Canadian residency, future)
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS dns_provider TEXT NOT NULL DEFAULT 'cloud_dns'`,
`CREATE TABLE IF NOT EXISTS vibn_domains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES vibn_workspaces(id) ON DELETE CASCADE,
domain TEXT NOT NULL,
tld TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
registrar TEXT NOT NULL DEFAULT 'opensrs',
registrar_order_id TEXT,
registrar_username TEXT,
registrar_password_enc TEXT,
period_years INT NOT NULL DEFAULT 1,
whois_privacy BOOLEAN NOT NULL DEFAULT TRUE,
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
registered_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
dns_provider TEXT,
dns_zone_id TEXT,
dns_nameservers JSONB,
last_reconciled_at TIMESTAMPTZ,
price_paid_cents INT,
price_currency TEXT,
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (domain)
)`,
`CREATE INDEX IF NOT EXISTS vibn_domains_workspace_idx ON vibn_domains (workspace_id)`,
`CREATE INDEX IF NOT EXISTS vibn_domains_status_idx ON vibn_domains (status)`,
`CREATE INDEX IF NOT EXISTS vibn_domains_expires_idx ON vibn_domains (expires_at) WHERE expires_at IS NOT NULL`,
`CREATE TABLE IF NOT EXISTS vibn_domain_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain_id UUID NOT NULL REFERENCES vibn_domains(id) ON DELETE CASCADE,
workspace_id UUID NOT NULL REFERENCES vibn_workspaces(id) ON DELETE CASCADE,
type TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`,
`CREATE INDEX IF NOT EXISTS vibn_domain_events_domain_idx ON vibn_domain_events (domain_id, created_at DESC)`,
`CREATE TABLE IF NOT EXISTS vibn_billing_ledger (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES vibn_workspaces(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
amount_cents INT NOT NULL,
currency TEXT NOT NULL DEFAULT 'CAD',
ref_type TEXT,
ref_id TEXT,
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`,
`CREATE INDEX IF NOT EXISTS vibn_billing_ledger_workspace_idx ON vibn_billing_ledger (workspace_id, created_at DESC)`,
`CREATE INDEX IF NOT EXISTS vibn_billing_ledger_ref_idx ON vibn_billing_ledger (ref_type, ref_id)`,
];
for (const stmt of statements) {
const label = stmt.trim().split("\n")[0].trim().slice(0, 80);
try {
await query(stmt, []);
results.push({ statement: label, ok: true });
} catch (err) {
results.push({
statement: label,
ok: false,
error: err instanceof Error ? err.message : String(err),
});
}
}
const failed = results.filter((r) => !r.ok);
return NextResponse.json(
{ ok: failed.length === 0, results },
{ status: failed.length === 0 ? 200 : 207 },
);
}

View File

@@ -1,114 +0,0 @@
/**
* Workspace autosave trigger.
*
* POST /api/admin/path-b/autosave
* Headers: Authorization: Bearer <NEXTAUTH_SECRET>
* Body: { projectId: string, projectSlug: string }
*
* Pushes /workspace inside the project's dev container to a
* `vibn-autosave/main` branch in Gitea. Throttled to once per 5 min
* per project so we don't hammer Gitea on every chat turn.
*
* Two intended callers:
* 1. Chat post-turn hook (best-effort fire-and-forget).
* 2. Cron sweep every 5 min as a backstop.
*
* The autosave branch is force-pushed; never collides with `main`.
* Treat this as a recovery point, not history — the user's real
* commits go through the `ship` tool.
*/
import { NextResponse } from "next/server";
import { autosaveWorkspace } from "@/lib/dev-container";
import { query } from "@/lib/db-postgres";
import { getOrCreateProvisionedWorkspace } from "@/lib/workspaces";
import { timingSafeStringEq } from "@/lib/server/timing-safe";
export async function POST(request: Request) {
const expected = process.env.NEXTAUTH_SECRET ?? "";
if (!expected) {
return NextResponse.json(
{ error: "NEXTAUTH_SECRET not configured" },
{ status: 503 },
);
}
const auth = request.headers.get("authorization") ?? "";
const bearer = auth.toLowerCase().startsWith("bearer ")
? auth.slice(7).trim()
: "";
if (!bearer || !timingSafeStringEq(expected, bearer)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let body: { projectId?: string; projectSlug?: string; sweep?: boolean };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
// Single-project mode.
if (body.projectId) {
const projectId = String(body.projectId);
const row = await query<{ slug: string; data: any; workspace: string }>(
`SELECT slug, data, workspace FROM fs_projects WHERE id = $1 LIMIT 1`,
[projectId],
);
if (row.length === 0) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
const ws = await getOrCreateProvisionedWorkspace({
userId: row[0].data?.userId ?? "",
email: row[0].data?.ownerEmail ?? "",
displayName: row[0].workspace,
}).catch(() => null);
if (!ws) {
return NextResponse.json(
{ error: "Workspace not provisioned" },
{ status: 503 },
);
}
const result = await autosaveWorkspace({
projectId,
projectSlug: row[0].slug,
workspace: ws,
});
return NextResponse.json({ result });
}
// Sweep mode: autosave every project with a running dev container.
if (body.sweep) {
const rows = await query<{ project_id: string; workspace: string }>(
`SELECT project_id, workspace FROM fs_project_dev_containers WHERE state = 'running'`,
[],
);
const out: Array<{ projectId: string; ran: boolean; reason: string }> = [];
for (const r of rows) {
const proj = await query<{ slug: string; data: any }>(
`SELECT slug, data FROM fs_projects WHERE id = $1 LIMIT 1`,
[r.project_id],
);
if (proj.length === 0) continue;
const ws = await getOrCreateProvisionedWorkspace({
userId: proj[0].data?.userId ?? "",
email: proj[0].data?.ownerEmail ?? "",
displayName: r.workspace,
}).catch(() => null);
if (!ws) continue;
const res = await autosaveWorkspace({
projectId: r.project_id,
projectSlug: proj[0].slug,
workspace: ws,
}).catch((err) => ({
ran: false,
reason: err instanceof Error ? err.message : String(err),
}));
out.push({ projectId: r.project_id, ran: res.ran, reason: res.reason });
}
return NextResponse.json({ result: { swept: out.length, out } });
}
return NextResponse.json(
{ error: "Provide either { projectId } or { sweep: true }" },
{ status: 400 },
);
}

View File

@@ -1,27 +0,0 @@
import { NextResponse } from "next/server";
import { setFlag } from "@/lib/feature-flags";
import { timingSafeStringEq } from "@/lib/server/timing-safe";
export async function POST(request: Request) {
const expected = process.env.NEXTAUTH_SECRET ?? "";
if (!expected) {
return NextResponse.json(
{ error: "NEXTAUTH_SECRET not configured" },
{ status: 503 },
);
}
const auth = request.headers.get("authorization") ?? "";
const bearer = auth.toLowerCase().startsWith("bearer ")
? auth.slice(7).trim()
: "";
if (!bearer || !timingSafeStringEq(expected, bearer)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
await setFlag("path_b_disabled", true);
return NextResponse.json({
ok: true,
flag: "path_b_disabled",
value: true,
note: "Path B (AI dev containers) disabled. New chat sessions fall back to Gitea-write tools. Existing dev containers continue until idle-suspend.",
});
}

View File

@@ -1,27 +0,0 @@
import { NextResponse } from "next/server";
import { setFlag } from "@/lib/feature-flags";
import { timingSafeStringEq } from "@/lib/server/timing-safe";
export async function POST(request: Request) {
const expected = process.env.NEXTAUTH_SECRET ?? "";
if (!expected) {
return NextResponse.json(
{ error: "NEXTAUTH_SECRET not configured" },
{ status: 503 },
);
}
const auth = request.headers.get("authorization") ?? "";
const bearer = auth.toLowerCase().startsWith("bearer ")
? auth.slice(7).trim()
: "";
if (!bearer || !timingSafeStringEq(expected, bearer)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
await setFlag("path_b_disabled", false);
return NextResponse.json({
ok: true,
flag: "path_b_disabled",
value: false,
note: "Path B re-enabled.",
});
}

View File

@@ -1,47 +0,0 @@
/**
* Idle-suspend sweep for Path B dev containers.
*
* POST /api/admin/path-b/idle-sweep[?minutes=30]
* Headers: Authorization: Bearer <NEXTAUTH_SECRET>
*
* Suspends every running dev container whose `last_active_at` is older
* than `minutes` (default 30). Idempotent — re-runs harmlessly.
*
* Wire this to a cron (every 5 min) once the frontend is stable.
* Crontab: "[asterisk]/5 * * * *" running:
* curl -fsS -X POST -H "Authorization: Bearer $SECRET" \
* https://vibnai.com/api/admin/path-b/idle-sweep
*
* Saves money (suspended containers don't bill compute) without
* destroying state — the workspace volume + cache volume persist, and
* the next shell.exec call resumes the service in <5s.
*/
import { NextResponse } from "next/server";
import { suspendIdleContainers } from "@/lib/dev-container";
import { timingSafeStringEq } from "@/lib/server/timing-safe";
export async function POST(request: Request) {
const expected = process.env.NEXTAUTH_SECRET ?? "";
if (!expected) {
return NextResponse.json(
{ error: "NEXTAUTH_SECRET not configured" },
{ status: 503 },
);
}
const auth = request.headers.get("authorization") ?? "";
const bearer = auth.toLowerCase().startsWith("bearer ")
? auth.slice(7).trim()
: "";
if (!bearer || !timingSafeStringEq(expected, bearer)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const url = new URL(request.url);
const minStr = url.searchParams.get("minutes");
const minutes =
minStr && Number.isFinite(Number(minStr))
? Math.max(5, Number(minStr))
: 30;
const result = await suspendIdleContainers(minutes);
return NextResponse.json({ result, idleMinutes: minutes });
}

View File

@@ -1,43 +0,0 @@
/**
* Path B kill switch.
*
* GET /api/admin/path-b → returns { disabled: boolean }
* POST /api/admin/path-b/disable → sets disabled=true (handled below)
* POST /api/admin/path-b/enable → sets disabled=false
*
* Auth: Bearer NEXTAUTH_SECRET (ops bootstrap), same pattern as the
* /api/admin/backfill-isolation endpoint. We deliberately do NOT accept
* workspace API keys here — flipping a global feature flag is a
* platform-level action.
*
* When `path_b_disabled = true`:
* - shell.exec, fs.*, devcontainer.* return 503 from /api/mcp
* - the chat system prompt falls back to Path A (Gitea-write) guidance
* - existing dev containers keep running until they idle-suspend
* (no force-kill — graceful drain)
*
* Reverting is a single POST. Cache TTL is 10s, so the flip propagates
* to every Vibn pod within ~10s of the SQL update.
*/
import { NextResponse } from "next/server";
import { getFlag } from "@/lib/feature-flags";
import { timingSafeStringEq } from "@/lib/server/timing-safe";
function authorized(request: Request): boolean {
const expected = process.env.NEXTAUTH_SECRET ?? "";
if (!expected) return false;
const auth = request.headers.get("authorization") ?? "";
const bearer = auth.toLowerCase().startsWith("bearer ")
? auth.slice(7).trim()
: "";
return Boolean(bearer && timingSafeStringEq(expected, bearer));
}
export async function GET(request: Request) {
if (!authorized(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const disabled = await getFlag<boolean>("path_b_disabled", false);
return NextResponse.json({ disabled });
}

View File

@@ -1,586 +0,0 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { GeminiLlmClient } from "@/lib/ai/gemini-client";
import { withTenantProject } from "@/lib/server/api-handler";
import { log } from "@/lib/server/logger";
import type { LlmClient } from "@/lib/ai/llm-client";
import { query } from "@/lib/db-postgres";
import { MODE_SYSTEM_PROMPTS, ChatMode } from "@/lib/ai/chat-modes";
import { resolveChatMode } from "@/lib/server/chat-mode-resolver";
import {
buildProjectContextForChat,
determineArtifactsUsed,
formatContextForPrompt,
} from "@/lib/server/chat-context";
import { logProjectEvent } from "@/lib/server/logs";
import type { CollectorPhaseHandoff } from "@/lib/types/phase-handoff";
// Increase timeout for Gemini 3 Pro thinking mode (can take 1-2 minutes)
export const maxDuration = 180; // 3 minutes
export const dynamic = "force-dynamic";
const ChatReplySchema = z.object({
reply: z.string(),
visionAnswers: z
.object({
q1: z.string().optional(), // Answer to question 1
q2: z.string().optional(), // Answer to question 2
q3: z.string().optional(), // Answer to question 3
allAnswered: z.boolean().optional(), // True when all 3 are complete
})
.optional(),
collectorHandoff: z
.object({
hasDocuments: z.boolean().optional(),
documentCount: z.number().optional(),
githubConnected: z.boolean().optional(),
githubRepo: z.string().optional(),
extensionLinked: z.boolean().optional(),
extensionDeclined: z.boolean().optional(),
noGithubYet: z.boolean().optional(),
readyForExtraction: z.boolean().optional(),
})
.optional(),
extractionReviewHandoff: z
.object({
extractionApproved: z.boolean().optional(),
readyForVision: z.boolean().optional(),
})
.optional(),
});
interface ChatRequestBody {
projectId?: string;
message?: string;
overrideMode?: ChatMode;
}
const ENSURE_CONV_TABLE = `
CREATE TABLE IF NOT EXISTS chat_conversations (
project_id text PRIMARY KEY,
messages jsonb NOT NULL DEFAULT '[]',
updated_at timestamptz NOT NULL DEFAULT NOW()
)
`;
async function appendConversation(
projectId: string,
newMessages: Array<{ role: "user" | "assistant"; content: string }>,
) {
await query(ENSURE_CONV_TABLE);
const now = new Date().toISOString();
const stamped = newMessages.map((m) => ({ ...m, createdAt: now }));
await query(
`INSERT INTO chat_conversations (project_id, messages, updated_at)
VALUES ($1, $2::jsonb, NOW())
ON CONFLICT (project_id) DO UPDATE
SET messages = chat_conversations.messages || $2::jsonb,
updated_at = NOW()`,
[projectId, JSON.stringify(stamped)],
);
}
export const POST = withTenantProject(
async (request, _ctx, { project, user }) => {
try {
const body = (await request.json()) as ChatRequestBody;
const projectId = project.id;
const message = body.message?.trim();
if (!message) {
return NextResponse.json(
{ error: "message is required" },
{ status: 400 },
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const projectData = (project.data ?? {}) as any;
log.info("ai/chat: starting", {
route: "api.ai.chat",
projectId,
user: user.email,
});
// Resolve chat mode (uses new resolver)
const resolvedMode =
body.overrideMode ?? (await resolveChatMode(projectId));
console.log(`[AI Chat] Mode: ${resolvedMode}`);
// Build comprehensive context with vector retrieval
// Only include GitHub analysis for MVP generation (not needed for vision questions)
const context = await buildProjectContextForChat(
projectId,
resolvedMode,
message,
{
retrievalLimit: 10,
includeVectorSearch: true,
includeGitHubAnalysis: resolvedMode === "mvp_mode", // Only load repo analysis when generating MVP
},
);
console.log(
`[AI Chat] Context built: ${context.retrievedChunks.length} vector chunks retrieved`,
);
// Get mode-specific system prompt
const systemPrompt = MODE_SYSTEM_PROMPTS[resolvedMode];
// Format context for LLM
const contextSummary = formatContextForPrompt(context);
// Prepare enhanced system prompt with context
const enhancedSystemPrompt = `${systemPrompt}
## Current Project Context
${contextSummary}
---
You have access to:
- Project artifacts (product model, MVP plan, marketing plan)
- Knowledge items (${context.knowledgeSummary.totalCount} total)
- Extraction signals (${context.extractionSummary.totalCount} analyzed)
${context.retrievedChunks.length > 0 ? `- ${context.retrievedChunks.length} relevant chunks from vector search (most similar to user's query)` : ""}
${context.repositoryAnalysis ? `- GitHub repository analysis (${context.repositoryAnalysis.totalFiles} files)` : ""}
${context.sessionHistory.totalSessions > 0 ? `- Complete Cursor session history (${context.sessionHistory.totalSessions} sessions, ${context.sessionHistory.messages.length} messages in chronological order)` : ""}
Use this context to provide specific, grounded responses. The session history shows your complete conversation history with the user - use it to understand what has been built and discussed.`;
// Load existing conversation history from Postgres
await query(ENSURE_CONV_TABLE);
const convRows = await query<{ messages: any[] }>(
`SELECT messages FROM chat_conversations WHERE project_id = $1`,
[projectId],
);
const conversationHistory: any[] = convRows[0]?.messages ?? [];
// Build full message context (history + current message)
const messages = [
...conversationHistory.map((msg: any) => ({
role: msg.role as "user" | "assistant",
content: msg.content as string,
})),
{
role: "user" as const,
content: message,
},
];
console.log(
`[AI Chat] Sending ${messages.length} messages to LLM (${conversationHistory.length} from history + 1 new)`,
);
console.log(
`[AI Chat] Mode: ${resolvedMode}, Phase: ${projectData.currentPhase}, Has extraction: ${!!context.phaseHandoffs?.extraction}`,
);
// Log system prompt length
console.log(
`[AI Chat] System prompt length: ${enhancedSystemPrompt.length} chars (~${Math.ceil(enhancedSystemPrompt.length / 4)} tokens)`,
);
// Log each message length
messages.forEach((msg, i) => {
console.log(
`[AI Chat] Message ${i + 1} (${msg.role}): ${msg.content.length} chars (~${Math.ceil(msg.content.length / 4)} tokens)`,
);
});
const totalInputChars =
enhancedSystemPrompt.length +
messages.reduce((sum, msg) => sum + msg.content.length, 0);
console.log(
`[AI Chat] Total input: ${totalInputChars} chars (~${Math.ceil(totalInputChars / 4)} tokens)`,
);
// Log system prompt preview (first 500 chars)
console.log(
`[AI Chat] System prompt preview: ${enhancedSystemPrompt.substring(0, 500)}...`,
);
// Log last user message
const lastUserMsg = messages[messages.length - 1];
console.log(`[AI Chat] User message: ${lastUserMsg.content}`);
// Safety check: extraction_review_mode requires extraction results
if (
resolvedMode === "extraction_review_mode" &&
!context.phaseHandoffs?.extraction
) {
console.warn(
`[AI Chat] WARNING: extraction_review_mode active but no extraction results found for project ${projectId}`,
);
}
const llm: LlmClient = new GeminiLlmClient();
// Configure thinking mode based on task complexity
// Simple modes (collector, extraction_review) don't need deep thinking
// Complex modes (mvp, vision) benefit from extended reasoning
const needsThinking =
resolvedMode === "mvp_mode" || resolvedMode === "vision_mode";
const reply = await llm.structuredCall<{
reply: string;
visionAnswers?: {
q1?: string;
q2?: string;
q3?: string;
allAnswered?: boolean;
};
collectorHandoff?: {
hasDocuments?: boolean;
documentCount?: number;
githubConnected?: boolean;
githubRepo?: string;
extensionLinked?: boolean;
extensionDeclined?: boolean;
noGithubYet?: boolean;
readyForExtraction?: boolean;
};
extractionReviewHandoff?: {
extractionApproved?: boolean;
readyForVision?: boolean;
};
}>({
model: "gemini",
systemPrompt: enhancedSystemPrompt,
messages: messages, // Full conversation history!
schema: ChatReplySchema,
temperature: 0.4,
thinking_config: needsThinking
? {
thinking_level: "high",
include_thoughts: false,
}
: undefined,
});
// Store all vision answers when provided
if (reply.visionAnswers) {
const updates: any = {};
if (reply.visionAnswers.q1) {
updates["visionAnswers.q1"] = reply.visionAnswers.q1;
console.log("[AI Chat] Storing vision answer Q1");
}
if (reply.visionAnswers.q2) {
updates["visionAnswers.q2"] = reply.visionAnswers.q2;
console.log("[AI Chat] Storing vision answer Q2");
}
if (reply.visionAnswers.q3) {
updates["visionAnswers.q3"] = reply.visionAnswers.q3;
console.log("[AI Chat] Storing vision answer Q3");
}
// If all answers are complete, trigger MVP generation
if (reply.visionAnswers.allAnswered) {
updates["visionAnswers.allAnswered"] = true;
updates["readyForMVP"] = true;
console.log(
"[AI Chat] ✅ All 3 vision answers complete - ready for MVP generation",
);
}
if (Object.keys(updates).length > 0) {
updates["visionAnswers.updatedAt"] = new Date().toISOString();
await query(
`UPDATE fs_projects
SET data = data || $1::jsonb
WHERE id = $2`,
[JSON.stringify({ visionAnswers: updates }), projectId],
).catch((error) => {
console.error("[ai/chat] Failed to store vision answers", error);
});
}
}
// Best-effort: append this turn to the persisted conversation history
appendConversation(projectId, [
{ role: "user", content: message },
{ role: "assistant", content: reply.reply },
]).catch((error) => {
console.error("[ai/chat] Failed to append conversation history", error);
});
// If in collector mode, always update handoff state based on actual project context
// This ensures the checklist updates even if AI doesn't return collectorHandoff
if (resolvedMode === "collector_mode") {
// Derive handoff state from actual project context
const hasDocuments =
(context.knowledgeSummary.bySourceType["imported_document"] ?? 0) > 0;
const documentCount =
context.knowledgeSummary.bySourceType["imported_document"] ?? 0;
const githubConnected = !!context.project.githubRepo;
const extensionLinked = context.project.extensionLinked ?? false;
// Check if AI indicated readiness (from reply if provided, otherwise check reply text)
let readyForExtraction =
reply.collectorHandoff?.readyForExtraction ?? false;
// Fallback: If AI says certain phrases, assume user confirmed readiness
// IMPORTANT: These phrases must be SPECIFIC to avoid false positives
if (!readyForExtraction && reply.reply) {
const replyLower = reply.reply.toLowerCase();
// Check for explicit analysis/digging phrases (not just "perfect!")
const analysisKeywords = [
"analyze",
"analyzing",
"digging",
"extraction",
"processing",
];
const hasAnalysisKeyword = analysisKeywords.some((keyword) =>
replyLower.includes(keyword),
);
// Only trigger if AI mentions BOTH readiness AND analysis action
if (hasAnalysisKeyword) {
const confirmPhrases = [
"let me analyze what you",
"i'll start digging into",
"i'm starting the analysis",
"running the extraction",
"processing what you've shared",
];
readyForExtraction = confirmPhrases.some((phrase) =>
replyLower.includes(phrase),
);
if (readyForExtraction) {
console.log(
`[AI Chat] Detected readiness from AI reply text: "${reply.reply.substring(0, 100)}"`,
);
}
}
}
const handoff: CollectorPhaseHandoff = {
phase: "collector",
readyForNextPhase: readyForExtraction,
confidence: readyForExtraction ? 0.9 : 0.5,
confirmed: {
hasDocuments,
documentCount,
githubConnected,
githubRepo: context.project.githubRepo ?? undefined,
extensionLinked,
},
uncertain: {
extensionDeclined:
reply.collectorHandoff?.extensionDeclined ?? false,
noGithubYet: reply.collectorHandoff?.noGithubYet ?? false,
},
missing: [],
questionsForUser: [],
sourceEvidence: [],
version: "1.0",
timestamp: new Date().toISOString(),
};
// Persist to project phaseData in Postgres
await query(
`UPDATE fs_projects
SET data = jsonb_set(
data,
'{phaseData,phaseHandoffs,collector}',
$1::jsonb,
true
)
WHERE id = $2`,
[JSON.stringify(handoff), projectId],
).catch((error) => {
console.error("[ai/chat] Failed to persist collector handoff", error);
});
console.log(`[AI Chat] Collector handoff persisted:`, {
hasDocuments: handoff.confirmed.hasDocuments,
githubConnected: handoff.confirmed.githubConnected,
extensionLinked: handoff.confirmed.extensionLinked,
readyForExtraction: handoff.readyForNextPhase,
});
// Auto-transition to extraction phase if ready
if (handoff.readyForNextPhase) {
console.log(
`[AI Chat] Collector complete - triggering backend extraction`,
);
// Mark collector as complete
await query(
`UPDATE fs_projects
SET data = jsonb_set(data, '{phaseData,collectorCompletedAt}', $1::jsonb, true)
WHERE id = $2`,
[JSON.stringify(new Date().toISOString()), projectId],
).catch((error) => {
console.error("[ai/chat] Failed to mark collector complete", error);
});
// Trigger backend extraction (async - don't await)
import("@/lib/server/backend-extractor").then(
({ runBackendExtractionForProject }) => {
runBackendExtractionForProject(projectId).catch((error) => {
console.error(
`[AI Chat] Backend extraction failed for project ${projectId}:`,
error,
);
});
},
);
}
}
// Handle extraction review → vision phase transition
if (resolvedMode === "extraction_review_mode") {
// Check if AI indicated extraction is approved and ready for vision
let readyForVision =
reply.extractionReviewHandoff?.readyForVision ?? false;
// Fallback: Check reply text for approval phrases
if (!readyForVision && reply.reply) {
const replyLower = reply.reply.toLowerCase();
// Check for vision transition phrases
const visionKeywords = ["vision", "mvp", "roadmap", "plan"];
const hasVisionKeyword = visionKeywords.some((keyword) =>
replyLower.includes(keyword),
);
if (hasVisionKeyword) {
const confirmPhrases = [
"ready to move to",
"ready for vision",
"let's move to vision",
"moving to vision",
"great! let's define",
"perfect! now let's",
];
readyForVision = confirmPhrases.some((phrase) =>
replyLower.includes(phrase),
);
if (readyForVision) {
console.log(
`[AI Chat] Detected vision readiness from AI reply text: "${reply.reply.substring(0, 100)}"`,
);
}
}
}
if (readyForVision) {
console.log(
`[AI Chat] Extraction review complete - transitioning to vision phase`,
);
// Mark extraction review as complete and transition to vision
await query(
`UPDATE fs_projects
SET data = data
|| '{"currentPhase":"vision","phaseStatus":"in_progress"}'::jsonb
|| jsonb_build_object('phaseData',
(data->'phaseData') || jsonb_build_object(
'extractionReviewCompletedAt', $1::text
)
)
WHERE id = $2`,
[new Date().toISOString(), projectId],
).catch((error) => {
console.error(
"[ai/chat] Failed to transition to vision phase",
error,
);
});
}
}
// Save conversation history to Postgres
await appendConversation(projectId, [
{ role: "user", content: message },
{ role: "assistant", content: reply.reply },
]).catch((error) => {
console.error("[ai/chat] Failed to save conversation history", error);
});
console.log(`[AI Chat] Conversation history saved (+2 messages)`);
// Determine which artifacts were used
const artifactsUsed = determineArtifactsUsed(context);
// Log successful interaction
logProjectEvent({
projectId,
userId: projectData.userId ?? null,
eventType: "chat_interaction",
mode: resolvedMode,
phase: projectData.currentPhase ?? null,
artifactsUsed,
usedVectorSearch: context.retrievedChunks.length > 0,
vectorChunkCount: context.retrievedChunks.length,
promptVersion: "2.0", // Updated with vector search
modelUsed: process.env.VERTEX_AI_MODEL || "gemini-3-pro-preview",
success: true,
errorMessage: null,
metadata: {
knowledgeCount: context.knowledgeSummary.totalCount,
extractionCount: context.extractionSummary.totalCount,
hasGithubRepo: !!context.repositoryAnalysis,
},
}).catch((err) => console.error("[ai/chat] Failed to log event:", err));
return NextResponse.json({
reply: reply.reply,
mode: resolvedMode,
projectPhase: projectData.currentPhase ?? null,
artifactsUsed,
usedVectorSearch: context.retrievedChunks.length > 0,
});
} catch (error) {
console.error("[ai/chat] Error handling chat request", error);
// Log error (best-effort) - extract projectId from request body if available
const errorProjectId =
typeof (error as { projectId?: string })?.projectId === "string"
? (error as { projectId: string }).projectId
: null;
if (errorProjectId) {
logProjectEvent({
projectId: errorProjectId,
userId: null,
eventType: "error",
mode: null,
phase: null,
artifactsUsed: [],
usedVectorSearch: false,
promptVersion: "2.0",
modelUsed: process.env.VERTEX_AI_MODEL || "gemini-3-pro-preview",
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
}).catch((err) =>
log.error("ai/chat log failed", {
route: "api.ai.chat",
err: err instanceof Error ? err.message : String(err),
}),
);
}
log.error("ai/chat error", {
route: "api.ai.chat",
err: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{
error: "Failed to process chat message",
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
},
{ source: "body", paramName: "projectId" },
);

View File

@@ -1,38 +0,0 @@
/**
* POST /api/ai/conversation/reset
* Body OR query: { projectId }
*
* Deletes the conversation history for a project the caller owns.
*
* Closes S-03. Also migrated off the legacy Firebase admin path onto Postgres
* to match `/api/ai/conversation`.
*/
import { NextResponse } from "next/server";
import { query } from "@/lib/db-postgres";
import { withTenantProject } from "@/lib/server/api-handler";
import { log } from "@/lib/server/logger";
export const POST = withTenantProject(
async (_request, _ctx, { project }) => {
try {
await query(`DELETE FROM chat_conversations WHERE project_id = $1`, [
project.id,
]);
return NextResponse.json({ success: true });
} catch (err) {
log.error("ai/conversation/reset failed", {
route: "api.ai.conversation.reset",
projectId: project.id,
err: err instanceof Error ? err.message : String(err),
});
return NextResponse.json(
{
error: "Failed to reset conversation",
details: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
);
}
},
{ source: "body", paramName: "projectId" },
);

View File

@@ -1,76 +0,0 @@
/**
* GET /api/ai/conversation?projectId=… — fetch saved conversation
* DELETE /api/ai/conversation?projectId=… — wipe saved conversation
*
* Closes S-02: was completely unauthenticated and accepted any projectId.
*/
import { NextResponse } from "next/server";
import { query } from "@/lib/db-postgres";
import { withTenantProject } from "@/lib/server/api-handler";
import { log } from "@/lib/server/logger";
const ENSURE_TABLE = `
CREATE TABLE IF NOT EXISTS chat_conversations (
project_id text PRIMARY KEY,
messages jsonb NOT NULL DEFAULT '[]',
updated_at timestamptz NOT NULL DEFAULT NOW()
)
`;
type StoredMessageRole = "user" | "assistant";
type ConversationMessage = {
role: StoredMessageRole;
content: string;
createdAt?: string;
};
type ConversationResponse = {
messages: ConversationMessage[];
};
export const GET = withTenantProject(
async (request, _ctx, { project }) => {
try {
await query(ENSURE_TABLE);
const rows = await query<{ messages: ConversationMessage[] }>(
`SELECT messages FROM chat_conversations WHERE project_id = $1`,
[project.id],
);
const messages: ConversationMessage[] = rows[0]?.messages ?? [];
const response: ConversationResponse = { messages };
return NextResponse.json(response);
} catch (err) {
log.error("ai/conversation GET failed", {
route: "api.ai.conversation",
projectId: project.id,
err: err instanceof Error ? err.message : String(err),
});
return NextResponse.json({ messages: [] });
}
},
{ source: "search", paramName: "projectId" },
);
export const DELETE = withTenantProject(
async (_request, _ctx, { project }) => {
try {
await query(ENSURE_TABLE);
await query(`DELETE FROM chat_conversations WHERE project_id = $1`, [
project.id,
]);
return NextResponse.json({ ok: true });
} catch (err) {
log.error("ai/conversation DELETE failed", {
route: "api.ai.conversation",
projectId: project.id,
err: err instanceof Error ? err.message : String(err),
});
return NextResponse.json(
{ error: "Failed to reset conversation" },
{ status: 500 },
);
}
},
{ source: "search", paramName: "projectId" },
);

View File

@@ -23,14 +23,13 @@ import {
detectKnownError,
formatRecoveryMessage,
} from "@/lib/ai/error-recovery";
import { autoExtractPlanUpdates } from "@/lib/ai/plan-extract";
import { listRecentSentryIssues } from "@/lib/integrations/sentry";
import {
ensureProjectRepoCloned,
commitAndPushIfDirty,
} from "@/lib/dev-container-git";
import { buildDesignKitPromptSection } from "@/lib/design-kits/for-ai";
import { buildCodebaseSummary } from "@/lib/ai/project-context/codebase-summary";
import { buildCodebaseSummary } from "@/lib/ai/codebase-summary";
import type { ChatMessage, ToolCall } from "@/lib/ai/gemini-chat";
// C-01: Lowered from 15 → 8. Real workflows (scaffold → install →
@@ -679,10 +678,10 @@ export async function POST(request: Request) {
? VIBN_TOOL_DEFINITIONS
: [];
// Every 2 silent rounds or 5 tool calls, nudge the model to surface a one-liner
// Every 8 silent rounds or 12 tool calls, gently nudge the model to surface a one-liner
// status before continuing. This is the user's only signal of
// life when a tool chain runs long.
const isSilent = roundsSinceText >= 2 || toolCallsSinceText >= 5;
// life when a tool chain runs long, but we keep the threshold high so it doesn't distract.
const isSilent = roundsSinceText >= 8 || toolCallsSinceText >= 12;
let extraSystem = isSilent
? "\n\n[STATUS NUDGE] You have run " +
`${toolCallsSinceText} tool call(s) over ${roundsSinceText} round(s) ` +
@@ -777,18 +776,15 @@ export async function POST(request: Request) {
}
}
// C-02: Tightened. Hard-break at 3 identical fingerprints (was 5).
if (maxRepeats === 2) {
extraSystem += `\n\n[WARNING] You have called ${repeatedCmd} twice in a row. Try a different approach or surface what's blocking you to the user.`;
// Hard-break at 6 identical fingerprints
if (maxRepeats === 4) {
extraSystem += `\n\n[WARNING] You have called ${repeatedCmd} four times recently. Try a different approach or surface what's blocking you to the user.`;
}
if (maxRepeats >= 3) {
if (maxRepeats >= 6) {
loopBreakReason = `Repeated ${repeatedCmd} ${maxRepeats}× in last 10 calls`;
}
// C-02: Also hard-break after 6 consecutive tool calls with no text.
if (!loopBreakReason && toolCallsSinceText >= 6) {
loopBreakReason = `${toolCallsSinceText} consecutive tool calls with no assistant text`;
}
// Removed consecutive tool call hard-break logic because it interrupts valid long tool chains.
// Execute tool calls and add results. OpenAI-compatible APIs
// (DeepSeek, etc.) require every tool_call_id to be answered with
@@ -898,7 +894,7 @@ export async function POST(request: Request) {
(round >= MAX_TOOL_ROUNDS ||
!!loopBreakReason ||
assistantText.trim().length === 0 ||
roundsSinceText >= 4 ||
roundsSinceText >= 8 ||
lastToolResultsHadFailure(messages));
if (needsRecovery) {
@@ -1140,17 +1136,7 @@ export async function POST(request: Request) {
return `${m.role.toUpperCase()}: ${text.slice(0, 1200)}`;
})
.join("\n\n");
const result = await autoExtractPlanUpdates(
threadProjectId,
transcript,
);
if (result) {
console.log(
"[chat] plan-extract:",
`${result.tasks} tasks, ${result.decisions} decisions, vision=${result.vision}`,
);
}
} catch (err) {
} catch (err) {
console.warn("[chat] plan-extract failed (non-fatal):", err);
}
})().catch(() => {});

View File

@@ -1,159 +0,0 @@
/**
* Import ChatGPT conversations from exported conversations.json file
*/
import { NextResponse } from 'next/server';
import { getAdminAuth, getAdminDb } from '@/lib/firebase/admin';
interface ChatGPTMessage {
id: string;
author: {
role: string;
name?: string;
};
content: {
content_type: string;
parts: string[];
};
create_time?: number;
update_time?: number;
}
interface ChatGPTConversation {
id: string;
title: string;
create_time: number;
update_time?: number;
mapping: Record<string, {
id: string;
message?: ChatGPTMessage;
parent?: string;
children: string[];
}>;
}
export async function POST(request: Request) {
try {
// Authenticate user
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const idToken = authHeader.split('Bearer ')[1];
const adminAuth = getAdminAuth();
const adminDb = getAdminDb();
let userId: string;
try {
const decodedToken = await adminAuth.verifyIdToken(idToken);
userId = decodedToken.uid;
} catch (error) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
const body = await request.json();
const { conversations, projectId } = body;
if (!conversations || !Array.isArray(conversations)) {
return NextResponse.json({ error: 'Invalid conversations data' }, { status: 400 });
}
console.log(`[ChatGPT Import] Processing ${conversations.length} conversations for user ${userId}`);
const importedConversations: Array<{ id: string; title: string; messageCount: number }> = [];
const batch = adminDb.batch();
let batchCount = 0;
for (const conv of conversations) {
try {
const conversation = conv as ChatGPTConversation;
// Extract messages from mapping
const messages: Array<{
role: string;
content: string;
timestamp?: number;
}> = [];
// Find the root and traverse the conversation tree
for (const [key, node] of Object.entries(conversation.mapping)) {
if (node.message && node.message.author.role !== 'system') {
const content = node.message.content.parts.join('\n');
if (content.trim()) {
messages.push({
role: node.message.author.role,
content: content,
timestamp: node.message.create_time,
});
}
}
}
// Sort messages by timestamp (if available) or keep order
messages.sort((a, b) => {
if (a.timestamp && b.timestamp) {
return a.timestamp - b.timestamp;
}
return 0;
});
// Store in Firestore
const importRef = adminDb.collection('chatgptImports').doc();
const importData = {
userId,
projectId: projectId || null,
conversationId: conversation.id,
title: conversation.title || 'Untitled Conversation',
messageCount: messages.length,
messages,
createdAt: conversation.create_time
? new Date(conversation.create_time * 1000).toISOString()
: new Date().toISOString(),
importedAt: new Date().toISOString(),
};
batch.set(importRef, importData);
batchCount++;
importedConversations.push({
id: conversation.id,
title: conversation.title,
messageCount: messages.length,
});
// Firestore batch limit is 500 operations
if (batchCount >= 500) {
await batch.commit();
batchCount = 0;
}
} catch (error) {
console.error(`[ChatGPT Import] Error processing conversation ${conv.id}:`, error);
// Continue with other conversations
}
}
// Commit remaining batch
if (batchCount > 0) {
await batch.commit();
}
console.log(`[ChatGPT Import] Successfully imported ${importedConversations.length} conversations`);
return NextResponse.json({
success: true,
imported: importedConversations.length,
conversations: importedConversations,
});
} catch (error) {
console.error('[ChatGPT Import] Error:', error);
return NextResponse.json(
{
error: 'Failed to import conversations',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}

View File

@@ -1,178 +0,0 @@
/**
* Import ChatGPT conversations using OpenAI's Conversations API
*/
import { NextResponse } from 'next/server';
import { getAdminAuth, getAdminDb } from '@/lib/firebase/admin';
const OPENAI_API_URL = 'https://api.openai.com/v1/conversations';
export async function POST(request: Request) {
try {
// Authenticate user
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const idToken = authHeader.split('Bearer ')[1];
const adminAuth = getAdminAuth();
const adminDb = getAdminDb();
let userId: string;
try {
const decodedToken = await adminAuth.verifyIdToken(idToken);
userId = decodedToken.uid;
} catch (error) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
const { conversationId, openaiApiKey, projectId } = await request.json();
if (!conversationId) {
return NextResponse.json({ error: 'Conversation ID is required' }, { status: 400 });
}
if (!openaiApiKey) {
return NextResponse.json({ error: 'OpenAI API key is required' }, { status: 400 });
}
// Fetch conversation from OpenAI
console.log(`[ChatGPT Import] Fetching conversation: ${conversationId}`);
const openaiResponse = await fetch(`${OPENAI_API_URL}/${conversationId}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${openaiApiKey}`,
'Content-Type': 'application/json',
},
});
if (!openaiResponse.ok) {
const errorText = await openaiResponse.text();
console.error('[ChatGPT Import] OpenAI API error:', openaiResponse.status, errorText);
return NextResponse.json(
{
error: 'Failed to fetch conversation from OpenAI',
details: errorText,
status: openaiResponse.status,
},
{ status: openaiResponse.status }
);
}
const conversationData = await openaiResponse.json();
console.log('[ChatGPT Import] Conversation fetched successfully');
// Extract relevant information
const messages = conversationData.messages || [];
const title = conversationData.title || 'Untitled Conversation';
const createdAt = conversationData.created_at || new Date().toISOString();
// Store in Firestore
const chatGPTImportRef = adminDb.collection('chatgptImports').doc();
await chatGPTImportRef.set({
userId,
projectId: projectId || null,
conversationId,
title,
createdAt,
importedAt: new Date().toISOString(),
messageCount: messages.length,
messages: messages.map((msg: any) => ({
role: msg.role || msg.author?.role || 'unknown',
content: msg.content?.parts?.join('\n') || msg.content || '',
timestamp: msg.create_time || msg.timestamp || null,
})),
rawData: conversationData, // Store full response for future reference
});
// If projectId provided, update project with ChatGPT reference
if (projectId) {
const projectRef = adminDb.collection('projects').doc(projectId);
await projectRef.update({
chatgptConversationId: conversationId,
chatgptTitle: title,
chatgptImportedAt: new Date().toISOString(),
});
console.log(`[ChatGPT Import] Updated project ${projectId} with conversation reference`);
}
return NextResponse.json({
success: true,
importId: chatGPTImportRef.id,
conversationId,
title,
messageCount: messages.length,
messages: messages.slice(0, 5).map((msg: any) => ({
role: msg.role || msg.author?.role || 'unknown',
preview: (msg.content?.parts?.join('\n') || msg.content || '').substring(0, 200) + '...',
})),
});
} catch (error) {
console.error('[ChatGPT Import] Error:', error);
return NextResponse.json(
{
error: 'Failed to import ChatGPT conversation',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}
// GET endpoint to list imported conversations
export async function GET(request: Request) {
try {
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const idToken = authHeader.split('Bearer ')[1];
const adminAuth = getAdminAuth();
const adminDb = getAdminDb();
let userId: string;
try {
const decodedToken = await adminAuth.verifyIdToken(idToken);
userId = decodedToken.uid;
} catch (error) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
// Get projectId from query params if provided
const url = new URL(request.url);
const projectId = url.searchParams.get('projectId');
let query = adminDb
.collection('chatgptImports')
.where('userId', '==', userId);
if (projectId) {
query = query.where('projectId', '==', projectId) as any;
}
const snapshot = await query.orderBy('importedAt', 'desc').limit(50).get();
const imports = snapshot.docs.map(doc => ({
id: doc.id,
...doc.data(),
// Don't send full rawData in list view
rawData: undefined,
}));
return NextResponse.json({ imports });
} catch (error) {
console.error('[ChatGPT Import] List error:', error);
return NextResponse.json(
{
error: 'Failed to list imports',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}

View File

@@ -1,99 +0,0 @@
/**
* POST /api/context/summarize
* Body: { content: string, title?: string }
*
* Generates a short summary via Gemini. Closes S-04: now requires a
* signed-in user (rate-limit per user, not per-IP) so we don't burn Gemini
* quota on anonymous traffic.
*/
import { NextResponse } from "next/server";
import { withAuth, withRateLimit } from "@/lib/server/api-handler";
import { log } from "@/lib/server/logger";
const MODEL = process.env.GEMINI_MODEL || "gemini-3.1-pro-preview";
const API_KEY = process.env.GOOGLE_API_KEY || "";
const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`;
export const POST = withRateLimit(
withAuth(async (request, _ctx, { user }) => {
try {
const { content, title } = (await request.json()) as {
content?: string;
title?: string;
};
if (!content || typeof content !== "string") {
return NextResponse.json(
{ error: "content is required" },
{ status: 400 },
);
}
const maxContentLength = 30000;
const truncatedContent =
content.length > maxContentLength
? content.substring(0, maxContentLength) + "..."
: content;
const prompt = `Read this document titled "${title ?? "(untitled)"}" and provide a concise 1-2 sentence summary that captures the main topic and key points. Be specific and actionable.
Document content:
${truncatedContent}
Summary:`;
const response = await fetch(`${GEMINI_URL}?key=${API_KEY}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ role: "user", parts: [{ text: prompt }] }],
generationConfig: { temperature: 0.3 },
}),
});
if (!response.ok) {
const text = await response.text();
log.warn("context/summarize gemini error", {
route: "api.context.summarize",
user: user.email,
status: response.status,
body: text.slice(0, 500),
});
return NextResponse.json(
{
error: `Gemini API error (${response.status})`,
details: text.slice(0, 500),
},
{ status: 502 },
);
}
const result = await response.json();
const summary =
result.candidates?.[0]?.content?.parts?.[0]?.text?.trim() ||
"Summary unavailable";
return NextResponse.json({ summary });
} catch (err) {
log.error("context/summarize failed", {
route: "api.context.summarize",
err: err instanceof Error ? err.message : String(err),
});
return NextResponse.json(
{
error: "Failed to generate summary",
details: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
);
}
}),
{
// 20 summaries / min / user — much higher than chat because they're cheap.
limit: 20,
windowMs: 60_000,
keyFn: (_req, extra) => {
const userEmail = (extra as { user?: { email?: string } })?.user?.email;
return `context-summarize:${userEmail ?? "anon"}`;
},
},
);

View File

@@ -1,51 +0,0 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { renderDesignSystemShowcase } from "@/lib/scaffold/open-design/design-system-showcase";
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
if (!id || id.includes("..") || id.includes("/")) {
return new NextResponse("Invalid design system ID", { status: 400 });
}
const systemPath = path.join(
process.cwd(),
"lib",
"scaffold",
"open-design",
"design-systems",
id
);
const mdPath = path.join(systemPath, "DESIGN.md");
try {
if (!fs.existsSync(systemPath)) {
return new NextResponse(`Design system not found for ${id}`, { status: 404 });
}
if (!fs.existsSync(mdPath)) {
return new NextResponse(`DESIGN.md not found for ${id}`, { status: 404 });
}
const rawMarkdown = await fs.promises.readFile(mdPath, "utf-8");
const html = renderDesignSystemShowcase(id, rawMarkdown);
return new NextResponse(html, {
status: 200,
headers: {
"Content-Type": "text/html; charset=utf-8",
"X-Frame-Options": "SAMEORIGIN",
},
});
} catch (err: any) {
return new NextResponse(`Error loading showcase: ${err.message}`, {
status: 500,
});
}
}

View File

@@ -1,137 +0,0 @@
import { NextResponse } from 'next/server';
import { getAdminAuth, getAdminDb } from '@/lib/firebase/admin';
/**
* Endpoint for the browser extension to link itself to a Vibn project.
* Extension sends: workspace path + Vibn project ID
* Backend stores: mapping for future requests
*/
export async function POST(request: Request) {
try {
// Verify auth
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const idToken = authHeader.split('Bearer ')[1];
const adminAuth = getAdminAuth();
let userId: string;
try {
const decodedToken = await adminAuth.verifyIdToken(idToken);
userId = decodedToken.uid;
} catch (error) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
const body = await request.json();
const { projectId, workspacePath } = body;
if (!projectId || !workspacePath) {
return NextResponse.json(
{ error: 'Missing projectId or workspacePath' },
{ status: 400 }
);
}
const adminDb = getAdminDb();
// Verify project exists and user has access
const projectSnap = await adminDb.collection('projects').doc(projectId).get();
if (!projectSnap.exists) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}
const projectData = projectSnap.data();
if (projectData?.userId !== userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
}
// Store the workspace → project mapping
await adminDb
.collection('extensionWorkspaceLinks')
.doc(workspacePath)
.set({
projectId,
userId,
workspacePath,
linkedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}, { merge: true });
// Also update project metadata to indicate extension is linked
await adminDb.collection('projects').doc(projectId).set(
{
extensionLinked: true,
extensionLinkedAt: new Date().toISOString(),
},
{ merge: true }
);
console.log(`[Extension] Linked workspace "${workspacePath}" to project ${projectId}`);
return NextResponse.json({
success: true,
message: 'Extension linked to project',
projectId,
workspacePath,
});
} catch (error) {
console.error('[Extension] Link error:', error);
return NextResponse.json(
{
error: 'Failed to link extension',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}
/**
* Get the Vibn project ID for a given workspace path
*/
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const workspacePath = searchParams.get('workspacePath');
if (!workspacePath) {
return NextResponse.json(
{ error: 'Missing workspacePath query param' },
{ status: 400 }
);
}
const adminDb = getAdminDb();
const linkSnap = await adminDb
.collection('extensionWorkspaceLinks')
.doc(workspacePath)
.get();
if (!linkSnap.exists) {
return NextResponse.json(
{ error: 'No project linked for this workspace' },
{ status: 404 }
);
}
const linkData = linkSnap.data();
return NextResponse.json({
projectId: linkData?.projectId,
linkedAt: linkData?.linkedAt,
});
} catch (error) {
console.error('[Extension] Get link error:', error);
return NextResponse.json(
{
error: 'Failed to get link',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}

View File

@@ -1,99 +0,0 @@
import { NextResponse } from 'next/server';
import { getAdminAuth, getAdminDb } from '@/lib/firebase/admin';
/**
* Fetch file content from GitHub
* GET /api/github/file-content?owner=X&repo=Y&path=Z
*/
export async function GET(request: Request) {
try {
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const idToken = authHeader.split('Bearer ')[1];
const adminAuth = getAdminAuth();
const adminDb = getAdminDb();
let userId: string;
try {
const decodedToken = await adminAuth.verifyIdToken(idToken);
userId = decodedToken.uid;
} catch (error) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
const url = new URL(request.url);
const owner = url.searchParams.get('owner');
const repo = url.searchParams.get('repo');
const path = url.searchParams.get('path');
const branch = url.searchParams.get('branch') || 'main';
if (!owner || !repo || !path) {
return NextResponse.json(
{ error: 'Missing owner, repo, or path' },
{ status: 400 }
);
}
// Get GitHub connection
const connectionDoc = await adminDb
.collection('githubConnections')
.doc(userId)
.get();
if (!connectionDoc.exists) {
return NextResponse.json(
{ error: 'GitHub not connected' },
{ status: 404 }
);
}
const connection = connectionDoc.data()!;
const accessToken = connection.accessToken; // TODO: Decrypt
// Fetch file content from GitHub API
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}?ref=${branch}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/vnd.github.v3+json',
},
}
);
if (!response.ok) {
if (response.status === 404) {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
}
const error = await response.json();
throw new Error(`GitHub API error: ${error.message || response.statusText}`);
}
const data = await response.json();
// GitHub returns base64-encoded content
const content = Buffer.from(data.content, 'base64').toString('utf-8');
return NextResponse.json({
path: data.path,
name: data.name,
size: data.size,
sha: data.sha,
content,
encoding: 'utf-8',
});
} catch (error) {
console.error('[GitHub File Content] Error:', error);
return NextResponse.json(
{
error: 'Failed to fetch file content',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}

View File

@@ -1,140 +0,0 @@
'use client';
import { useEffect, useState, Suspense } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { auth } from '@/lib/firebase/config';
import { exchangeCodeForToken, getGitHubUser } from '@/lib/github/oauth';
import { Loader2, CheckCircle2, XCircle } from 'lucide-react';
function GitHubCallbackContent() {
const searchParams = useSearchParams();
const router = useRouter();
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function handleCallback() {
try {
const code = searchParams.get('code');
const state = searchParams.get('state');
const error = searchParams.get('error');
if (error) {
throw new Error(`GitHub OAuth error: ${error}`);
}
if (!code) {
throw new Error('No authorization code received');
}
// Verify state (CSRF protection)
const storedState = sessionStorage.getItem('github_oauth_state');
if (state !== storedState) {
throw new Error('Invalid state parameter');
}
sessionStorage.removeItem('github_oauth_state');
// Exchange code for token
const tokenData = await exchangeCodeForToken(code);
// Get GitHub user info
const githubUser = await getGitHubUser(tokenData.access_token);
// Store connection in Firebase
const user = auth.currentUser;
if (!user) {
throw new Error('User not authenticated');
}
const idToken = await user.getIdToken();
const response = await fetch('/api/github/connect', {
method: 'POST',
headers: {
'Authorization': `Bearer ${idToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
accessToken: tokenData.access_token,
githubUser,
}),
});
if (!response.ok) {
throw new Error('Failed to store GitHub connection');
}
setStatus('success');
// Redirect back to connections page after 2 seconds
setTimeout(() => {
const workspace = user.displayName || 'workspace';
router.push(`/${workspace}/connections`);
}, 2000);
} catch (err: any) {
console.error('[GitHub Callback] Error:', err);
setError(err.message);
setStatus('error');
}
}
handleCallback();
}, [searchParams, router]);
return (
<div className="flex min-h-screen items-center justify-center bg-background p-6">
<div className="w-full max-w-md space-y-6 text-center">
{status === 'loading' && (
<>
<Loader2 className="mx-auto h-12 w-12 animate-spin text-primary" />
<h1 className="text-2xl font-bold">Connecting to GitHub...</h1>
<p className="text-muted-foreground">
Please wait while we complete the connection.
</p>
</>
)}
{status === 'success' && (
<>
<CheckCircle2 className="mx-auto h-12 w-12 text-green-500" />
<h1 className="text-2xl font-bold">Successfully Connected!</h1>
<p className="text-muted-foreground">
Your GitHub account has been connected. Redirecting...
</p>
</>
)}
{status === 'error' && (
<>
<XCircle className="mx-auto h-12 w-12 text-red-500" />
<h1 className="text-2xl font-bold">Connection Failed</h1>
<p className="text-muted-foreground">{error}</p>
<button
onClick={() => router.push('/connections')}
className="mt-4 rounded-lg bg-primary px-6 py-2 text-white hover:bg-primary/90"
>
Back to Connections
</button>
</>
)}
</div>
</div>
);
}
export default function GitHubCallbackPage() {
return (
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center bg-background p-6">
<div className="w-full max-w-md space-y-6 text-center">
<Loader2 className="mx-auto h-12 w-12 animate-spin text-primary" />
<h1 className="text-2xl font-bold">Loading...</h1>
</div>
</div>
}
>
<GitHubCallbackContent />
</Suspense>
);
}

View File

@@ -1,149 +0,0 @@
import { NextResponse } from 'next/server';
import { getAdminAuth, getAdminDb } from '@/lib/firebase/admin';
/**
* Fetch repository file tree from GitHub
* GET /api/github/repo-tree?owner=X&repo=Y
*/
export async function GET(request: Request) {
try {
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const idToken = authHeader.split('Bearer ')[1];
const adminAuth = getAdminAuth();
const adminDb = getAdminDb();
let userId: string;
try {
const decodedToken = await adminAuth.verifyIdToken(idToken);
userId = decodedToken.uid;
} catch (error) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
const url = new URL(request.url);
const owner = url.searchParams.get('owner');
const repo = url.searchParams.get('repo');
const branch = url.searchParams.get('branch') || 'main';
if (!owner || !repo) {
return NextResponse.json({ error: 'Missing owner or repo' }, { status: 400 });
}
// Get GitHub connection
const connectionDoc = await adminDb
.collection('githubConnections')
.doc(userId)
.get();
if (!connectionDoc.exists) {
return NextResponse.json(
{ error: 'GitHub not connected' },
{ status: 404 }
);
}
const connection = connectionDoc.data()!;
const accessToken = connection.accessToken; // TODO: Decrypt
// Fetch repository tree from GitHub API (recursive)
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/vnd.github.v3+json',
},
}
);
if (!response.ok) {
const error = await response.json();
throw new Error(`GitHub API error: ${error.message || response.statusText}`);
}
const data = await response.json();
// Filter to only include files (not directories)
// and exclude common non-code files
const excludePatterns = [
/node_modules\//,
/\.git\//,
/dist\//,
/build\//,
/\.next\//,
/coverage\//,
/\.cache\//,
/\.env/,
/package-lock\.json$/,
/yarn\.lock$/,
/pnpm-lock\.yaml$/,
/\.png$/,
/\.jpg$/,
/\.jpeg$/,
/\.gif$/,
/\.svg$/,
/\.ico$/,
/\.woff$/,
/\.woff2$/,
/\.ttf$/,
/\.eot$/,
/\.min\.js$/,
/\.min\.css$/,
/\.map$/,
];
// Include common code file extensions
const includePatterns = [
/\.(ts|tsx|js|jsx|py|java|go|rs|cpp|c|h|cs|rb|php|swift|kt)$/,
/\.(json|yaml|yml|toml|xml)$/,
/\.(md|txt)$/,
/\.(sql|graphql|proto)$/,
/\.(css|scss|sass|less)$/,
/\.(html|htm)$/,
/Dockerfile$/,
/Makefile$/,
/README$/,
];
const files = data.tree
.filter((item: any) => item.type === 'blob')
.filter((item: any) => {
// Exclude patterns
if (excludePatterns.some(pattern => pattern.test(item.path))) {
return false;
}
// Include patterns
return includePatterns.some(pattern => pattern.test(item.path));
})
.map((item: any) => ({
path: item.path,
sha: item.sha,
size: item.size,
url: item.url,
}));
console.log(`[GitHub Tree] Found ${files.length} code files in ${owner}/${repo}`);
return NextResponse.json({
owner,
repo,
branch,
totalFiles: files.length,
files,
});
} catch (error) {
console.error('[GitHub Tree] Error:', error);
return NextResponse.json(
{
error: 'Failed to fetch repository tree',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}

View File

@@ -1,79 +0,0 @@
import { NextResponse } from 'next/server';
import { getAdminAuth, getAdminDb } from '@/lib/firebase/admin';
/**
* Fetch user's GitHub repositories
*/
export async function GET(request: Request) {
try {
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const idToken = authHeader.split('Bearer ')[1];
const adminAuth = getAdminAuth();
const adminDb = getAdminDb();
let userId: string;
try {
const decodedToken = await adminAuth.verifyIdToken(idToken);
userId = decodedToken.uid;
} catch (error) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
// Get GitHub connection
const connectionDoc = await adminDb
.collection('githubConnections')
.doc(userId)
.get();
if (!connectionDoc.exists) {
return NextResponse.json(
{ error: 'GitHub not connected' },
{ status: 404 }
);
}
const connection = connectionDoc.data()!;
const accessToken = connection.accessToken; // TODO: Decrypt
// Fetch repos from GitHub API
const response = await fetch('https://api.github.com/user/repos?sort=updated&per_page=100', {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/vnd.github.v3+json',
},
});
if (!response.ok) {
throw new Error('Failed to fetch repositories from GitHub');
}
const repos = await response.json();
// Return simplified repo data
return NextResponse.json(
repos.map((repo: any) => ({
id: repo.id,
name: repo.name,
full_name: repo.full_name,
description: repo.description,
html_url: repo.html_url,
language: repo.language,
default_branch: repo.default_branch,
private: repo.private,
topics: repo.topics || [],
updated_at: repo.updated_at,
}))
);
} catch (error) {
console.error('[GitHub Repos] Error:', error);
return NextResponse.json(
{ error: 'Failed to fetch repositories' },
{ status: 500 }
);
}
}

View File

@@ -1,53 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import {
isCoolifyInfraOperational,
runCoolifyInfraHealthProbe,
} from "@/lib/server/infra-coolify-health";
import { timingSafeStringEq } from "@/lib/server/timing-safe";
/**
* Authenticated infrastructure probe for Coolify API + SSH→Docker.
*
* Configure on deploy:
* INFRA_HEALTH_SECRET — required; pass as Authorization: Bearer <secret>
*
* Use from Cron / UptimeRobot / Better Stack:
* curl -sf -H "Authorization: Bearer $INFRA_HEALTH_SECRET" \
* https://<your-app>/api/internal/infra-health
*
* Returns 200 only when Coolify API and SSH+docker on the host are OK.
*/
export async function GET(req: NextRequest) {
const secret = process.env.INFRA_HEALTH_SECRET?.trim();
if (!secret) {
return NextResponse.json(
{
ok: false,
error:
"INFRA_HEALTH_SECRET is not set — configure it on vibn-frontend to enable this probe.",
},
{ status: 503 },
);
}
const auth = req.headers.get("authorization");
const bearer = auth?.startsWith("Bearer ") ? auth.slice(7).trim() : "";
const headerSecret = req.headers.get("x-vibn-infra-secret")?.trim() ?? "";
const token = bearer || headerSecret;
if (!token || !timingSafeStringEq(secret, token)) {
return NextResponse.json(
{ ok: false, error: "Unauthorized" },
{ status: 401 },
);
}
try {
const report = await runCoolifyInfraHealthProbe();
const ok = isCoolifyInfraOperational(report);
return NextResponse.json({ ok, report }, { status: ok ? 200 : 503 });
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return NextResponse.json({ ok: false, error: message }, { status: 503 });
}
}

View File

@@ -1,85 +0,0 @@
/**
* GET /api/invites/[token] — validate (public, used by /auth page)
* POST /api/invites/[token]/redeem — consume (called on sign-up completion)
*/
import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db-postgres";
import { authSession } from "@/lib/auth/session-server";
interface InviteRow {
token: string;
email: string | null;
max_uses: number;
use_count: number;
expires_at: string | null;
redeemed_by: string[];
}
async function validateInvite(
token: string,
): Promise<{ valid: boolean; reason?: string; row?: InviteRow }> {
const row = await queryOne<InviteRow>(
`SELECT token, email, max_uses, use_count, expires_at, redeemed_by
FROM invites WHERE token = $1`,
[token],
);
if (!row) return { valid: false, reason: "Token not found" };
if (row.use_count >= row.max_uses)
return { valid: false, reason: "Token already fully redeemed" };
if (row.expires_at && new Date(row.expires_at) < new Date())
return { valid: false, reason: "Token expired" };
return { valid: true, row };
}
/** GET /api/invites/[token] — check if a token is valid (used by auth page UI) */
export async function GET(
_req: Request,
{ params }: { params: Promise<{ token: string }> },
) {
const { token } = await params;
const { valid, reason, row } = await validateInvite(token);
if (!valid) {
return NextResponse.json({ valid: false, reason }, { status: 400 });
}
return NextResponse.json({
valid: true,
email: row!.email ?? null,
usesRemaining: row!.max_uses - row!.use_count,
});
}
/** POST /api/invites/[token] — redeem (call after a user signs up) */
export async function POST(
_req: Request,
{ params }: { params: Promise<{ token: string }> },
) {
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { token } = await params;
const { valid, reason, row } = await validateInvite(token);
if (!valid) {
return NextResponse.json({ ok: false, reason }, { status: 400 });
}
const email = session.user.email;
if (row!.email && row!.email !== email) {
return NextResponse.json(
{ ok: false, reason: "Token is for a different email address" },
{ status: 403 },
);
}
if (row!.redeemed_by.includes(email)) {
return NextResponse.json(
{ ok: true, alreadyRedeemed: true },
);
}
await query(
`UPDATE invites
SET use_count = use_count + 1,
redeemed_by = array_append(redeemed_by, $2)
WHERE token = $1`,
[token, email],
);
return NextResponse.json({ ok: true, redeemed: true });
}

View File

@@ -1,90 +0,0 @@
/**
* POST /api/invites
*
* Admin-only. Creates an invite token and returns the invite URL.
* Closes BETA_LAUNCH_PLAN P4.8.
*
* Body: { email?: string, note?: string, maxUses?: number }
* Auth: Bearer ADMIN_MIGRATE_SECRET
*
* curl -X POST https://vibnai.com/api/invites \
* -H "x-admin-secret: $ADMIN_MIGRATE_SECRET" \
* -d '{"email":"friend@example.com","note":"beta tester"}'
*/
import { NextResponse } from "next/server";
import { query } from "@/lib/db-postgres";
import { withAdminSecret } from "@/lib/server/api-handler";
import { randomBytes } from "crypto";
let tableReady = false;
async function ensureTable() {
if (tableReady) return;
await query(`
CREATE TABLE IF NOT EXISTS invites (
token TEXT PRIMARY KEY,
email TEXT,
note TEXT,
max_uses INT NOT NULL DEFAULT 1,
use_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ,
redeemed_by TEXT[] NOT NULL DEFAULT '{}'
)
`);
await query(`CREATE INDEX IF NOT EXISTS invites_email_idx ON invites (email) WHERE email IS NOT NULL`);
tableReady = true;
}
export const POST = withAdminSecret(
async (request) => {
await ensureTable();
const body = await request.json().catch(() => ({})) as {
email?: string;
note?: string;
maxUses?: number;
expiresInDays?: number;
};
const token = randomBytes(20).toString("hex");
const maxUses = Math.max(1, Math.min(100, body.maxUses ?? 1));
const expiresAt = body.expiresInDays
? new Date(Date.now() + body.expiresInDays * 86_400_000).toISOString()
: null;
await query(
`INSERT INTO invites (token, email, note, max_uses, expires_at)
VALUES ($1, $2, $3, $4, $5)`,
[token, body.email ?? null, body.note ?? null, maxUses, expiresAt],
);
const baseUrl =
process.env.NEXT_PUBLIC_APP_URL ||
process.env.NEXTAUTH_URL ||
"https://vibnai.com";
return NextResponse.json({
ok: true,
token,
inviteUrl: `${baseUrl}/auth?invite=${token}`,
email: body.email ?? null,
maxUses,
expiresAt,
});
},
{ secretEnvVar: "ADMIN_MIGRATE_SECRET", altHeader: "x-admin-secret" },
);
export const GET = withAdminSecret(
async (request) => {
await ensureTable();
const { searchParams } = new URL(request.url);
const limit = Math.min(200, parseInt(searchParams.get("limit") ?? "50", 10));
const rows = await query(
`SELECT token, email, note, max_uses, use_count, created_at, expires_at, redeemed_by
FROM invites ORDER BY created_at DESC LIMIT $1`,
[limit],
);
return NextResponse.json({ invites: rows });
},
{ secretEnvVar: "ADMIN_MIGRATE_SECRET", altHeader: "x-admin-secret" },
);

View File

@@ -1,84 +0,0 @@
/**
* Internal API to get decrypted key value
* This endpoint is used by Vibn internally, not exposed to frontend
*/
import { NextResponse } from 'next/server';
import { getAdminAuth, getAdminDb } from '@/lib/firebase/admin';
import { FieldValue } from 'firebase-admin/firestore';
import * as crypto from 'crypto';
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || 'vibn-default-encryption-key-change-me!!';
const ALGORITHM = 'aes-256-cbc';
function decrypt(encrypted: string, ivHex: string): string {
const key = crypto.createHash('sha256').update(ENCRYPTION_KEY).digest();
const iv = Buffer.from(ivHex, 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
export async function POST(request: Request) {
try {
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const idToken = authHeader.split('Bearer ')[1];
const adminAuth = getAdminAuth();
const adminDb = getAdminDb();
let userId: string;
try {
const decodedToken = await adminAuth.verifyIdToken(idToken);
userId = decodedToken.uid;
} catch (error) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
const { service } = await request.json();
if (!service) {
return NextResponse.json({ error: 'Service is required' }, { status: 400 });
}
// Get the key
const keysSnapshot = await adminDb
.collection('userKeys')
.where('userId', '==', userId)
.where('service', '==', service)
.limit(1)
.get();
if (keysSnapshot.empty) {
return NextResponse.json({ error: 'Key not found', hasKey: false }, { status: 404 });
}
const keyDoc = keysSnapshot.docs[0];
const keyData = keyDoc.data();
// Decrypt the key
const decryptedKey = decrypt(keyData.encryptedKey, keyData.iv);
// Update last used timestamp
await keyDoc.ref.update({
lastUsed: FieldValue.serverTimestamp(),
});
return NextResponse.json({
hasKey: true,
keyValue: decryptedKey,
service: keyData.service,
});
} catch (error) {
console.error('Error getting key:', error);
return NextResponse.json(
{ error: 'Failed to get key', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,214 +0,0 @@
/**
* Manage user's third-party API keys (OpenAI, GitHub, etc.)
*/
import { NextResponse } from 'next/server';
import { getAdminAuth, getAdminDb } from '@/lib/firebase/admin';
import { FieldValue } from 'firebase-admin/firestore';
import * as crypto from 'crypto';
// Encryption helpers
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || 'vibn-default-encryption-key-change-me!!';
const ALGORITHM = 'aes-256-cbc';
function encrypt(text: string): { encrypted: string; iv: string } {
const key = crypto.createHash('sha256').update(ENCRYPTION_KEY).digest();
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
return { encrypted, iv: iv.toString('hex') };
}
function decrypt(encrypted: string, ivHex: string): string {
const key = crypto.createHash('sha256').update(ENCRYPTION_KEY).digest();
const iv = Buffer.from(ivHex, 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
// GET - List all keys (metadata only, not actual values)
export async function GET(request: Request) {
try {
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const idToken = authHeader.split('Bearer ')[1];
const adminAuth = getAdminAuth();
const adminDb = getAdminDb();
let userId: string;
try {
const decodedToken = await adminAuth.verifyIdToken(idToken);
userId = decodedToken.uid;
} catch (error) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
const keysSnapshot = await adminDb
.collection('userKeys')
.where('userId', '==', userId)
.get();
const keys = keysSnapshot.docs.map(doc => {
const data = doc.data();
return {
id: doc.id,
service: data.service,
name: data.name,
createdAt: data.createdAt,
lastUsed: data.lastUsed,
// Don't send the actual key
};
});
return NextResponse.json({ keys });
} catch (error) {
console.error('Error fetching keys:', error);
return NextResponse.json(
{ error: 'Failed to fetch keys', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}
// POST - Add or update a key
export async function POST(request: Request) {
try {
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const idToken = authHeader.split('Bearer ')[1];
const adminAuth = getAdminAuth();
const adminDb = getAdminDb();
let userId: string;
try {
const decodedToken = await adminAuth.verifyIdToken(idToken);
userId = decodedToken.uid;
} catch (error) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
const { service, name, keyValue } = await request.json();
if (!service || !keyValue) {
return NextResponse.json({ error: 'Service and key value are required' }, { status: 400 });
}
// Encrypt the key
const { encrypted, iv } = encrypt(keyValue);
// Check if key already exists for this service
const existingKeysSnapshot = await adminDb
.collection('userKeys')
.where('userId', '==', userId)
.where('service', '==', service)
.limit(1)
.get();
if (!existingKeysSnapshot.empty) {
// Update existing key
const keyDoc = existingKeysSnapshot.docs[0];
await keyDoc.ref.update({
name: name || service,
encryptedKey: encrypted,
iv,
updatedAt: FieldValue.serverTimestamp(),
});
return NextResponse.json({
success: true,
message: `${service} key updated`,
id: keyDoc.id,
});
} else {
// Create new key
const keyRef = await adminDb.collection('userKeys').add({
userId,
service,
name: name || service,
encryptedKey: encrypted,
iv,
createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
lastUsed: null,
});
return NextResponse.json({
success: true,
message: `${service} key added`,
id: keyRef.id,
});
}
} catch (error) {
console.error('Error saving key:', error);
return NextResponse.json(
{ error: 'Failed to save key', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}
// DELETE - Remove a key
export async function DELETE(request: Request) {
try {
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const idToken = authHeader.split('Bearer ')[1];
const adminAuth = getAdminAuth();
const adminDb = getAdminDb();
let userId: string;
try {
const decodedToken = await adminAuth.verifyIdToken(idToken);
userId = decodedToken.uid;
} catch (error) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
const { service } = await request.json();
if (!service) {
return NextResponse.json({ error: 'Service is required' }, { status: 400 });
}
// Find and delete the key
const keysSnapshot = await adminDb
.collection('userKeys')
.where('userId', '==', userId)
.where('service', '==', service)
.get();
if (keysSnapshot.empty) {
return NextResponse.json({ error: 'Key not found' }, { status: 404 });
}
const batch = adminDb.batch();
keysSnapshot.docs.forEach(doc => {
batch.delete(doc.ref);
});
await batch.commit();
return NextResponse.json({
success: true,
message: `${service} key deleted`,
});
} catch (error) {
console.error('Error deleting key:', error);
return NextResponse.json(
{ error: 'Failed to delete key', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,126 +0,0 @@
/**
* Generate a long-lived MCP API key for ChatGPT integration
*/
import { NextResponse } from 'next/server';
import { getAdminAuth, getAdminDb } from '@/lib/firebase/admin';
import { randomBytes } from 'crypto';
export async function POST(request: Request) {
try {
// Authenticate user
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const idToken = authHeader.split('Bearer ')[1];
const adminAuth = getAdminAuth();
const adminDb = getAdminDb();
let userId: string;
try {
const decodedToken = await adminAuth.verifyIdToken(idToken);
userId = decodedToken.uid;
} catch (error) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
// Check if user already has an MCP key
const mcpKeysRef = adminDb.collection('mcpKeys');
const existingKey = await mcpKeysRef
.where('userId', '==', userId)
.limit(1)
.get();
if (!existingKey.empty) {
// Return existing key
const keyDoc = existingKey.docs[0];
const keyData = keyDoc.data();
return NextResponse.json({
apiKey: keyData.key,
createdAt: keyData.createdAt,
message: 'Using existing MCP API key',
});
}
// Generate new API key
const apiKey = `vibn_mcp_${randomBytes(32).toString('hex')}`;
// Store in Firestore
await mcpKeysRef.add({
userId,
key: apiKey,
type: 'mcp',
createdAt: new Date().toISOString(),
lastUsed: null,
});
return NextResponse.json({
apiKey,
createdAt: new Date().toISOString(),
message: 'MCP API key generated successfully',
});
} catch (error) {
console.error('Error generating MCP key:', error);
return NextResponse.json(
{
error: 'Failed to generate MCP key',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}
// DELETE endpoint to revoke MCP key
export async function DELETE(request: Request) {
try {
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const idToken = authHeader.split('Bearer ')[1];
const adminAuth = getAdminAuth();
const adminDb = getAdminDb();
let userId: string;
try {
const decodedToken = await adminAuth.verifyIdToken(idToken);
userId = decodedToken.uid;
} catch (error) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
// Delete user's MCP key
const mcpKeysRef = adminDb.collection('mcpKeys');
const existingKey = await mcpKeysRef
.where('userId', '==', userId)
.get();
if (existingKey.empty) {
return NextResponse.json({ message: 'No MCP key to delete' });
}
// Delete all keys for this user
const batch = adminDb.batch();
existingKey.docs.forEach(doc => {
batch.delete(doc.ref);
});
await batch.commit();
return NextResponse.json({ message: 'MCP key deleted successfully' });
} catch (error) {
console.error('Error deleting MCP key:', error);
return NextResponse.json(
{
error: 'Failed to delete MCP key',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}

View File

@@ -296,12 +296,15 @@ export async function POST(request: Request) {
return await toolProjectsGet(principal, params);
case "project.recent_errors":
case "project.recent.errors":
return await toolProjectRecentErrors(principal, params);
case "project.error_detail":
case "project.error.detail":
return await toolProjectErrorDetail(principal, params);
case "project.error_resolve":
case "project.error.resolve":
return await toolProjectErrorResolve(principal, params);
case "apps.list":
@@ -330,6 +333,7 @@ export async function POST(request: Request) {
case "apps.update":
return await toolAppsUpdate(principal, params);
case "apps.rewire_git":
case "apps.rewire.git":
return await toolAppsRewireGit(principal, params);
case "apps.delete":
return await toolAppsDelete(principal, params);
@@ -391,7 +395,9 @@ export async function POST(request: Request) {
return await toolStorageDescribe(principal);
case "storage.provision":
return await toolStorageProvision(principal);
case "storage.inject_env":
case "storage.inject.env":
return await toolStorageInjectEnv(principal, params);
case "gitea.repos.list":
@@ -2110,13 +2116,17 @@ async function toolAppsCreate(
created = await createPrivateDeployKeyApp({
...commonOpts,
privateKeyUuid,
gitRepository: `ssh://git@git.vibnai.com:222/${repoOrg}/${repoName}.git`,
gitRepository: `git@git.vibnai.com:222/${repoOrg}/${repoName}.git`,
gitBranch: String(params.branch ?? repo.default_branch ?? "main"),
portsExposes: String(params.ports ?? "3000"),
buildPack: (params.buildPack as any) ?? "nixpacks",
name: appName,
domains: toDomainsString([fqdn]),
isAutoDeployEnabled: true,
isAutoDeployEnabled: true,
instantDeploy: false,
installCommand: params.installCommand ? String(params.installCommand) : undefined,
buildCommand: params.buildCommand ? String(params.buildCommand) : undefined,
startCommand: params.startCommand ? String(params.startCommand) : undefined,
dockerComposeLocation: params.dockerComposeLocation
? String(params.dockerComposeLocation)
: undefined,
@@ -2143,7 +2153,11 @@ async function toolAppsCreate(
buildPack: (params.buildPack as any) ?? "nixpacks",
name: appName,
domains: toDomainsString([fqdn]),
isAutoDeployEnabled: true,
isAutoDeployEnabled: true,
instantDeploy: false,
installCommand: params.installCommand ? String(params.installCommand) : undefined,
buildCommand: params.buildCommand ? String(params.buildCommand) : undefined,
startCommand: params.startCommand ? String(params.startCommand) : undefined,
dockerComposeLocation: params.dockerComposeLocation
? String(params.dockerComposeLocation)
: undefined,
@@ -2635,7 +2649,7 @@ function resolveFqdn(
: workspaceAppFqdn(slug, appName);
if (!isDomainUnderWorkspace(fqdn, slug)) {
return NextResponse.json(
{ error: `Domain ${fqdn} must end with .${slug}.vibnai.com` },
{ error: `Domain ${fqdn} must end with -${slug}.vibnai.com` },
{ status: 403 },
);
}
@@ -2699,6 +2713,8 @@ async function applyEnvsAndDeploy(
if (params.instantDeploy === false) return null;
try {
// Give Coolify 1.5s to settle the DB inserts and attach the deploy key before hitting /deploy
await new Promise((r) => setTimeout(r, 1500));
const dep = await deployApplication(appUuid);
return dep.deployment_uuid ?? null;
} catch (e) {
@@ -2990,7 +3006,7 @@ async function toolAppsDomainsSet(
.toLowerCase();
if (!isDomainUnderWorkspace(clean, ws.slug)) {
return NextResponse.json(
{ error: `Domain ${clean} must end with .${ws.slug}.vibnai.com` },
{ error: `Domain ${clean} must end with -${ws.slug}.vibnai.com` },
{ status: 403 },
);
}

View File

@@ -1,72 +0,0 @@
/**
* Retrieve ChatGPT GPT information
* Note: This is limited by what's available via OpenAI API
* GPTs are primarily accessible through the ChatGPT web interface
*/
import { NextResponse } from 'next/server';
import { getAdminAuth } from '@/lib/firebase/admin';
export async function POST(request: Request) {
try {
// Authenticate user
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const idToken = authHeader.split('Bearer ')[1];
const adminAuth = getAdminAuth();
let userId: string;
try {
const decodedToken = await adminAuth.verifyIdToken(idToken);
userId = decodedToken.uid;
} catch (error) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
const { gptUrl } = await request.json();
if (!gptUrl) {
return NextResponse.json({ error: 'GPT URL is required' }, { status: 400 });
}
// Extract GPT ID from URL
// Format: https://chatgpt.com/g/g-p-[id]-[name]/project
const gptMatch = gptUrl.match(/\/g\/(g-p-[a-zA-Z0-9]+)/);
if (!gptMatch) {
return NextResponse.json({ error: 'Invalid GPT URL format' }, { status: 400 });
}
const gptId = gptMatch[1];
const nameMatch = gptUrl.match(/g-p-[a-zA-Z0-9]+-([^\/]+)/);
const gptName = nameMatch ? nameMatch[1].replace(/-/g, ' ') : 'Unknown GPT';
console.log(`[ChatGPT GPT] Extracted ID: ${gptId}, Name: ${gptName}`);
// Note: OpenAI API doesn't currently expose GPTs directly
// We'll store the reference for now and display in the UI
return NextResponse.json({
success: true,
data: {
id: gptId,
name: gptName,
url: gptUrl,
type: 'chatgpt-gpt',
message: 'GPT reference saved. To capture conversations with this GPT, import individual chat sessions.',
},
});
} catch (error) {
console.error('[ChatGPT GPT] Error:', error);
return NextResponse.json(
{
error: 'Failed to process GPT',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}

View File

@@ -1,82 +0,0 @@
/**
* Retrieve OpenAI Projects using the Projects API
* https://platform.openai.com/docs/api-reference/projects
*/
import { NextResponse } from 'next/server';
import { getAdminAuth } from '@/lib/firebase/admin';
const OPENAI_API_URL = 'https://api.openai.com/v1/projects';
export async function POST(request: Request) {
try {
// Authenticate user
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const idToken = authHeader.split('Bearer ')[1];
const adminAuth = getAdminAuth();
let userId: string;
try {
const decodedToken = await adminAuth.verifyIdToken(idToken);
userId = decodedToken.uid;
} catch (error) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
const { openaiApiKey, projectId } = await request.json();
if (!openaiApiKey) {
return NextResponse.json({ error: 'OpenAI API key is required' }, { status: 400 });
}
// If projectId provided, fetch specific project
const url = projectId
? `${OPENAI_API_URL}/${projectId}`
: OPENAI_API_URL; // List all projects
console.log(`[OpenAI Projects] Fetching: ${url}`);
const openaiResponse = await fetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${openaiApiKey}`,
'Content-Type': 'application/json',
},
});
if (!openaiResponse.ok) {
const errorText = await openaiResponse.text();
console.error('[OpenAI Projects] API error:', openaiResponse.status, errorText);
return NextResponse.json(
{
error: 'Failed to fetch from OpenAI',
details: errorText,
status: openaiResponse.status,
},
{ status: openaiResponse.status }
);
}
const projectData = await openaiResponse.json();
console.log('[OpenAI Projects] Data fetched successfully');
return NextResponse.json({
success: true,
data: projectData,
});
} catch (error) {
console.error('[OpenAI Projects] Error:', error);
return NextResponse.json(
{
error: 'Failed to fetch OpenAI project',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}

View File

@@ -1,122 +0,0 @@
/**
* GET /api/preview/embed?u=<encoded https URL>
*
* Experimental: authenticated HTML proxy that injects vibn-preview-bridge.js + `<base>`.
* Serving tunnel markup under the Vibn origin breaks Next.js and similar SPAs
* (History/replaceState + asset CORS). Use direct iframe tunnel URLs by default;
* enable via NEXT_PUBLIC_USE_PREVIEW_EMBED_PROXY only for simple static previews.
*/
import { NextResponse } from "next/server";
import { authSession } from "@/lib/auth/session-server";
import { serverPreviewHostnameAllowed } from "@/lib/preview-embed-allowlist";
export const runtime = "nodejs";
const MAX_HTML_CHARS = 2_500_000;
function escapeHtmlAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
}
function requestOrigin(req: Request): string {
const self = new URL(req.url);
const xfHost = req.headers.get("x-forwarded-host");
const xfProto = req.headers.get("x-forwarded-proto");
if (xfHost) {
return `${xfProto ?? "https"}://${xfHost.split(",")[0]?.trim() ?? xfHost}`;
}
return self.origin;
}
function directoryBasePath(pathname: string): string {
if (pathname.endsWith("/")) return pathname || "/";
const i = pathname.lastIndexOf("/");
if (i <= 0) return "/";
return pathname.slice(0, i + 1);
}
/**
* GET /api/preview/embed?u=<encoded https URL>
* Authenticated HTML proxy that injects vibn-preview-bridge.js + <base> so element-pick works same-origin.
*/
export async function GET(req: Request) {
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let target: URL;
try {
const { searchParams } = new URL(req.url);
const raw = searchParams.get("u");
if (!raw?.trim()) {
return NextResponse.json({ error: "Missing u" }, { status: 400 });
}
target = new URL(raw);
} catch {
return NextResponse.json({ error: "Invalid URL" }, { status: 400 });
}
if (!serverPreviewHostnameAllowed(target.hostname, target.protocol)) {
return NextResponse.json({ error: "Host not allowed for preview embed" }, { status: 403 });
}
let res: Response;
try {
res = await fetch(target.toString(), {
redirect: "follow",
signal: AbortSignal.timeout(25_000),
headers: {
Accept: "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8",
"User-Agent": "VibnPreviewEmbed/1.0",
},
});
} catch {
return NextResponse.json({ error: "Failed to fetch preview" }, { status: 502 });
}
const finalUrl = new URL(res.url);
if (!serverPreviewHostnameAllowed(finalUrl.hostname, finalUrl.protocol)) {
return NextResponse.json({ error: "Redirect led to disallowed host" }, { status: 403 });
}
const ct = res.headers.get("content-type") ?? "";
if (!/html/i.test(ct)) {
return NextResponse.json({ error: "Not HTML" }, { status: 415 });
}
let html = await res.text();
if (html.length > MAX_HTML_CHARS) {
return NextResponse.json({ error: "HTML too large" }, { status: 413 });
}
const appOrigin = requestOrigin(req);
const bridgeSrc = `${appOrigin}/vibn-preview-bridge.js`;
const baseHref = `${finalUrl.origin}${directoryBasePath(finalUrl.pathname)}`;
html = html.replace(/<meta[^>]*http-equiv\s*=\s*["']Content-Security-Policy["'][^>]*>/gi, "");
html = html.replace(/<base\s[^>]*>/gi, "");
const baseTag = `<base href="${escapeHtmlAttr(baseHref)}">`;
if (/<head[^>]*>/i.test(html)) {
html = html.replace(/<head[^>]*>/i, `$&${baseTag}`);
} else {
html = `<!DOCTYPE html><html><head>${baseTag}<meta charset="utf-8"/></head><body>${html}</body></html>`;
}
const scriptTag = `<script src="${escapeHtmlAttr(bridgeSrc)}" defer><\/script>`;
if (/<\/body>/i.test(html)) {
html = html.replace(/<\/body>/i, `${scriptTag}</body>`);
} else {
html += scriptTag;
}
return new NextResponse(html, {
status: 200,
headers: {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "private, no-store",
"X-Robots-Tag": "noindex, nofollow",
},
});
}

View File

@@ -1,84 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params;
// Get all sessions for this project
const sessionsSnapshot = await adminDb
.collection('sessions')
.where('projectId', '==', projectId)
.get();
const sessions = sessionsSnapshot.docs
.map(doc => {
const data = doc.data();
return {
id: doc.id,
startTime: data.startTime?.toDate?.() || data.startTime,
endTime: data.endTime?.toDate?.() || data.endTime,
duration: data.duration || 0,
filesModified: data.filesModified || [],
conversationSummary: data.conversationSummary || '',
workspacePath: data.workspacePath || '',
conversation: data.conversation || []
};
})
.sort((a, b) => {
const aTime = a.startTime ? new Date(a.startTime).getTime() : 0;
const bTime = b.startTime ? new Date(b.startTime).getTime() : 0;
return aTime - bTime;
});
// Analyze activity
const fileActivity: Record<string, { count: number; dates: string[] }> = {};
const dailyActivity: Record<string, number> = {};
sessions.forEach(session => {
if (!session.startTime) return;
const date = new Date(session.startTime).toISOString().split('T')[0];
dailyActivity[date] = (dailyActivity[date] || 0) + 1;
session.filesModified.forEach((file: string) => {
if (!fileActivity[file]) {
fileActivity[file] = { count: 0, dates: [] };
}
fileActivity[file].count++;
if (!fileActivity[file].dates.includes(date)) {
fileActivity[file].dates.push(date);
}
});
});
// Get top files
const topFiles = Object.entries(fileActivity)
.map(([file, data]) => ({ file, ...data }))
.sort((a, b) => b.count - a.count)
.slice(0, 50);
return NextResponse.json({
totalSessions: sessions.length,
sessions,
fileActivity: topFiles,
dailyActivity: Object.entries(dailyActivity)
.map(([date, count]) => ({ date, sessionCount: count }))
.sort((a, b) => a.date.localeCompare(b.date))
});
} catch (error) {
console.error('Error fetching activity:', error);
return NextResponse.json(
{
error: 'Failed to fetch activity',
details: error instanceof Error ? error.message : String(error)
},
{ status: 500 }
);
}
}

View File

@@ -1,132 +0,0 @@
/**
* POST /api/projects/[projectId]/agent/sessions/[sessionId]/approve
*
* Called by the frontend when the user clicks "Approve & commit".
* Verifies ownership, then asks the agent runner to git commit + push
* the changes it made in the workspace, and triggers a Coolify deploy.
*
* Body: { commitMessage: string }
*/
import { NextResponse } from "next/server";
import { authSession } from "@/lib/auth/session-server";
import { query } from "@/lib/db-postgres";
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
const COOLIFY_API_URL = process.env.COOLIFY_API_URL ?? "";
const COOLIFY_API_TOKEN = process.env.COOLIFY_API_TOKEN ?? "";
interface AppEntry {
name: string;
path: string;
coolifyServiceUuid?: string | null;
domain?: string | null;
}
export async function POST(
req: Request,
{ params }: { params: Promise<{ projectId: string; sessionId: string }> }
) {
try {
const { projectId, sessionId } = await params;
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json() as { commitMessage?: string };
const commitMessage = body.commitMessage?.trim();
if (!commitMessage) {
return NextResponse.json({ error: "commitMessage is required" }, { status: 400 });
}
// Verify ownership + fetch project data (giteaRepo, apps list)
const rows = await query<{ data: Record<string, unknown> }>(
`SELECT p.data FROM fs_projects p
JOIN fs_users u ON u.id = p.user_id
WHERE p.id::text = $1 AND u.data->>'email' = $2 LIMIT 1`,
[projectId, session.user.email]
);
if (rows.length === 0) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
const projectData = rows[0].data;
const giteaRepo = projectData?.giteaRepo as string | undefined;
if (!giteaRepo) {
return NextResponse.json({ error: "No Gitea repo linked to this project" }, { status: 400 });
}
// Find the session to get the appName (so we can find the right Coolify UUID)
const sessionRows = await query<{ app_name: string; status: string }>(
`SELECT app_name, status FROM agent_sessions WHERE id = $1::uuid AND project_id::text = $2 LIMIT 1`,
[sessionId, projectId]
);
if (sessionRows.length === 0) {
return NextResponse.json({ error: "Session not found" }, { status: 404 });
}
if (sessionRows[0].status !== "done") {
return NextResponse.json({ error: "Session must be in 'done' state to approve" }, { status: 400 });
}
const appName = sessionRows[0].app_name;
// Find the matching Coolify UUID from project.data.apps[]
const apps: AppEntry[] = (projectData?.apps ?? []) as AppEntry[];
const matchedApp = apps.find(a => a.name === appName);
const coolifyAppUuid = matchedApp?.coolifyServiceUuid ?? undefined;
// Call agent runner to commit + push
const approveRes = await fetch(`${AGENT_RUNNER_URL}/agent/approve`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
giteaRepo,
commitMessage,
coolifyApiUrl: COOLIFY_API_URL,
coolifyApiToken: COOLIFY_API_TOKEN,
coolifyAppUuid,
}),
});
const approveData = await approveRes.json() as {
ok: boolean;
committed?: boolean;
deployed?: boolean;
message?: string;
error?: string;
};
if (!approveRes.ok || !approveData.ok) {
return NextResponse.json(
{ error: approveData.error ?? "Agent runner returned an error" },
{ status: 500 }
);
}
// Mark session as approved in DB
await query(
`UPDATE agent_sessions
SET status = 'approved', completed_at = COALESCE(completed_at, now()), updated_at = now(),
output = output || $1::jsonb
WHERE id = $2::uuid`,
[
JSON.stringify([{
ts: new Date().toISOString(),
type: "done",
text: `${approveData.message ?? "Committed and pushed."}${approveData.deployed ? " Deployment triggered." : ""}`,
}]),
sessionId,
]
);
return NextResponse.json({
ok: true,
committed: approveData.committed,
deployed: approveData.deployed,
message: approveData.message,
});
} catch (err) {
console.error("[agent/approve]", err);
return NextResponse.json({ error: "Failed to approve session" }, { status: 500 });
}
}

View File

@@ -1,28 +0,0 @@
import { NextResponse } from 'next/server';
import { buildCanonicalProductModel } from '@/lib/server/product-model';
export async function POST(
_request: Request,
{ params }: { params: Promise<{ projectId: string }> },
) {
try {
const { projectId } = await params;
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
const canonicalProductModel = await buildCanonicalProductModel(projectId);
return NextResponse.json({ canonicalProductModel });
} catch (error) {
console.error('[aggregate] Failed to build canonical product model', error);
return NextResponse.json(
{
error: 'Failed to aggregate product signals',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
}

View File

@@ -1,190 +0,0 @@
import { NextResponse } from 'next/server';
import { getAdminAuth, getAdminDb } from '@/lib/firebase/admin';
import { FieldValue } from 'firebase-admin/firestore';
/**
* Associate existing sessions with a project when GitHub is connected
* Matches sessions by:
* 1. githubRepo field (from Cursor extension)
* 2. workspacePath (if repo name matches)
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params;
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const idToken = authHeader.split('Bearer ')[1];
const adminAuth = getAdminAuth();
const adminDb = getAdminDb();
let userId: string;
try {
const decodedToken = await adminAuth.verifyIdToken(idToken);
userId = decodedToken.uid;
} catch (error) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
const { githubRepo, githubRepoUrl } = await request.json();
if (!githubRepo) {
return NextResponse.json(
{ error: 'githubRepo is required' },
{ status: 400 }
);
}
// Verify project belongs to user
const projectDoc = await adminDb.collection('projects').doc(projectId).get();
if (!projectDoc.exists || projectDoc.data()?.userId !== userId) {
return NextResponse.json(
{ error: 'Project not found or unauthorized' },
{ status: 403 }
);
}
const projectData = projectDoc.data();
const projectWorkspacePath = projectData?.workspacePath;
console.log(`[Associate GitHub Sessions] Project: ${projectId}`);
console.log(`[Associate GitHub Sessions] GitHub repo: ${githubRepo}`);
console.log(`[Associate GitHub Sessions] Project workspace path: ${projectWorkspacePath || 'not set'}`);
console.log(`[Associate GitHub Sessions] User ID: ${userId}`);
// Strategy 1: Match by exact githubRepo field in sessions
// (This requires the Cursor extension to send githubRepo with sessions)
const sessionsSnapshot1 = await adminDb
.collection('sessions')
.where('userId', '==', userId)
.where('githubRepo', '==', githubRepo)
.where('needsProjectAssociation', '==', true)
.get();
console.log(`[Associate GitHub Sessions] Found ${sessionsSnapshot1.size} sessions with exact githubRepo match`);
// Strategy 2: Match by exact workspacePath (if project has one set)
let matchedByPath: any[] = [];
if (projectWorkspacePath) {
console.log(`[Associate GitHub Sessions] Strategy 2A: Exact workspace path match`);
console.log(`[Associate GitHub Sessions] Looking for sessions from: ${projectWorkspacePath}`);
const pathMatchSnapshot = await adminDb
.collection('sessions')
.where('userId', '==', userId)
.where('workspacePath', '==', projectWorkspacePath)
.where('needsProjectAssociation', '==', true)
.get();
matchedByPath = pathMatchSnapshot.docs;
console.log(`[Associate GitHub Sessions] Found ${matchedByPath.length} sessions with exact workspace path match`);
} else {
// Fallback: Match by repo name (less reliable but better than nothing)
console.log(`[Associate GitHub Sessions] Strategy 2B: Fuzzy match by repo folder name (project has no workspace path set)`);
const repoName = githubRepo.split('/')[1]; // Extract "my-app" from "username/my-app"
console.log(`[Associate GitHub Sessions] Looking for folders ending with: ${repoName}`);
const allUnassociatedSessions = await adminDb
.collection('sessions')
.where('userId', '==', userId)
.where('needsProjectAssociation', '==', true)
.get();
console.log(`[Associate GitHub Sessions] Total unassociated sessions for user: ${allUnassociatedSessions.size}`);
matchedByPath = allUnassociatedSessions.docs.filter(doc => {
const workspacePath = doc.data().workspacePath;
if (!workspacePath) return false;
const pathSegments = workspacePath.split('/');
const lastSegment = pathSegments[pathSegments.length - 1];
const matches = lastSegment === repoName;
if (matches) {
console.log(`[Associate GitHub Sessions] ✅ Fuzzy match: ${workspacePath} ends with ${repoName}`);
}
return matches;
});
console.log(`[Associate GitHub Sessions] Found ${matchedByPath.length} sessions with fuzzy folder name match`);
// Debug: Log some example workspace paths to help diagnose
if (matchedByPath.length === 0 && allUnassociatedSessions.size > 0) {
console.log(`[Associate GitHub Sessions] Debug - Example workspace paths in unassociated sessions:`);
allUnassociatedSessions.docs.slice(0, 5).forEach(doc => {
const path = doc.data().workspacePath;
const folder = path ? path.split('/').pop() : 'null';
console.log(` - ${path} (folder: ${folder})`);
});
console.log(`[Associate GitHub Sessions] Tip: Set project.workspacePath for accurate matching`);
}
}
// Combine both strategies (deduplicate by session ID)
const allMatchedSessions = new Map();
// Add exact matches
sessionsSnapshot1.docs.forEach(doc => {
allMatchedSessions.set(doc.id, doc);
});
// Add path matches
matchedByPath.forEach(doc => {
allMatchedSessions.set(doc.id, doc);
});
// Batch update all matched sessions
if (allMatchedSessions.size > 0) {
const batch = adminDb.batch();
let count = 0;
allMatchedSessions.forEach((doc) => {
batch.update(doc.ref, {
projectId,
needsProjectAssociation: false,
updatedAt: FieldValue.serverTimestamp(),
});
count++;
});
await batch.commit();
console.log(`[Associate GitHub Sessions] Successfully associated ${count} sessions with project ${projectId}`);
return NextResponse.json({
success: true,
sessionsAssociated: count,
message: `Found and linked ${count} existing chat sessions from this repository`,
details: {
exactMatches: sessionsSnapshot1.size,
pathMatches: matchedByPath.length,
}
});
}
return NextResponse.json({
success: true,
sessionsAssociated: 0,
message: 'No matching sessions found for this repository',
});
} catch (error) {
console.error('[Associate GitHub Sessions] Error:', error);
return NextResponse.json(
{
error: 'Failed to associate sessions',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}

View File

@@ -1,505 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
import { getApiUrl } from '@/lib/utils/api-url';
// Types
interface WorkSession {
sessionId: string;
date: string;
startTime: Date;
endTime: Date;
duration: number; // minutes
messageCount: number;
userMessages: number;
aiMessages: number;
topics: string[];
filesWorkedOn: string[];
}
interface TimelineAnalysis {
firstActivity: Date | null;
lastActivity: Date | null;
totalDays: number;
activeDays: number;
totalSessions: number;
sessions: WorkSession[];
velocity: {
messagesPerDay: number;
averageSessionLength: number;
peakProductivityHours: number[];
};
}
interface CostAnalysis {
messageStats: {
totalMessages: number;
userMessages: number;
aiMessages: number;
avgMessageLength: number;
};
estimatedTokens: {
input: number;
output: number;
total: number;
};
costs: {
inputCost: number;
outputCost: number;
totalCost: number;
currency: string;
};
model: string;
pricing: {
inputPer1M: number;
outputPer1M: number;
};
}
interface Feature {
name: string;
description: string;
pages: string[];
apis: string[];
status: 'complete' | 'in-progress' | 'planned';
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params;
// 1. Load conversations from Firestore
console.log(`🔍 Loading conversations for project ${projectId}...`);
const conversationsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.get();
if (conversationsSnapshot.empty) {
return NextResponse.json({
error: 'No conversations found for this project',
suggestion: 'Import Cursor conversations first'
}, { status: 404 });
}
const conversations = conversationsSnapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
}));
console.log(`✅ Found ${conversations.length} conversations`);
// 2. Load all messages for each conversation
let allMessages: any[] = [];
for (const conv of conversations) {
const messagesSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.doc(conv.id)
.collection('messages')
.orderBy('createdAt', 'asc')
.get();
const messages = messagesSnapshot.docs.map(doc => ({
...doc.data(),
conversationId: conv.id,
conversationName: conv.name
}));
allMessages = allMessages.concat(messages);
}
console.log(`✅ Loaded ${allMessages.length} total messages`);
// 3. Load extension activity data (files edited, sessions)
let extensionActivity: any = null;
try {
const activitySnapshot = await adminDb
.collection('sessions')
.where('projectId', '==', projectId)
.get();
const extensionSessions = activitySnapshot.docs
.map(doc => {
const data = doc.data();
return {
startTime: data.startTime?.toDate?.() || data.startTime,
endTime: data.endTime?.toDate?.() || data.endTime,
filesModified: data.filesModified || [],
conversationSummary: data.conversationSummary || ''
};
})
.sort((a, b) => {
const aTime = a.startTime ? new Date(a.startTime).getTime() : 0;
const bTime = b.startTime ? new Date(b.startTime).getTime() : 0;
return aTime - bTime;
});
// Analyze file activity
const fileActivity: Record<string, number> = {};
extensionSessions.forEach(session => {
session.filesModified.forEach((file: string) => {
fileActivity[file] = (fileActivity[file] || 0) + 1;
});
});
const topFiles = Object.entries(fileActivity)
.map(([file, count]) => ({ file, editCount: count }))
.sort((a, b) => b.editCount - a.editCount)
.slice(0, 20);
extensionActivity = {
totalSessions: extensionSessions.length,
uniqueFilesEdited: Object.keys(fileActivity).length,
topFiles,
earliestActivity: extensionSessions[0]?.startTime || null,
latestActivity: extensionSessions[extensionSessions.length - 1]?.endTime || null
};
console.log(`✅ Loaded ${extensionSessions.length} extension activity sessions`);
} catch (error) {
console.log(`⚠️ Could not load extension activity: ${error}`);
}
// 4. Load Git commit history
let gitHistory: any = null;
try {
const gitResponse = await fetch(getApiUrl(`/api/projects/${projectId}/git-history`, request));
if (gitResponse.ok) {
gitHistory = await gitResponse.json();
console.log(`✅ Loaded ${gitHistory.totalCommits} Git commits`);
}
} catch (error) {
console.log(`⚠️ Could not load Git history: ${error}`);
}
// 4b. Load unified timeline (combines all data sources by day)
let unifiedTimeline: any = null;
try {
const timelineResponse = await fetch(getApiUrl(`/api/projects/${projectId}/timeline`, request));
if (timelineResponse.ok) {
unifiedTimeline = await timelineResponse.json();
console.log(`✅ Loaded unified timeline with ${unifiedTimeline.days.length} days`);
}
} catch (error) {
console.log(`⚠️ Could not load unified timeline: ${error}`);
}
// 5. Analyze timeline
const timeline = analyzeTimeline(allMessages);
// 6. Calculate costs
const costs = calculateCosts(allMessages);
// 7. Extract features from codebase (static list for now)
const features = getFeaturesList();
// 8. Get tech stack
const techStack = getTechStack();
// 9. Generate report
const report = {
projectId,
generatedAt: new Date().toISOString(),
timeline,
costs,
features,
techStack,
extensionActivity,
gitHistory,
unifiedTimeline,
summary: {
totalConversations: conversations.length,
totalMessages: allMessages.length,
developmentPeriod: timeline.totalDays,
estimatedCost: costs.costs.totalCost,
extensionSessions: extensionActivity?.totalSessions || 0,
filesEdited: extensionActivity?.uniqueFilesEdited || 0,
gitCommits: gitHistory?.totalCommits || 0,
linesAdded: gitHistory?.totalInsertions || 0,
linesRemoved: gitHistory?.totalDeletions || 0,
timelineDays: unifiedTimeline?.days.length || 0
}
};
console.log(`✅ Audit report generated successfully`);
return NextResponse.json(report);
} catch (error) {
console.error('Error generating audit report:', error);
return NextResponse.json(
{
error: 'Failed to generate audit report',
details: error instanceof Error ? error.message : String(error)
},
{ status: 500 }
);
}
}
// Helper: Analyze timeline from messages
function analyzeTimeline(messages: any[]): TimelineAnalysis {
if (messages.length === 0) {
return {
firstActivity: null,
lastActivity: null,
totalDays: 0,
activeDays: 0,
totalSessions: 0,
sessions: [],
velocity: {
messagesPerDay: 0,
averageSessionLength: 0,
peakProductivityHours: []
}
};
}
// Sort messages by time
const sorted = [...messages].sort((a, b) =>
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
const firstActivity = new Date(sorted[0].createdAt);
const lastActivity = new Date(sorted[sorted.length - 1].createdAt);
const totalDays = Math.ceil((lastActivity.getTime() - firstActivity.getTime()) / (1000 * 60 * 60 * 24));
// Group into sessions (gap > 4 hours = new session)
const SESSION_GAP = 4 * 60 * 60 * 1000; // 4 hours
const sessions: WorkSession[] = [];
let currentSession: any = null;
for (const msg of sorted) {
const msgTime = new Date(msg.createdAt).getTime();
if (!currentSession || msgTime - currentSession.endTime > SESSION_GAP) {
// Start new session
if (currentSession) {
sessions.push(formatSession(currentSession));
}
currentSession = {
messages: [msg],
startTime: msgTime,
endTime: msgTime,
date: new Date(msgTime).toISOString().split('T')[0]
};
} else {
// Add to current session
currentSession.messages.push(msg);
currentSession.endTime = msgTime;
}
}
// Don't forget the last session
if (currentSession) {
sessions.push(formatSession(currentSession));
}
// Calculate velocity metrics
const activeDays = new Set(sorted.map(m =>
new Date(m.createdAt).toISOString().split('T')[0]
)).size;
const totalSessionMinutes = sessions.reduce((sum, s) => sum + s.duration, 0);
const averageSessionLength = sessions.length > 0 ? totalSessionMinutes / sessions.length : 0;
// Find peak productivity hours
const hourCounts = new Map<number, number>();
sorted.forEach(msg => {
const hour = new Date(msg.createdAt).getHours();
hourCounts.set(hour, (hourCounts.get(hour) || 0) + 1);
});
const peakProductivityHours = Array.from(hourCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([hour]) => hour)
.sort((a, b) => a - b);
return {
firstActivity,
lastActivity,
totalDays,
activeDays,
totalSessions: sessions.length,
sessions,
velocity: {
messagesPerDay: messages.length / activeDays,
averageSessionLength: Math.round(averageSessionLength),
peakProductivityHours
}
};
}
function formatSession(sessionData: any): WorkSession {
const duration = Math.ceil((sessionData.endTime - sessionData.startTime) / (1000 * 60));
const userMessages = sessionData.messages.filter((m: any) => m.type === 1).length;
const aiMessages = sessionData.messages.filter((m: any) => m.type === 2).length;
// Extract topics (first 3 unique conversation names)
const topics = [...new Set(sessionData.messages.map((m: any) => m.conversationName))].slice(0, 3);
// Extract files
const files = [...new Set(
sessionData.messages.flatMap((m: any) => m.attachedFiles || [])
)];
return {
sessionId: `session-${sessionData.date}-${sessionData.startTime}`,
date: sessionData.date,
startTime: new Date(sessionData.startTime),
endTime: new Date(sessionData.endTime),
duration,
messageCount: sessionData.messages.length,
userMessages,
aiMessages,
topics,
filesWorkedOn: files
};
}
// Helper: Calculate costs
function calculateCosts(messages: any[]): CostAnalysis {
const userMessages = messages.filter(m => m.type === 1);
const aiMessages = messages.filter(m => m.type === 2);
// Calculate average message length
const totalChars = messages.reduce((sum, m) => sum + (m.text?.length || 0), 0);
const avgMessageLength = messages.length > 0 ? Math.round(totalChars / messages.length) : 0;
// Estimate tokens (rough: 1 token ≈ 4 characters)
const inputChars = userMessages.reduce((sum, m) => sum + (m.text?.length || 0), 0);
const outputChars = aiMessages.reduce((sum, m) => sum + (m.text?.length || 0), 0);
const inputTokens = Math.ceil(inputChars / 4);
const outputTokens = Math.ceil(outputChars / 4);
const totalTokens = inputTokens + outputTokens;
// Claude Sonnet 3.5 pricing (Nov 2024)
const INPUT_COST_PER_1M = 3.0;
const OUTPUT_COST_PER_1M = 15.0;
const inputCost = (inputTokens / 1_000_000) * INPUT_COST_PER_1M;
const outputCost = (outputTokens / 1_000_000) * OUTPUT_COST_PER_1M;
const totalAICost = inputCost + outputCost;
return {
messageStats: {
totalMessages: messages.length,
userMessages: userMessages.length,
aiMessages: aiMessages.length,
avgMessageLength
},
estimatedTokens: {
input: inputTokens,
output: outputTokens,
total: totalTokens
},
costs: {
inputCost: Math.round(inputCost * 100) / 100,
outputCost: Math.round(outputCost * 100) / 100,
totalCost: Math.round(totalAICost * 100) / 100,
currency: 'USD'
},
model: 'Claude Sonnet 3.5',
pricing: {
inputPer1M: INPUT_COST_PER_1M,
outputPer1M: OUTPUT_COST_PER_1M
}
};
}
// Helper: Get features list
function getFeaturesList(): Feature[] {
return [
{
name: "Project Management",
description: "Create, manage, and organize AI-coded projects",
pages: ["/projects", "/project/[id]/overview", "/project/[id]/settings"],
apis: ["/api/projects/create", "/api/projects/[id]", "/api/projects/delete"],
status: "complete"
},
{
name: "AI Chat Integration",
description: "Real-time chat with AI assistants for development",
pages: ["/project/[id]/v_ai_chat"],
apis: ["/api/ai/chat", "/api/ai/conversation"],
status: "complete"
},
{
name: "Cursor Import",
description: "Import historical conversations from Cursor IDE",
pages: [],
apis: ["/api/cursor/backfill", "/api/cursor/tag-sessions"],
status: "complete"
},
{
name: "GitHub Integration",
description: "Connect GitHub repositories and browse code",
pages: ["/connections"],
apis: ["/api/github/connect", "/api/github/repos", "/api/github/repo-tree"],
status: "complete"
},
{
name: "Session Tracking",
description: "Track development sessions and activity",
pages: ["/project/[id]/sessions"],
apis: ["/api/sessions/track", "/api/sessions/associate-project"],
status: "complete"
},
{
name: "Knowledge Base",
description: "Document and organize project knowledge",
pages: ["/project/[id]/context"],
apis: ["/api/projects/[id]/knowledge/*"],
status: "complete"
},
{
name: "Planning & Automation",
description: "Generate development plans and automate workflows",
pages: ["/project/[id]/plan", "/project/[id]/automation"],
apis: ["/api/projects/[id]/plan/mvp", "/api/projects/[id]/plan/marketing"],
status: "in-progress"
},
{
name: "Analytics & Costs",
description: "Track development costs and project analytics",
pages: ["/project/[id]/analytics", "/costs"],
apis: ["/api/stats", "/api/projects/[id]/aggregate"],
status: "in-progress"
}
];
}
// Helper: Get tech stack
function getTechStack() {
return {
frontend: {
framework: "Next.js 16.0.1",
react: "19.2.0",
typescript: "5.x",
styling: "Tailwind CSS 4",
uiComponents: "Radix UI + shadcn/ui",
icons: "Lucide React",
fonts: "Geist Sans, Geist Mono"
},
backend: {
runtime: "Next.js API Routes",
database: "Firebase Firestore",
auth: "Firebase Auth",
storage: "Firebase Storage"
},
integrations: [
"Google Vertex AI",
"Google Generative AI",
"GitHub OAuth",
"v0.dev SDK"
]
};
}

View File

@@ -1,165 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { getApiUrl } from '@/lib/utils/api-url';
/**
* Complete Chronological History
* Returns ALL project data in a single chronological timeline
* Optimized for AI consumption - no truncation, no summaries
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params;
// Load all three data sources
const [contextRes, gitRes, activityRes, timelineRes] = await Promise.all([
fetch(getApiUrl(`/api/projects/${projectId}/context`, request)),
fetch(getApiUrl(`/api/projects/${projectId}/git-history`, request)),
fetch(getApiUrl(`/api/projects/${projectId}/activity`, request)),
fetch(getApiUrl(`/api/projects/${projectId}/timeline`, request))
]);
const context = contextRes.ok ? await contextRes.json() : null;
const git = gitRes.ok ? await gitRes.json() : null;
const activity = activityRes.ok ? await activityRes.json() : null;
const timeline = timelineRes.ok ? await timelineRes.json() : null;
// Build complete chronological event stream
const events: any[] = [];
// Add all Git commits as events
if (git?.commits) {
for (const commit of git.commits) {
events.push({
type: 'git_commit',
timestamp: new Date(commit.date).toISOString(),
date: commit.date.split(' ')[0],
data: {
hash: commit.hash,
author: commit.author,
message: commit.message,
filesChanged: commit.filesChanged,
insertions: commit.insertions,
deletions: commit.deletions
}
});
}
}
// Add all extension sessions as events
if (activity?.sessions) {
for (const session of activity.sessions) {
events.push({
type: 'extension_session',
timestamp: session.startTime,
date: new Date(session.startTime).toISOString().split('T')[0],
data: {
id: session.id,
startTime: session.startTime,
endTime: session.endTime,
duration: session.duration,
filesModified: session.filesModified,
conversationSummary: session.conversationSummary?.substring(0, 200),
conversationSnippets: (session.conversation || []).slice(0, 5).map((msg: any) => ({
role: msg.role,
message: msg.message?.substring(0, 100),
timestamp: msg.timestamp
}))
}
});
}
}
// Add Cursor conversations (from recent conversations in context)
if (context?.activity?.recentConversations) {
for (const conv of context.activity.recentConversations) {
events.push({
type: 'cursor_conversation',
timestamp: conv.createdAt,
date: new Date(conv.createdAt).toISOString().split('T')[0],
data: {
id: conv.id,
name: conv.name,
createdAt: conv.createdAt,
messageCount: conv.recentMessages?.length || 0,
recentMessages: conv.recentMessages?.map((msg: any) => ({
type: msg.type,
text: msg.text?.substring(0, 150),
createdAt: msg.createdAt
}))
}
});
}
}
// Sort everything chronologically
events.sort((a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
// Group by date for easier consumption
const eventsByDate: Record<string, any[]> = {};
for (const event of events) {
if (!eventsByDate[event.date]) {
eventsByDate[event.date] = [];
}
eventsByDate[event.date].push(event);
}
// Build response
const completeHistory = {
project: {
id: projectId,
name: context?.project?.name,
vision: context?.project?.vision,
githubRepo: context?.project?.githubRepo
},
summary: {
totalEvents: events.length,
dateRange: {
earliest: events[0]?.date,
latest: events[events.length - 1]?.date,
totalDays: Object.keys(eventsByDate).length
},
breakdown: {
gitCommits: events.filter(e => e.type === 'git_commit').length,
extensionSessions: events.filter(e => e.type === 'extension_session').length,
cursorConversations: events.filter(e => e.type === 'cursor_conversation').length
}
},
chronologicalEvents: events,
eventsByDate: Object.keys(eventsByDate)
.sort()
.map(date => ({
date,
dayOfWeek: new Date(date).toLocaleDateString('en-US', { weekday: 'long' }),
eventCount: eventsByDate[date].length,
events: eventsByDate[date]
})),
metadata: {
generatedAt: new Date().toISOString(),
dataComplete: true,
includesFullHistory: true
}
};
return NextResponse.json(completeHistory);
} catch (error) {
console.error('Error generating complete history:', error);
return NextResponse.json(
{
error: 'Failed to generate complete history',
details: error instanceof Error ? error.message : String(error)
},
{ status: 500 }
);
}
}

View File

@@ -1,254 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
import { getApiUrl } from '@/lib/utils/api-url';
/**
* Complete Project Context API
* Returns everything an AI needs to understand the project state
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params;
// 1. Load project metadata
const projectDoc = await adminDb
.collection('projects')
.doc(projectId)
.get();
if (!projectDoc.exists) {
return NextResponse.json(
{ error: 'Project not found' },
{ status: 404 }
);
}
const projectData = projectDoc.data();
// 2. Load timeline data
const timelineResponse = await fetch(
getApiUrl(`/api/projects/${projectId}/timeline`, request)
);
const timeline = timelineResponse.ok ? await timelineResponse.json() : null;
// 3. Load Git history summary
const gitResponse = await fetch(
getApiUrl(`/api/projects/${projectId}/git-history`, request)
);
const gitHistory = gitResponse.ok ? await gitResponse.json() : null;
// 4. Load extension activity
const activityResponse = await fetch(
getApiUrl(`/api/projects/${projectId}/activity`, request)
);
const activity = activityResponse.ok ? await activityResponse.json() : null;
// 5. Load uploaded documents
const documentsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('documents')
.orderBy('uploadedAt', 'desc')
.get();
const documents = documentsSnapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
}));
// 6. Get recent conversations (last 7 days)
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const conversationsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.where('createdAt', '>=', sevenDaysAgo.toISOString())
.orderBy('createdAt', 'desc')
.limit(10)
.get();
const recentConversations = [];
for (const convDoc of conversationsSnapshot.docs) {
const conv = convDoc.data();
const messagesSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.doc(convDoc.id)
.collection('messages')
.orderBy('createdAt', 'desc')
.limit(5)
.get();
recentConversations.push({
id: convDoc.id,
name: conv.name,
createdAt: conv.createdAt,
recentMessages: messagesSnapshot.docs.map(m => ({
type: m.data().type === 1 ? 'user' : 'assistant',
text: m.data().text?.substring(0, 200) + '...',
createdAt: m.data().createdAt
}))
});
}
// 7. Calculate key metrics
const activeDays = timeline?.days?.filter((d: any) =>
d.summary.totalGitCommits > 0 ||
d.summary.totalExtensionSessions > 0 ||
d.summary.totalCursorMessages > 0
).length || 0;
const topFiles = activity?.fileActivity?.slice(0, 10) || [];
// 8. Extract key milestones (commits with significant changes)
const keyMilestones = gitHistory?.commits
?.filter((c: any) => c.insertions + c.deletions > 1000)
.slice(0, 5)
.map((c: any) => ({
date: c.date,
message: c.message,
author: c.author,
impact: `+${c.insertions}/-${c.deletions} lines`
})) || [];
// 9. Generate AI-friendly summary
const context = {
project: {
id: projectId,
name: projectData?.name || 'Untitled Project',
vision: projectData?.vision || null,
description: projectData?.description || null,
createdAt: projectData?.createdAt || null,
githubRepo: projectData?.githubRepo || null
},
timeline: {
dateRange: {
earliest: timeline?.dateRange?.earliest,
latest: timeline?.dateRange?.latest,
totalDays: timeline?.dateRange?.totalDays || 0,
activeDays
},
dataSources: {
git: {
available: timeline?.dataSources?.git?.available || false,
totalCommits: timeline?.dataSources?.git?.totalRecords || 0,
dateRange: {
first: timeline?.dataSources?.git?.firstDate,
last: timeline?.dataSources?.git?.lastDate
}
},
extension: {
available: timeline?.dataSources?.extension?.available || false,
totalSessions: timeline?.dataSources?.extension?.totalRecords || 0,
dateRange: {
first: timeline?.dataSources?.extension?.firstDate,
last: timeline?.dataSources?.extension?.lastDate
}
},
cursor: {
available: timeline?.dataSources?.cursor?.available || false,
totalMessages: timeline?.dataSources?.cursor?.totalRecords || 0,
dateRange: {
first: timeline?.dataSources?.cursor?.firstDate,
last: timeline?.dataSources?.cursor?.lastDate
}
}
}
},
codebase: {
totalCommits: gitHistory?.totalCommits || 0,
totalLinesAdded: gitHistory?.totalInsertions || 0,
totalLinesRemoved: gitHistory?.totalDeletions || 0,
contributors: gitHistory?.authors || [],
topFiles: gitHistory?.topFiles?.slice(0, 20) || []
},
activity: {
totalSessions: activity?.totalSessions || 0,
uniqueFilesEdited: activity?.fileActivity?.length || 0,
topEditedFiles: topFiles,
recentConversations
},
milestones: keyMilestones,
documents: documents.map(doc => ({
id: doc.id,
title: doc.title,
type: doc.type,
uploadedAt: doc.uploadedAt,
contentPreview: doc.content?.substring(0, 500) + '...'
})),
summary: generateProjectSummary({
projectData,
timeline,
gitHistory,
activity,
documents
})
};
return NextResponse.json(context);
} catch (error) {
console.error('Error loading project context:', error);
return NextResponse.json(
{
error: 'Failed to load project context',
details: error instanceof Error ? error.message : String(error)
},
{ status: 500 }
);
}
}
// Helper to generate human-readable summary
function generateProjectSummary(data: any): string {
const { projectData, timeline, gitHistory, activity, documents } = data;
const parts = [];
// Project basics
if (projectData?.name) {
parts.push(`Project: ${projectData.name}`);
}
if (projectData?.vision) {
parts.push(`Vision: ${projectData.vision}`);
}
// Timeline
if (timeline?.dateRange?.totalDays) {
parts.push(`Development span: ${timeline.dateRange.totalDays} days`);
}
// Git stats
if (gitHistory?.totalCommits) {
parts.push(
`Code: ${gitHistory.totalCommits} commits, ` +
`+${gitHistory.totalInsertions.toLocaleString()}/-${gitHistory.totalDeletions.toLocaleString()} lines`
);
}
// Activity
if (activity?.totalSessions) {
parts.push(`Activity: ${activity.totalSessions} development sessions`);
}
// Documents
if (documents?.length) {
parts.push(`Documentation: ${documents.length} documents uploaded`);
}
return parts.join(' | ');
}

View File

@@ -1,59 +0,0 @@
import { NextResponse } from 'next/server';
import { getAdminDb } from '@/lib/firebase/admin';
export async function GET(
request: Request,
context: { params: Promise<{ projectId: string }> | { projectId: string } }
) {
try {
const params = 'then' in context.params ? await context.params : context.params;
const projectId = params.projectId;
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
const adminDb = getAdminDb();
// Get ALL knowledge items for this project
const knowledgeSnapshot = await adminDb
.collection('knowledge_items')
.where('projectId', '==', projectId)
.get();
const items = knowledgeSnapshot.docs.map(doc => {
const data = doc.data();
return {
id: doc.id,
title: data.title,
sourceType: data.sourceType,
contentLength: data.content?.length || 0,
createdAt: data.createdAt,
tags: data.sourceMeta?.tags || [],
};
});
// Get project info
const projectDoc = await adminDb.collection('projects').doc(projectId).get();
const projectData = projectDoc.data();
return NextResponse.json({
projectId,
projectName: projectData?.name,
currentPhase: projectData?.currentPhase,
totalKnowledgeItems: items.length,
items,
extractionHandoff: projectData?.phaseData?.phaseHandoffs?.extraction,
});
} catch (error) {
console.error('[debug-knowledge] Error:', error);
return NextResponse.json(
{
error: 'Failed to debug knowledge',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}

View File

@@ -1,61 +0,0 @@
/**
* GET /api/projects/[projectId]/dev-server-logs?id=ds_…
*
* Returns the tail of /var/log/vibn-dev/<id>.log from the project dev
* container so the Preview tab can show errors without MCP-only tools.
*/
import { NextResponse } from 'next/server';
import { authSession } from '@/lib/auth/session-server';
import { query } from '@/lib/db-postgres';
import { execInDevContainer } from '@/lib/dev-container';
const ID_RE = /^ds_[a-f0-9]+$/;
export async function GET(
req: Request,
{ params }: { params: Promise<{ projectId: string }> },
) {
try {
const { projectId } = await params;
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const id = searchParams.get('id')?.trim() ?? '';
if (!id || !ID_RE.test(id)) {
return NextResponse.json({ error: 'Invalid or missing id' }, { status: 400 });
}
const rows = await query<{ one: number }>(
`SELECT 1 AS one
FROM fs_dev_servers d
JOIN fs_projects p ON p.id = d.project_id
JOIN fs_users u ON u.id = p.user_id
WHERE d.id = $1 AND d.project_id = $2 AND u.data->>'email' = $3
LIMIT 1`,
[id, projectId, session.user.email],
);
if (rows.length === 0) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const safeId = id.replace(/[^a-z0-9_]/gi, '');
const r = await execInDevContainer({
projectId,
command: `tail -n 200 /var/log/vibn-dev/${safeId}.log 2>/dev/null || echo "(no log file for ${safeId})"`,
timeoutMs: 12_000,
});
return NextResponse.json({
logs: (r.stdout ?? '').trim() || '(empty)',
});
} catch (err) {
console.error('[dev-server-logs]', err);
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to read logs' },
{ status: 502 },
);
}
}

View File

@@ -1,98 +0,0 @@
import { NextResponse } from 'next/server';
import { FieldValue } from 'firebase-admin/firestore';
import { GeminiLlmClient } from '@/lib/ai/gemini-client';
import { runChatExtraction } from '@/lib/ai/chat-extractor';
import { getKnowledgeItem } from '@/lib/server/knowledge';
import { createChatExtraction } from '@/lib/server/chat-extraction';
import { getAdminDb } from '@/lib/firebase/admin';
import type { ProjectPhaseScores } from '@/lib/types/project-artifacts';
interface ExtractFromChatRequest {
knowledgeItemId?: string;
}
// Increase Vercel/Next timeout for large transcripts
export const maxDuration = 60;
export async function POST(
request: Request,
{ params }: { params: Promise<{ projectId: string }> },
) {
try {
const { projectId } = await params;
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
const body = (await request.json()) as ExtractFromChatRequest;
const knowledgeItemId = body.knowledgeItemId?.trim();
if (!knowledgeItemId) {
return NextResponse.json({ error: 'knowledgeItemId is required' }, { status: 400 });
}
const knowledgeItem = await getKnowledgeItem(projectId, knowledgeItemId);
if (!knowledgeItem) {
return NextResponse.json({ error: 'Knowledge item not found' }, { status: 404 });
}
console.log(`[extract-from-chat] Starting extraction for knowledgeItemId=${knowledgeItemId}, content length=${knowledgeItem.content.length}`);
const llm = new GeminiLlmClient();
const extractionData = await runChatExtraction(knowledgeItem, llm);
console.log(`[extract-from-chat] Extraction complete for knowledgeItemId=${knowledgeItemId}`);
const overallCompletion = extractionData.summary_scores.overall_completion ?? 0;
const overallConfidence = extractionData.summary_scores.overall_confidence ?? 0;
const extraction = await createChatExtraction({
projectId,
knowledgeItemId,
data: extractionData,
overallCompletion,
overallConfidence,
});
const adminDb = getAdminDb();
const projectRef = adminDb.collection('projects').doc(projectId);
const snapshot = await projectRef.get();
const docData = snapshot.data() ?? {};
const existingScores = (docData.phaseScores ?? {}) as ProjectPhaseScores;
const phaseHistory = Array.isArray(docData.phaseHistory) ? [...docData.phaseHistory] : [];
phaseHistory.push({
phase: 'extractor',
status: 'completed',
knowledgeItemId,
timestamp: new Date().toISOString(),
});
existingScores.extractor = {
knowledgeItemId,
overallCompletion,
overallConfidence,
updatedAt: new Date().toISOString(),
};
await projectRef.set(
{
currentPhase: 'analyzed',
phaseScores: existingScores,
phaseStatus: 'in_progress',
phaseHistory,
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true },
);
return NextResponse.json({ extraction });
} catch (error) {
console.error('[extract-from-chat] Extraction failed', error);
return NextResponse.json(
{
error: 'Failed to extract product signals',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
}

View File

@@ -1,110 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import admin from '@/lib/firebase/admin';
/**
* Extract vision answers from chat history and save to project
* This is a helper endpoint to migrate from AI chat-based vision collection
* to the structured visionAnswers field
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params;
const db = admin.firestore();
console.log(`[Extract Vision] Extracting vision answers from chat for project ${projectId}`);
// Get chat messages
const conversationRef = db
.collection('projects')
.doc(projectId)
.collection('conversations')
.doc('ai_chat');
const messagesSnapshot = await conversationRef
.collection('messages')
.orderBy('createdAt', 'asc')
.get();
if (messagesSnapshot.empty) {
return NextResponse.json(
{ error: 'No chat messages found' },
{ status: 404 }
);
}
const messages = messagesSnapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
}));
console.log(`[Extract Vision] Found ${messages.length} total messages`);
// Extract user messages (answers to the 3 vision questions)
const userMessages = messages.filter((m: any) => m.role === 'user');
console.log(`[Extract Vision] Found ${userMessages.length} user messages`);
if (userMessages.length < 3) {
return NextResponse.json(
{
error: 'Not enough answers found',
details: `Found ${userMessages.length} answers, need 3`,
userMessages: userMessages.map((m: any) => m.content?.substring(0, 100))
},
{ status: 400 }
);
}
// The first 3 user messages should be the answers to Q1, Q2, Q3
const visionAnswers = {
q1: userMessages[0].content,
q2: userMessages[1].content,
q3: userMessages[2].content,
allAnswered: true,
updatedAt: new Date().toISOString(),
};
console.log(`[Extract Vision] Extracted vision answers:`, {
q1: visionAnswers.q1.substring(0, 50) + '...',
q2: visionAnswers.q2.substring(0, 50) + '...',
q3: visionAnswers.q3.substring(0, 50) + '...',
});
// Save to project
await db.collection('projects').doc(projectId).set(
{
visionAnswers,
readyForMVP: true,
currentPhase: 'mvp',
phaseStatus: 'ready',
},
{ merge: true }
);
console.log(`[Extract Vision] ✅ Vision answers saved for project ${projectId}`);
return NextResponse.json({
success: true,
message: 'Vision answers extracted and saved',
visionAnswers: {
q1: visionAnswers.q1.substring(0, 100) + '...',
q2: visionAnswers.q2.substring(0, 100) + '...',
q3: visionAnswers.q3.substring(0, 100) + '...',
}
});
} catch (error) {
console.error('[Extract Vision] Error:', error);
return NextResponse.json(
{
error: 'Failed to extract vision answers',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}

View File

@@ -1,115 +0,0 @@
import { NextResponse } from 'next/server';
import { getAdminDb } from '@/lib/firebase/admin';
export async function GET(
request: Request,
context: { params: Promise<{ projectId: string }> | { projectId: string } }
) {
try {
// Handle async params
const params = 'then' in context.params ? await context.params : context.params;
const projectId = params.projectId;
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
const adminDb = getAdminDb();
// Fetch project to get extraction handoff
const projectDoc = await adminDb.collection('projects').doc(projectId).get();
if (!projectDoc.exists) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}
const projectData = projectDoc.data();
const extractionHandoff = projectData?.phaseData?.phaseHandoffs?.extraction;
if (!extractionHandoff) {
return NextResponse.json({ error: 'No extraction results found' }, { status: 404 });
}
return NextResponse.json({
handoff: extractionHandoff,
});
} catch (error) {
console.error('[extraction-handoff] Error:', error);
return NextResponse.json(
{
error: 'Failed to fetch extraction handoff',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}
export async function PATCH(
request: Request,
context: { params: Promise<{ projectId: string }> | { projectId: string } }
) {
try {
// Handle async params
const params = 'then' in context.params ? await context.params : context.params;
const projectId = params.projectId;
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
const body = await request.json();
const { confirmed } = body;
if (!confirmed) {
return NextResponse.json({ error: 'Missing confirmed data' }, { status: 400 });
}
const adminDb = getAdminDb();
// Fetch current handoff
const projectDoc = await adminDb.collection('projects').doc(projectId).get();
if (!projectDoc.exists) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}
const projectData = projectDoc.data();
const currentHandoff = projectData?.phaseData?.phaseHandoffs?.extraction;
if (!currentHandoff) {
return NextResponse.json({ error: 'No extraction handoff found' }, { status: 404 });
}
// Update the handoff with edited data
const updatedHandoff = {
...currentHandoff,
confirmed: {
...currentHandoff.confirmed,
...confirmed,
},
updatedAt: new Date().toISOString(),
};
// Save to Firestore
await adminDb.collection('projects').doc(projectId).update({
'phaseData.phaseHandoffs.extraction': updatedHandoff,
updatedAt: new Date().toISOString(),
});
return NextResponse.json({
success: true,
handoff: updatedHandoff,
});
} catch (error) {
console.error('[extraction-handoff] PATCH error:', error);
return NextResponse.json(
{
error: 'Failed to update extraction handoff',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}

View File

@@ -1,169 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
interface GitCommit {
hash: string;
date: string;
author: string;
message: string;
filesChanged: number;
insertions: number;
deletions: number;
}
interface GitStats {
totalCommits: number;
firstCommit: string | null;
lastCommit: string | null;
totalFilesChanged: number;
totalInsertions: number;
totalDeletions: number;
commits: GitCommit[];
topFiles: Array<{ filePath: string; changeCount: number }>;
commitsByDay: Record<string, number>;
authors: Array<{ name: string; commitCount: number }>;
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params;
// For now, we'll use the current workspace
// In the future, we can store git repo path in project metadata
const repoPath = '/Users/markhenderson/ai-proxy';
// Get all commits with detailed stats
const { stdout: commitsOutput } = await execAsync(
`cd "${repoPath}" && git log --all --pretty=format:"%H|%ai|%an|%s" --numstat`,
{ maxBuffer: 10 * 1024 * 1024 } // 10MB buffer for large repos
);
if (!commitsOutput.trim()) {
return NextResponse.json({
totalCommits: 0,
firstCommit: null,
lastCommit: null,
totalFilesChanged: 0,
totalInsertions: 0,
totalDeletions: 0,
commits: [],
topFiles: [],
commitsByDay: {},
authors: []
});
}
// Parse commit data
const commits: GitCommit[] = [];
const fileChangeCounts = new Map<string, number>();
const commitsByDay: Record<string, number> = {};
const authorCounts = new Map<string, number>();
let totalFilesChanged = 0;
let totalInsertions = 0;
let totalDeletions = 0;
const lines = commitsOutput.split('\n');
let currentCommit: Partial<GitCommit> | null = null;
for (const line of lines) {
if (line.includes('|')) {
// This is a commit header line
if (currentCommit) {
commits.push(currentCommit as GitCommit);
}
const [hash, date, author, message] = line.split('|');
currentCommit = {
hash: hash.substring(0, 8),
date,
author,
message,
filesChanged: 0,
insertions: 0,
deletions: 0
};
// Count commits by day
const day = date.split(' ')[0];
commitsByDay[day] = (commitsByDay[day] || 0) + 1;
// Count commits by author
authorCounts.set(author, (authorCounts.get(author) || 0) + 1);
} else if (line.trim() && currentCommit) {
// This is a file stat line (insertions, deletions, filename)
const parts = line.trim().split('\t');
if (parts.length === 3) {
const [insertStr, delStr, filepath] = parts;
const insertions = insertStr === '-' ? 0 : parseInt(insertStr, 10) || 0;
const deletions = delStr === '-' ? 0 : parseInt(delStr, 10) || 0;
currentCommit.filesChanged!++;
currentCommit.insertions! += insertions;
currentCommit.deletions! += deletions;
totalFilesChanged++;
totalInsertions += insertions;
totalDeletions += deletions;
fileChangeCounts.set(filepath, (fileChangeCounts.get(filepath) || 0) + 1);
}
}
}
// Push the last commit
if (currentCommit) {
commits.push(currentCommit as GitCommit);
}
// Sort commits by date (most recent first)
commits.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
const firstCommit = commits.length > 0 ? commits[commits.length - 1].date : null;
const lastCommit = commits.length > 0 ? commits[0].date : null;
// Get top 20 most changed files
const topFiles = Array.from(fileChangeCounts.entries())
.sort(([, countA], [, countB]) => countB - countA)
.slice(0, 20)
.map(([filePath, changeCount]) => ({ filePath, changeCount }));
// Get author stats
const authors = Array.from(authorCounts.entries())
.sort(([, countA], [, countB]) => countB - countA)
.map(([name, commitCount]) => ({ name, commitCount }));
const stats: GitStats = {
totalCommits: commits.length,
firstCommit,
lastCommit,
totalFilesChanged,
totalInsertions,
totalDeletions,
commits: commits.slice(0, 50), // Return last 50 commits for display
topFiles,
commitsByDay,
authors
};
return NextResponse.json(stats);
} catch (error) {
console.error('Error loading Git history:', error);
return NextResponse.json(
{
error: 'Could not load Git history',
details: error instanceof Error ? error.message : String(error)
},
{ status: 500 }
);
}
}

View File

@@ -1,196 +0,0 @@
import { NextResponse } from 'next/server';
import { getAdminDb } from '@/lib/firebase/admin';
import { runChatExtraction } from '@/lib/ai/chat-extractor';
import { GeminiLlmClient } from '@/lib/ai/gemini-client';
import { createChatExtraction } from '@/lib/server/chat-extraction';
import { FieldValue } from 'firebase-admin/firestore';
import type { ProjectPhaseScores } from '@/lib/types/project-artifacts';
import type { KnowledgeItem } from '@/lib/types/knowledge';
export const maxDuration = 300; // 5 minutes for batch processing
interface BatchExtractionResult {
knowledgeItemId: string;
success: boolean;
error?: string;
}
export async function POST(
request: Request,
context: { params?: Promise<{ projectId?: string }> | { projectId?: string } } = {},
) {
try {
// Await params if it's a Promise (Next.js 15+)
const params = context.params instanceof Promise ? await context.params : context.params;
const url = new URL(request.url);
const pathSegments = url.pathname.split('/');
const projectsIndex = pathSegments.indexOf('projects');
const projectIdFromPath =
projectsIndex !== -1 ? pathSegments[projectsIndex + 1] : undefined;
const projectId =
(params?.projectId ?? projectIdFromPath ?? url.searchParams.get('projectId') ?? '').trim();
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
const adminDb = getAdminDb();
// Get all knowledge_items for this project
const knowledgeSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('knowledge_items')
.get();
if (knowledgeSnapshot.empty) {
return NextResponse.json({
message: 'No knowledge items to extract',
results: []
});
}
const knowledgeItems = knowledgeSnapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
})) as KnowledgeItem[];
// Get existing extractions to avoid re-processing
const extractionsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('chat_extractions')
.get();
const processedKnowledgeIds = new Set(
extractionsSnapshot.docs.map(doc => doc.data().knowledgeItemId)
);
// Filter to only unprocessed items
const itemsToProcess = knowledgeItems.filter(
item => !processedKnowledgeIds.has(item.id)
);
if (itemsToProcess.length === 0) {
return NextResponse.json({
message: 'All knowledge items already extracted',
results: []
});
}
console.log(`[batch-extract] Processing ${itemsToProcess.length} knowledge items for project ${projectId}`);
const llm = new GeminiLlmClient();
const results: BatchExtractionResult[] = [];
let successCount = 0;
let lastSuccessfulExtraction = null;
// Process each item
for (const knowledgeItem of itemsToProcess) {
try {
console.log(`[batch-extract] Extracting from knowledgeItemId=${knowledgeItem.id}`);
const extractionData = await runChatExtraction(knowledgeItem, llm);
const overallCompletion = extractionData.summary_scores.overall_completion ?? 0;
const overallConfidence = extractionData.summary_scores.overall_confidence ?? 0;
const extraction = await createChatExtraction({
projectId,
knowledgeItemId: knowledgeItem.id,
data: extractionData,
overallCompletion,
overallConfidence,
});
lastSuccessfulExtraction = extraction;
successCount++;
results.push({
knowledgeItemId: knowledgeItem.id,
success: true
});
console.log(`[batch-extract] Successfully extracted from knowledgeItemId=${knowledgeItem.id}`);
// Also chunk and embed this item (fire-and-forget)
(async () => {
try {
const { writeKnowledgeChunksForItem } = await import('@/lib/server/vector-memory');
await writeKnowledgeChunksForItem({
id: knowledgeItem.id,
projectId: knowledgeItem.projectId,
content: knowledgeItem.content,
sourceMeta: knowledgeItem.sourceMeta,
});
} catch (chunkError) {
console.error(`[batch-extract] Failed to chunk item ${knowledgeItem.id}:`, chunkError);
}
})();
} catch (error) {
console.error(`[batch-extract] Failed to extract from knowledgeItemId=${knowledgeItem.id}:`, error);
results.push({
knowledgeItemId: knowledgeItem.id,
success: false,
error: error instanceof Error ? error.message : String(error)
});
}
}
// Update project phase if we had any successful extractions
if (successCount > 0 && lastSuccessfulExtraction) {
const projectRef = adminDb.collection('projects').doc(projectId);
const snapshot = await projectRef.get();
const docData = snapshot.data() ?? {};
const existingScores = (docData.phaseScores ?? {}) as ProjectPhaseScores;
const phaseHistory = Array.isArray(docData.phaseHistory) ? [...docData.phaseHistory] : [];
phaseHistory.push({
phase: 'extractor',
status: 'completed',
knowledgeItemId: 'batch_extraction',
timestamp: new Date().toISOString(),
});
// Use the last extraction's scores as representative
const lastData = lastSuccessfulExtraction.data as { summary_scores?: { overall_completion?: number; overall_confidence?: number } };
existingScores.extractor = {
knowledgeItemId: 'batch_extraction',
overallCompletion: lastData.summary_scores?.overall_completion ?? 0,
overallConfidence: lastData.summary_scores?.overall_confidence ?? 0,
updatedAt: new Date().toISOString(),
};
await projectRef.set(
{
currentPhase: 'analyzed',
phaseScores: existingScores,
phaseStatus: 'in_progress',
phaseHistory,
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true },
);
console.log(`[batch-extract] Updated project phase to 'analyzed' for project ${projectId}`);
}
return NextResponse.json({
message: `Processed ${itemsToProcess.length} items: ${successCount} succeeded, ${results.filter(r => !r.success).length} failed`,
results,
successCount,
totalProcessed: itemsToProcess.length
});
} catch (error) {
console.error('[batch-extract] Batch extraction failed:', error);
return NextResponse.json(
{
error: 'Failed to batch extract knowledge items',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
}

View File

@@ -1,118 +0,0 @@
import { NextResponse } from 'next/server';
import { getAdminAuth, getAdminDb } from '@/lib/firebase/admin';
import { createKnowledgeItem } from '@/lib/server/knowledge';
import { writeKnowledgeChunksForItem } from '@/lib/server/vector-memory';
import type { KnowledgeSourceMeta } from '@/lib/types/knowledge';
export const maxDuration = 60;
interface ChunkInsightRequest {
content: string;
title?: string;
importance?: 'primary' | 'supporting' | 'irrelevant';
tags?: string[];
sourceKnowledgeItemId?: string;
metadata?: Record<string, any>;
}
export async function POST(
request: Request,
context: { params: Promise<{ projectId: string }> | { projectId: string } }
) {
try {
// Verify auth
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const idToken = authHeader.split('Bearer ')[1];
const adminAuth = getAdminAuth();
let userId: string;
try {
const decodedToken = await adminAuth.verifyIdToken(idToken);
userId = decodedToken.uid;
} catch (error) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
// Handle async params in Next.js 16
const params = 'then' in context.params ? await context.params : context.params;
const projectId = params.projectId;
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
const body = await request.json() as ChunkInsightRequest;
if (!body.content || body.content.trim().length === 0) {
return NextResponse.json({ error: 'Content is required' }, { status: 400 });
}
const adminDb = getAdminDb();
const projectSnap = await adminDb.collection('projects').doc(projectId).get();
if (!projectSnap.exists) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}
console.log(`[chunk-insight] Creating confirmed insight for project ${projectId}`);
// Create source metadata
const sourceMeta: KnowledgeSourceMeta = {
origin: 'vibn',
createdAtOriginal: new Date().toISOString(),
importance: body.importance || 'primary',
tags: [
'extracted_insight',
'user_confirmed',
'extracted_by:' + userId,
...(body.sourceKnowledgeItemId ? [`source:${body.sourceKnowledgeItemId}`] : []),
...(body.tags || [])
],
};
// Store the confirmed insight as a knowledge_item
const knowledgeItem = await createKnowledgeItem({
projectId,
sourceType: 'other',
title: body.title || 'Extracted Insight',
content: body.content,
sourceMeta,
});
console.log(`[chunk-insight] Created knowledge_item ${knowledgeItem.id}`);
// Chunk and embed in AlloyDB (synchronous for this endpoint)
try {
await writeKnowledgeChunksForItem({
id: knowledgeItem.id,
projectId: knowledgeItem.projectId,
content: knowledgeItem.content,
sourceMeta: knowledgeItem.sourceMeta,
});
console.log(`[chunk-insight] Successfully chunked and embedded insight`);
} catch (chunkError) {
console.error(`[chunk-insight] Failed to chunk item ${knowledgeItem.id}:`, chunkError);
// Don't fail the request, item is still saved in Firestore
}
return NextResponse.json({
success: true,
knowledgeItemId: knowledgeItem.id,
message: 'Insight chunked and stored successfully',
});
} catch (error) {
console.error('[chunk-insight] Error:', error);
return NextResponse.json(
{
error: 'Failed to store insight',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
}

View File

@@ -1,75 +0,0 @@
import { NextResponse } from 'next/server';
import { getAdminAuth } from '@/lib/firebase/admin';
import { getAlloyDbClient } from '@/lib/db/alloydb';
export async function GET(
request: Request,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params;
// Authentication (skip in development if no auth header)
const authHeader = request.headers.get('Authorization');
const isDevelopment = process.env.NODE_ENV === 'development';
if (!isDevelopment || authHeader?.startsWith('Bearer ')) {
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const token = authHeader.substring(7);
const auth = getAdminAuth();
const decoded = await auth.verifyIdToken(token);
if (!decoded?.uid) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
}
// Fetch knowledge chunks from AlloyDB
let chunks = [];
let count = 0;
try {
const pool = await getAlloyDbClient();
const result = await pool.query(
`SELECT
id,
chunk_index,
content,
source_type,
importance,
created_at
FROM knowledge_chunks
WHERE project_id = $1
ORDER BY created_at DESC
LIMIT 100`,
[projectId]
);
chunks = result.rows;
count = result.rowCount || 0;
console.log('[API /knowledge/chunks] Found', count, 'chunks');
} catch (dbError) {
console.error('[API /knowledge/chunks] AlloyDB query failed:', dbError);
console.error('[API /knowledge/chunks] This is likely due to AlloyDB not being configured or connected');
// Return empty array instead of failing
chunks = [];
count = 0;
}
return NextResponse.json({
success: true,
chunks,
count,
});
} catch (error) {
console.error('[API] Error fetching knowledge chunks:', error);
return NextResponse.json(
{ error: 'Failed to fetch knowledge chunks' },
{ status: 500 }
);
}
}

View File

@@ -1,94 +0,0 @@
import { NextResponse } from 'next/server';
import { authSession } from "@/lib/auth/session-server";
import { query } from '@/lib/db-postgres';
import { createKnowledgeItem } from '@/lib/server/knowledge';
import type { KnowledgeSourceMeta } from '@/lib/types/knowledge';
const PROVIDER_MAP = new Set(['chatgpt', 'gemini', 'claude', 'cursor', 'vibn', 'other']);
interface ImportAiChatRequest {
title?: string;
provider?: string;
transcript?: string;
sourceLink?: string | null;
createdAtOriginal?: string | null;
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ projectId: string }> },
) {
try {
const { projectId } = await params;
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
const body = (await request.json()) as ImportAiChatRequest;
const transcript = body.transcript?.trim();
const provider = body.provider?.toLowerCase();
if (!transcript) {
return NextResponse.json({ error: 'transcript is required' }, { status: 400 });
}
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const projectRows = await query(`SELECT id FROM fs_projects WHERE id = $1 LIMIT 1`, [projectId]);
if (projectRows.length === 0) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}
const origin = PROVIDER_MAP.has(provider ?? '') ? provider : 'other';
const sourceMeta: KnowledgeSourceMeta = {
origin: (origin as KnowledgeSourceMeta['origin']) ?? 'other',
url: body.sourceLink ?? null,
filename: body.title ?? null,
createdAtOriginal: body.createdAtOriginal ?? null,
importance: 'primary',
tags: ['ai_chat'],
};
const knowledgeItem = await createKnowledgeItem({
projectId,
sourceType: 'imported_ai_chat',
title: body.title ?? null,
content: transcript,
sourceMeta,
});
// Chunk and embed in background (don't block response)
// This populates AlloyDB knowledge_chunks for vector search
(async () => {
try {
const { writeKnowledgeChunksForItem } = await import('@/lib/server/vector-memory');
await writeKnowledgeChunksForItem({
id: knowledgeItem.id,
projectId: knowledgeItem.projectId,
content: knowledgeItem.content,
sourceMeta: knowledgeItem.sourceMeta,
});
} catch (error) {
// Log but don't fail the request
console.error('[import-ai-chat] Failed to chunk/embed knowledge_item:', error);
}
})();
return NextResponse.json({ knowledgeItem });
} catch (error) {
console.error('[import-ai-chat] Failed to import chat', error);
return NextResponse.json(
{
error: 'Failed to import AI chat transcript',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
}

View File

@@ -1,136 +0,0 @@
import { NextResponse } from 'next/server';
import { getAdminDb } from '@/lib/firebase/admin';
import { createKnowledgeItem } from '@/lib/server/knowledge';
import type { KnowledgeSourceMeta } from '@/lib/types/knowledge';
import { chunkDocument } from '@/lib/utils/document-chunker';
interface ImportDocumentRequest {
filename?: string;
content?: string;
mimeType?: string;
}
export const maxDuration = 30;
export async function POST(
request: Request,
{ params }: { params: Promise<{ projectId: string }> },
) {
try {
const { projectId } = await params;
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
const body = (await request.json()) as ImportDocumentRequest;
const content = body.content?.trim();
const filename = body.filename?.trim();
if (!content || !filename) {
return NextResponse.json({ error: 'filename and content are required' }, { status: 400 });
}
const adminDb = getAdminDb();
const projectSnap = await adminDb.collection('projects').doc(projectId).get();
if (!projectSnap.exists) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}
console.log(`[import-document] Processing ${filename}, length=${content.length}`);
// Chunk the document
const chunks = chunkDocument(content, {
maxChunkSize: 2000,
chunkOverlap: 200,
preserveParagraphs: true,
preserveCodeBlocks: true,
});
console.log(`[import-document] Created ${chunks.length} chunks for ${filename}`);
// Store each chunk as a separate knowledge_item
const knowledgeItemIds: string[] = [];
for (const chunk of chunks) {
const sourceMeta: KnowledgeSourceMeta = {
origin: 'other',
url: null,
filename,
createdAtOriginal: new Date().toISOString(),
importance: 'primary',
tags: ['document', 'chunked'],
};
const chunkTitle = chunks.length > 1
? `${filename} (chunk ${chunk.metadata.chunkIndex + 1}/${chunk.metadata.totalChunks})`
: filename;
const knowledgeItem = await createKnowledgeItem({
projectId,
sourceType: 'imported_document',
title: chunkTitle,
content: chunk.content,
sourceMeta: {
...sourceMeta,
chunkMetadata: {
chunkIndex: chunk.metadata.chunkIndex,
totalChunks: chunk.metadata.totalChunks,
startChar: chunk.metadata.startChar,
endChar: chunk.metadata.endChar,
tokenCount: chunk.metadata.tokenCount,
},
},
});
knowledgeItemIds.push(knowledgeItem.id);
// Chunk and embed in AlloyDB (fire-and-forget)
(async () => {
try {
const { writeKnowledgeChunksForItem } = await import('@/lib/server/vector-memory');
await writeKnowledgeChunksForItem({
id: knowledgeItem.id,
projectId: knowledgeItem.projectId,
content: knowledgeItem.content,
sourceMeta: knowledgeItem.sourceMeta,
});
} catch (error) {
console.error(`[import-document] Failed to chunk item ${knowledgeItem.id}:`, error);
}
})();
}
// Also create a summary record in contextSources for UI display
const contextSourcesRef = adminDb.collection('projects').doc(projectId).collection('contextSources');
await contextSourcesRef.add({
type: 'document',
name: filename,
summary: `Document with ${chunks.length} chunks (${content.length} characters)`,
connectedAt: new Date(),
metadata: {
chunkCount: chunks.length,
totalChars: content.length,
mimeType: body.mimeType,
knowledgeItemIds,
},
});
return NextResponse.json({
success: true,
filename,
chunkCount: chunks.length,
knowledgeItemIds,
});
} catch (error) {
console.error('[import-document] Failed to import document', error);
return NextResponse.json(
{
error: 'Failed to import document',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
}

View File

@@ -1,105 +0,0 @@
import { NextResponse } from 'next/server';
import { getAdminAuth, getAdminDb } from '@/lib/firebase/admin';
import { getAlloyDbClient } from '@/lib/db/alloydb';
import { GeminiLlmClient } from '@/lib/ai/gemini-client';
import { z } from 'zod';
const ThemeGroupingSchema = z.object({
themes: z.array(z.object({
theme: z.string().describe('A short, descriptive theme name (2-4 words)'),
description: z.string().describe('A brief description of what this theme represents'),
insightIds: z.array(z.string()).describe('Array of insight IDs that belong to this theme'),
})),
});
export async function POST(
request: Request,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params;
// Authentication (skip in development if no auth header)
const authHeader = request.headers.get('Authorization');
const isDevelopment = process.env.NODE_ENV === 'development';
if (!isDevelopment || authHeader?.startsWith('Bearer ')) {
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const token = authHeader.substring(7);
const auth = getAdminAuth();
const decoded = await auth.verifyIdToken(token);
if (!decoded?.uid) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
}
// Get insights from request body
const { insights } = await request.json();
if (!insights || insights.length === 0) {
return NextResponse.json({
success: true,
themes: [],
});
}
console.log('[API /knowledge/themes] Grouping', insights.length, 'insights into themes');
// Prepare insights for AI analysis
const insightsContext = insights.map((insight: any, index: number) =>
`[${insight.id}] ${insight.content?.substring(0, 200) || insight.title}`
).join('\n\n');
// Use AI to group insights into themes
const llm = new GeminiLlmClient();
const systemPrompt = `You are an expert at analyzing and categorizing information. Given a list of insights/knowledge chunks, group them into meaningful themes. Each theme should represent a coherent topic or concept. Aim for 3-7 themes depending on the diversity of content.`;
const userPrompt = `Analyze these insights and group them into themes:
${insightsContext}
Group these insights into themes. Each insight ID is in brackets at the start of each line. Return the themes with their associated insight IDs.`;
try {
const result = await llm.structuredCall({
model: 'gemini',
systemPrompt,
messages: [{ role: 'user', content: userPrompt }],
schema: ThemeGroupingSchema,
temperature: 0.3,
});
console.log('[API /knowledge/themes] Generated', result.themes.length, 'themes');
return NextResponse.json({
success: true,
themes: result.themes,
});
} catch (aiError) {
console.error('[API /knowledge/themes] AI grouping failed:', aiError);
// Fallback: create a single "Ungrouped" theme with all insights
return NextResponse.json({
success: true,
themes: [{
theme: 'All Insights',
description: 'Ungrouped insights',
insightIds: insights.map((i: any) => i.id),
}],
});
}
} catch (error) {
console.error('[API /knowledge/themes] Error:', error);
return NextResponse.json(
{
error: 'Failed to group insights into themes',
details: error instanceof Error ? error.message : String(error)
},
{ status: 500 }
);
}
}

View File

@@ -1,146 +0,0 @@
import { NextResponse } from 'next/server';
import { getAdminAuth, getAdminDb } from '@/lib/firebase/admin';
import { createKnowledgeItem } from '@/lib/server/knowledge';
import type { KnowledgeSourceMeta } from '@/lib/types/knowledge';
// import { chunkDocument } from '@/lib/utils/document-chunker'; // Not needed - Extractor AI handles chunking
import { getStorage } from 'firebase-admin/storage';
export const maxDuration = 60;
export async function POST(
request: Request,
context: { params: Promise<{ projectId: string }> | { projectId: string } }
) {
try {
// Verify auth
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const idToken = authHeader.split('Bearer ')[1];
const adminAuth = getAdminAuth();
let userId: string;
try {
const decodedToken = await adminAuth.verifyIdToken(idToken);
userId = decodedToken.uid;
} catch (error) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
// Handle async params in Next.js 16
const params = 'then' in context.params ? await context.params : context.params;
const projectId = params.projectId;
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
// Parse multipart form data
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
const adminDb = getAdminDb();
const projectSnap = await adminDb.collection('projects').doc(projectId).get();
if (!projectSnap.exists) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}
console.log(`[upload-document] Uploading ${file.name}, size=${file.size}`);
// Read file content
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const content = buffer.toString('utf-8');
// Upload original file to Firebase Storage
const storage = getStorage();
const bucket = storage.bucket();
const storagePath = `projects/${projectId}/documents/${Date.now()}_${file.name}`;
const fileRef = bucket.file(storagePath);
await fileRef.save(buffer, {
metadata: {
contentType: file.type,
metadata: {
uploadedBy: userId,
projectId,
originalFilename: file.name,
uploadedAt: new Date().toISOString(),
},
},
});
// Make file publicly accessible (or use signed URLs if you want private)
await fileRef.makePublic();
const publicUrl = `https://storage.googleapis.com/${bucket.name}/${storagePath}`;
console.log(`[upload-document] File saved to Storage: ${publicUrl}`);
// Store whole document as single knowledge_item (no chunking)
// Extractor AI will collaboratively chunk important sections later
const sourceMeta: KnowledgeSourceMeta = {
origin: 'other',
url: publicUrl,
filename: file.name,
createdAtOriginal: new Date().toISOString(),
importance: 'primary',
tags: ['document', 'uploaded', 'pending_extraction'],
};
const knowledgeItem = await createKnowledgeItem({
projectId,
sourceType: 'imported_document',
title: file.name,
content: content,
sourceMeta,
});
console.log(`[upload-document] Stored whole document as knowledge_item: ${knowledgeItem.id}`);
const knowledgeItemIds = [knowledgeItem.id];
// Create a summary record in contextSources for UI display
const contextSourcesRef = adminDb.collection('projects').doc(projectId).collection('contextSources');
await contextSourcesRef.add({
type: 'document',
name: file.name,
summary: `Document (${content.length} characters) - pending extraction`,
url: publicUrl,
connectedAt: new Date(),
metadata: {
totalChars: content.length,
fileSize: file.size,
mimeType: file.type,
storagePath,
knowledgeItemId: knowledgeItem.id,
uploadedBy: userId,
status: 'pending_extraction',
},
});
return NextResponse.json({
success: true,
filename: file.name,
url: publicUrl,
knowledgeItemId: knowledgeItem.id,
status: 'stored',
message: 'Document stored. Extractor AI will review and chunk important sections.',
});
} catch (error) {
console.error('[upload-document] Failed to upload document', error);
return NextResponse.json(
{
error: 'Failed to upload document',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
}

View File

@@ -1,222 +0,0 @@
import { NextResponse } from 'next/server';
import { getAdminAuth, getAdminDb } from '@/lib/firebase/admin';
import { getAlloyDbClient } from '@/lib/db/alloydb';
import { GeminiLlmClient } from '@/lib/ai/gemini-client';
import { z } from 'zod';
const MissionFrameworkSchema = z.object({
targetCustomer: z.object({
primaryAudience: z.string().describe('Primary narrow target segment (include geography/region if mentioned in context)'),
theirSituation: z.string().describe('What situation or context they are in'),
relatedMarkets: z.array(z.string()).describe('2-4 additional related market segments or customer types that could benefit'),
}),
existingSolutions: z.array(z.object({
category: z.string().describe('Category of solution (e.g., "Legacy EMR Systems", "AI Scribes", "Practice Management", "Open Source")'),
description: z.string().describe('Description of this category and its limitations'),
products: z.array(z.object({
name: z.string().describe('Product/company name'),
url: z.string().optional().describe('Website URL if known'),
})).min(5).max(20).describe('Comprehensive list of 5-20 specific products in this category. Include all major players and notable solutions.'),
})).min(4).max(7).describe('4-7 categories of existing solutions with comprehensive product lists. ALWAYS include an "Open Source" category if applicable to the market.'),
innovations: z.array(z.object({
title: z.string().describe('Short title for this innovation (3-5 words)'),
description: z.string().describe('How this makes you different and better'),
})).describe('3 key innovations or differentiators'),
ideaValidation: z.array(z.object({
title: z.string().describe('Name of this validation metric'),
description: z.string().describe('What success looks like for this metric'),
})).describe('3 ways to validate the idea is sound'),
financialSuccess: z.object({
subscribers: z.number().describe('Target number of subscribers (Year 1)'),
pricePoint: z.number().describe('Monthly price per subscriber in dollars'),
retentionRate: z.number().describe('Target monthly retention rate as a percentage (0-100)'),
}),
});
export async function POST(
request: Request,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params;
// Authentication (skip in development if no auth header)
const authHeader = request.headers.get('Authorization');
const isDevelopment = process.env.NODE_ENV === 'development';
if (!isDevelopment || authHeader?.startsWith('Bearer ')) {
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const token = authHeader.substring(7);
const auth = getAdminAuth();
const decoded = await auth.verifyIdToken(token);
if (!decoded?.uid) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
}
console.log('[API /mission/generate] Generating mission framework for project:', projectId);
// Fetch insights from AlloyDB
let insights: any[] = [];
try {
const pool = await getAlloyDbClient();
const result = await pool.query(
`SELECT content, source_type, importance, created_at
FROM knowledge_chunks
WHERE project_id = $1
ORDER BY importance DESC, created_at DESC
LIMIT 50`,
[projectId]
);
insights = result.rows;
console.log('[API /mission/generate] Found', insights.length, 'insights');
} catch (dbError) {
console.log('[API /mission/generate] No AlloyDB insights available');
}
// Fetch knowledge items from Firestore
let knowledgeItems: any[] = [];
try {
const adminDb = getAdminDb();
const knowledgeSnapshot = await adminDb
.collection('knowledge')
.where('projectId', '==', projectId)
.orderBy('createdAt', 'desc')
.limit(20)
.get();
knowledgeItems = knowledgeSnapshot.docs.map(doc => doc.data());
console.log('[API /mission/generate] Found', knowledgeItems.length, 'knowledge items');
} catch (firestoreError) {
console.log('[API /mission/generate] No Firestore knowledge available');
}
// Get project data
let projectData: any = {};
try {
const adminDb = getAdminDb();
const projectDoc = await adminDb.collection('projects').doc(projectId).get();
if (projectDoc.exists) {
projectData = projectDoc.data();
}
} catch (error) {
console.log('[API /mission/generate] Could not fetch project data');
}
// Build context from available data
const contextParts = [];
if (projectData?.productVision) {
contextParts.push(`Product Vision: ${projectData.productVision}`);
}
if (projectData?.phaseData?.canonicalProductModel) {
const model = projectData.phaseData.canonicalProductModel;
if (model.oneLiner) contextParts.push(`Product Description: ${model.oneLiner}`);
if (model.problem) contextParts.push(`Problem: ${model.problem}`);
if (model.targetUser) contextParts.push(`Target User: ${model.targetUser}`);
if (model.coreSolution) contextParts.push(`Solution: ${model.coreSolution}`);
}
if (insights.length > 0) {
const insightTexts = insights.slice(0, 10).map(i => i.content).join('\n- ');
contextParts.push(`Key Insights:\n- ${insightTexts}`);
}
if (knowledgeItems.length > 0) {
const knowledgeTexts = knowledgeItems.slice(0, 5)
.map(k => k.title || k.content?.substring(0, 100))
.filter(Boolean)
.join('\n- ');
if (knowledgeTexts) {
contextParts.push(`Additional Context:\n- ${knowledgeTexts}`);
}
}
const context = contextParts.length > 0
? contextParts.join('\n\n')
: 'No project context available yet. Please create a generic framework based on best practices for new product development.';
console.log('[API /mission/generate] Context length:', context.length);
// Use AI to generate the mission framework
const llm = new GeminiLlmClient();
const systemPrompt = `You are a product strategy expert. Based on the provided project information, create a comprehensive mission framework that helps the founder clearly articulate their product vision, market position, and success metrics.
CRITICAL: For Target Customer, be VERY SPECIFIC and NARROW:
- Look for geographic/regional targeting in the context (country, state, city, region)
- Look for specific customer segments, verticals, or niches
- Avoid broad generalizations like "all doctors" or "businesses everywhere"
- If region is mentioned, ALWAYS include it in the primary audience
- Target the smallest viable market segment that can sustain the business
Be specific and actionable. Use the project context to inform your recommendations.`;
const userPrompt = `Based on this project information, generate a complete mission framework:
${context}
Create a structured mission framework that includes:
1. Target Customer:
- Primary Audience: Be EXTREMELY SPECIFIC and narrow (include geography if mentioned)
Example: "Solo family practice physicians in rural Oregon" NOT "Primary care doctors"
- Their Situation: What problem/context they face
- Related Markets: List 2-4 other related customer segments that could also benefit
Example: ["Urgent care clinics", "Pediatric specialists in small practices", "Telemedicine providers"]
2. Existing Solutions: Group into 4-7 CATEGORIES (e.g., "Legacy EMR Systems", "AI Medical Scribes", "Open Source", etc.)
- For each category: provide a description of what they do and their limitations
- List 5-20 specific PRODUCTS/COMPANIES in each category with website URLs if you know them
- Be COMPREHENSIVE - include all major players, notable solutions, and emerging alternatives
- ALWAYS include an "Open Source" category listing relevant open-source alternatives (GitHub, frameworks, libraries, tools)
- Include direct competitors, adjacent solutions, and legacy approaches
3. Your Innovations (3 key differentiators)
4. Idea Validation (3 validation metrics)
5. Financial Success (subscribers, price point, retention rate)
Be comprehensive with existing solutions. Be specific and narrow with primary target, but show the range of related markets.`;
const result = await llm.structuredCall({
model: 'gemini',
systemPrompt,
messages: [{ role: 'user', content: userPrompt }],
schema: MissionFrameworkSchema,
temperature: 0.7,
});
console.log('[API /mission/generate] Successfully generated mission framework');
// Store the generated framework in Firestore
try {
const adminDb = getAdminDb();
await adminDb.collection('projects').doc(projectId).update({
'phaseData.missionFramework': result,
'phaseData.missionFrameworkUpdatedAt': new Date(),
});
console.log('[API /mission/generate] Saved framework to Firestore');
} catch (saveError) {
console.error('[API /mission/generate] Could not save framework:', saveError);
}
return NextResponse.json({
success: true,
framework: result,
});
} catch (error) {
console.error('[API /mission/generate] Error:', error);
return NextResponse.json(
{
error: 'Failed to generate mission framework',
details: error instanceof Error ? error.message : String(error)
},
{ status: 500 }
);
}
}

View File

@@ -1,966 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import admin from '@/lib/firebase/admin';
import { GoogleGenerativeAI } from '@google/generative-ai';
import { getApiUrl } from '@/lib/utils/api-url';
import fs from 'fs';
import path from 'path';
/**
* MVP Page & Feature Checklist Generator (AI-Powered)
* Uses Gemini AI with the Vibn MVP Planner agent spec to generate intelligent,
* context-aware plans from project vision answers and existing work
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params;
const db = admin.firestore();
// Check if we have a saved plan
const projectDoc = await db.collection('projects').doc(projectId).get();
const projectData = projectDoc.data();
if (projectData?.mvpChecklist && !request.nextUrl.searchParams.get('regenerate')) {
console.log('Loading saved MVP checklist');
return NextResponse.json({
...projectData.mvpChecklist,
cached: true,
cachedAt: projectData.mvpChecklistGeneratedAt
});
}
// If no checklist exists and not forcing regeneration, return empty state
if (!projectData?.mvpChecklist && !request.nextUrl.searchParams.get('regenerate')) {
console.log('[MVP Generation] No checklist exists - returning empty state');
return NextResponse.json({
error: 'No MVP checklist generated yet',
message: 'Click "Regenerate Plan" to create your MVP checklist',
mvpChecklist: [],
summary: { totalPages: 0, estimatedDays: 0 }
});
}
console.log('[MVP Generation] 🚀 Starting MVP checklist generation...');
// Load complete history
console.log('[MVP Generation] 📊 Loading project history...');
const historyResponse = await fetch(
getApiUrl(`/api/projects/${projectId}/complete-history`, request)
);
const history = await historyResponse.json();
console.log('[MVP Generation] ✅ History loaded');
// Load intelligent analysis (with fallback if project doesn't have codebase access)
console.log('[MVP Generation] 🧠 Running intelligent analysis...');
let analysis = null;
try {
const analysisResponse = await fetch(
getApiUrl(`/api/projects/${projectId}/plan/intelligent`, request)
);
if (analysisResponse.ok) {
analysis = await analysisResponse.json();
console.log('[MVP Generation] ✅ Analysis complete');
} else {
console.log('[MVP Generation] ⚠️ Analysis failed (project may lack codebase access), using fallback');
analysis = { codebaseAnalysis: null, intelligentPlan: null };
}
} catch (error) {
console.log('[MVP Generation] ⚠️ Analysis error:', error instanceof Error ? error.message : String(error));
analysis = { codebaseAnalysis: null, intelligentPlan: null };
}
// Generate MVP checklist using AI
console.log('[MVP Generation] 🤖 Calling AI to generate MVP plan...');
const checklist = await generateAIMVPChecklist(projectId, history, analysis, projectData);
console.log('[MVP Generation] ✅ MVP plan generated!');
// Save to Firestore (filter out undefined values to avoid Firestore errors)
const cleanChecklist = JSON.parse(JSON.stringify(checklist, (key, value) =>
value === undefined ? null : value
));
await db.collection('projects').doc(projectId).update({
mvpChecklist: cleanChecklist,
mvpChecklistGeneratedAt: admin.firestore.FieldValue.serverTimestamp()
});
console.log('[MVP Generation] ✅ MVP checklist saved to Firestore');
return NextResponse.json(checklist);
} catch (error) {
console.error('Error generating MVP checklist:', error);
return NextResponse.json(
{
error: 'Failed to generate MVP checklist',
details: error instanceof Error ? error.message : String(error)
},
{ status: 500 }
);
}
}
/**
* POST to force regeneration of the checklist
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params;
const db = admin.firestore();
console.log('[MVP Generation] 🚀 Starting MVP checklist regeneration...');
// Re-fetch project data
const projectDoc = await db.collection('projects').doc(projectId).get();
const projectData = projectDoc.data();
// Load complete history
console.log('[MVP Generation] 📊 Loading project history...');
const historyResponse = await fetch(
getApiUrl(`/api/projects/${projectId}/complete-history`, request)
);
const history = await historyResponse.json();
console.log('[MVP Generation] ✅ History loaded');
// Load intelligent analysis (with fallback if project doesn't have codebase access)
console.log('[MVP Generation] 🧠 Running intelligent analysis...');
let analysis = null;
try {
const analysisResponse = await fetch(
getApiUrl(`/api/projects/${projectId}/plan/intelligent`, request)
);
if (analysisResponse.ok) {
analysis = await analysisResponse.json();
console.log('[MVP Generation] ✅ Analysis complete');
} else {
console.log('[MVP Generation] ⚠️ Analysis failed (project may lack codebase access), using fallback');
analysis = { codebaseAnalysis: null, intelligentPlan: null };
}
} catch (error) {
console.log('[MVP Generation] ⚠️ Analysis error:', error instanceof Error ? error.message : String(error));
analysis = { codebaseAnalysis: null, intelligentPlan: null };
}
// Generate MVP checklist using AI
console.log('[MVP Generation] 🤖 Calling AI to generate MVP plan...');
const checklist = await generateAIMVPChecklist(projectId, history, analysis, projectData);
console.log('[MVP Generation] ✅ MVP plan generated!');
console.log('[MVP Generation] 📊 Summary:', JSON.stringify(checklist.summary, null, 2));
// Save to Firestore (filter out undefined values to avoid Firestore errors)
const cleanChecklist = JSON.parse(JSON.stringify(checklist, (key, value) =>
value === undefined ? null : value
));
await db.collection('projects').doc(projectId).update({
mvpChecklist: cleanChecklist,
mvpChecklistGeneratedAt: admin.firestore.FieldValue.serverTimestamp()
});
console.log('[MVP Generation] ✅ MVP checklist saved to Firestore');
return NextResponse.json({
...checklist,
regenerated: true
});
} catch (error) {
console.error('[MVP Generation] ❌ Error regenerating MVP checklist:', error);
return NextResponse.json(
{
error: 'Failed to regenerate MVP checklist',
details: error instanceof Error ? error.message : String(error)
},
{ status: 500 }
);
}
}
/**
* Generate AI-powered MVP Checklist using Gemini and the Vibn MVP Planner agent spec
*/
async function generateAIMVPChecklist(
projectId: string,
history: any,
analysis: any,
projectData: any
) {
try {
// Check for Gemini API key
const geminiApiKey = process.env.GEMINI_API_KEY;
if (!geminiApiKey) {
console.warn('[MVP Generation] ⚠️ No GEMINI_API_KEY found, falling back to template-based generation');
return generateFallbackChecklist(history, analysis);
}
console.log('[MVP Generation] 🔑 GEMINI_API_KEY found, using AI generation');
// Load the agent spec
const agentSpecPath = path.join(process.cwd(), '..', 'vibn-vision', 'initial-questions.json');
const agentSpec = JSON.parse(fs.readFileSync(agentSpecPath, 'utf-8'));
console.log('[MVP Generation] 📋 Agent spec loaded');
// Initialize Gemini
const genAI = new GoogleGenerativeAI(geminiApiKey);
const model = genAI.getGenerativeModel({
model: "gemini-2.0-flash-exp",
generationConfig: {
temperature: 0.4,
topP: 0.95,
topK: 40,
maxOutputTokens: 8192,
responseMimeType: "application/json",
},
});
console.log('[MVP Generation] 🤖 Gemini model initialized (gemini-2.0-flash-exp)');
// Prepare vision input from project data
const visionInput = prepareVisionInput(projectData, history);
console.log('[MVP Generation] 📝 Vision input prepared:', {
q1: visionInput.q1_who_and_problem.raw_answer?.substring(0, 50) + '...',
q2: visionInput.q2_story.raw_answer?.substring(0, 50) + '...',
q3: visionInput.q3_improvement.raw_answer?.substring(0, 50) + '...'
});
// Log what data we have vs missing
console.log('[MVP Generation] 📊 Data availability check:');
console.log(' ✅ Vision answers:', !!projectData.visionAnswers);
console.log(' ✅ GitHub repo:', projectData.githubRepo || 'None');
console.log(' ⚠️ GitHub userId:', projectData.userId || 'MISSING - cannot load repo code');
console.log(' ✅ Git commits:', history.gitSummary?.totalCommits || 0);
console.log(' ✅ Cursor sessions:', history.summary?.breakdown?.extensionSessions || 0);
console.log(' ✅ Codebase analysis:', analysis.codebaseAnalysis?.builtFeatures?.length || 0, 'features found');
// Load Cursor conversation history from Firestore
console.log('[MVP Generation] 💬 Loading Cursor conversation history...');
const adminDb = admin.firestore();
let cursorConversations: any[] = [];
let cursorMessageCount = 0;
try {
const conversationsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.orderBy('lastUpdatedAt', 'desc')
.limit(10) // Get most recent 10 conversations
.get();
for (const convDoc of conversationsSnapshot.docs) {
const convData = convDoc.data();
const messagesSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.doc(convDoc.id)
.collection('messages')
.orderBy('createdAt', 'asc')
.limit(50) // Limit messages per conversation to avoid token bloat
.get();
const messages = messagesSnapshot.docs.map(msgDoc => {
const msg = msgDoc.data();
return {
role: msg.type === 1 ? 'user' : 'assistant',
text: msg.text || '',
createdAt: msg.createdAt
};
});
cursorMessageCount += messages.length;
cursorConversations.push({
name: convData.name || 'Untitled',
messageCount: messages.length,
messages: messages,
createdAt: convData.createdAt,
lastUpdatedAt: convData.lastUpdatedAt
});
}
console.log('[MVP Generation] ✅ Loaded', cursorConversations.length, 'Cursor conversations with', cursorMessageCount, 'messages');
} catch (error) {
console.error('[MVP Generation] ⚠️ Failed to load Cursor conversations:', error);
}
// Prepare work_to_date context with all available data
const githubSummary = history.gitSummary
? `${history.gitSummary.totalCommits || 0} commits, ${history.gitSummary.filesChanged || 0} files changed`
: 'No Git history available';
const codebaseSummary = analysis.codebaseAnalysis?.summary
|| (analysis.codebaseAnalysis?.builtFeatures?.length > 0
? `Built: ${analysis.codebaseAnalysis.builtFeatures.map((f: any) => f.name).join(', ')}`
: 'No codebase analysis available');
const cursorSessionsSummary = cursorConversations.length > 0
? `${cursorConversations.length} Cursor conversations with ${cursorMessageCount} messages imported from Cursor IDE`
: 'No Cursor conversation history available';
// Format Cursor conversations for the prompt
const cursorContextText = cursorConversations.length > 0
? cursorConversations.map(conv =>
`Conversation: "${conv.name}" (${conv.messageCount} messages)\n` +
conv.messages.slice(0, 10).map((m: any) => ` ${m.role}: ${m.text.substring(0, 200)}`).join('\n')
).join('\n\n')
: '';
const workToDate = {
code_summary: codebaseSummary,
github_summary: githubSummary,
cursor_sessions_summary: cursorSessionsSummary,
cursor_conversations: cursorContextText, // Include actual conversation snippets
existing_assets_notes: `Built features: ${analysis.codebaseAnalysis?.builtFeatures?.length || 0}, Missing: ${analysis.codebaseAnalysis?.missingFeatures?.length || 0}`
};
console.log('[MVP Generation] 🔍 Work context prepared:', {
...workToDate,
cursor_conversations: cursorContextText.length > 0 ? `${cursorContextText.length} chars from conversations` : 'None'
});
// Build the prompt with agent spec instructions
const prompt = `${agentSpec.agent_spec.instructions_for_model}
Here is the input data:
${JSON.stringify({
vision_input: visionInput,
work_to_date: workToDate
}, null, 2)}
Return ONLY valid JSON matching the output schema, with no additional text or markdown.`;
console.log('[MVP Generation] 📤 Sending prompt to Gemini (length:', prompt.length, 'chars)');
// Call Gemini
const result = await model.generateContent(prompt);
const response = result.response;
const text = response.text();
console.log('[MVP Generation] 📥 Received AI response (length:', text.length, 'chars)');
// Parse AI response (Gemini returns JSON directly with responseMimeType set)
const aiResponse = JSON.parse(text);
console.log('[MVP Generation] ✅ AI response parsed successfully');
console.log('[MVP Generation] 🔍 AI Response structure:', JSON.stringify({
has_journey_tree: !!aiResponse.journey_tree,
has_touchpoints_tree: !!aiResponse.touchpoints_tree,
has_system_tree: !!aiResponse.system_tree,
journey_nodes: aiResponse.journey_tree?.nodes?.length || 0,
touchpoints_nodes: aiResponse.touchpoints_tree?.nodes?.length || 0,
system_nodes: aiResponse.system_tree?.nodes?.length || 0,
summary: aiResponse.summary
}, null, 2));
// Transform AI trees into our existing format
const checklist = transformAIResponseToChecklist(aiResponse, history, analysis);
console.log('[MVP Generation] ✅ Checklist transformed, total pages:', checklist.summary?.totalPages || 0);
return checklist;
} catch (error) {
console.error('[MVP Generation] ❌ Error generating AI MVP checklist:', error);
console.warn('[MVP Generation] ⚠️ Falling back to template-based generation');
return generateFallbackChecklist(history, analysis);
}
}
/**
* Fallback to template-based generation if AI fails
*/
function generateFallbackChecklist(history: any, analysis: any) {
const vision = history.project.vision || '';
const builtFeatures = analysis.codebaseAnalysis?.builtFeatures || [];
const missingFeatures = analysis.codebaseAnalysis?.missingFeatures || [];
// Scan commit messages for evidence of pages
const commitMessages = history.chronologicalEvents
.filter((e: any) => e.type === 'git_commit')
.map((e: any) => e.data.message);
// Simple flat taxonomy structure (existing template)
const corePages = [
{
category: 'Core Features',
pages: [
{
path: '/auth',
title: 'Authentication',
status: detectPageStatus('auth', commitMessages, builtFeatures),
priority: 'critical',
evidence: findEvidence('auth', commitMessages)
},
{
path: '/[workspace]',
title: 'Workspace Selector',
status: detectPageStatus('workspace', commitMessages, builtFeatures),
priority: 'critical',
evidence: findEvidence('workspace', commitMessages)
},
{
path: '/[workspace]/projects',
title: 'Projects List',
status: detectPageStatus('projects page', commitMessages, builtFeatures),
priority: 'critical',
evidence: findEvidence('projects list', commitMessages)
},
{
path: '/project/[id]/overview',
title: 'Project Dashboard',
status: detectPageStatus('overview', commitMessages, builtFeatures),
priority: 'critical',
evidence: findEvidence('overview', commitMessages)
},
{
path: '/project/[id]/mission',
title: 'Vision/Mission Screen',
status: detectPageStatus('mission|vision', commitMessages, builtFeatures),
priority: 'critical',
evidence: findEvidence('vision|mission', commitMessages)
},
{
path: '/project/[id]/audit',
title: 'Project History & Audit',
status: detectPageStatus('audit', commitMessages, builtFeatures),
priority: 'high',
evidence: findEvidence('audit', commitMessages)
},
{
path: '/project/[id]/timeline-plan',
title: 'MVP Timeline & Checklist',
status: detectPageStatus('timeline-plan', commitMessages, builtFeatures),
priority: 'critical',
evidence: findEvidence('timeline-plan', commitMessages)
},
{
path: '/api/github/oauth',
title: 'GitHub OAuth API',
status: detectPageStatus('github/oauth', commitMessages, builtFeatures),
priority: 'critical',
evidence: findEvidence('github oauth', commitMessages)
},
{
path: '/api/projects',
title: 'Project Management APIs',
status: detectPageStatus('api/projects', commitMessages, builtFeatures),
priority: 'critical',
evidence: findEvidence('project api', commitMessages)
},
{
path: '/api/projects/[id]/mvp-checklist',
title: 'MVP Checklist Generation API',
status: detectPageStatus('mvp-checklist', commitMessages, builtFeatures),
priority: 'critical',
evidence: findEvidence('mvp-checklist', commitMessages)
}
]
},
{
category: 'Flows',
pages: [
{
path: 'flow/onboarding',
title: 'User Onboarding Flow',
status: 'in_progress',
priority: 'critical',
evidence: [],
note: 'Sign Up → Workspace Creation → Connect GitHub'
},
{
path: 'flow/project-creation',
title: 'Project Creation Flow',
status: 'in_progress',
priority: 'critical',
evidence: findEvidence('project creation', commitMessages),
note: 'Import/New Project → Repository → History Import → Vision Setup'
},
{
path: 'flow/plan-generation',
title: 'Plan Generation Flow',
status: 'in_progress',
priority: 'critical',
evidence: findEvidence('plan', commitMessages),
note: 'Context Analysis → MVP Checklist → Timeline View'
}
]
},
{
category: 'Marketing',
pages: [
{
path: '/project/[id]/marketing',
title: 'Marketing Dashboard',
status: 'missing',
priority: 'high',
evidence: [],
note: 'Have /plan/marketing API but no UI'
},
{
path: '/api/projects/[id]/plan/marketing',
title: 'Marketing Plan Generation API',
status: detectPageStatus('marketing api', commitMessages, builtFeatures),
priority: 'high',
evidence: findEvidence('marketing', commitMessages)
},
{
path: '/',
title: 'Marketing Landing Page',
status: detectPageStatus('marketing page', commitMessages, builtFeatures),
priority: 'high',
evidence: findEvidence('marketing site|landing', commitMessages)
}
]
},
{
category: 'Social',
pages: [
{
path: '/[workspace]/connections',
title: 'Social Connections & Integrations',
status: detectPageStatus('connections', commitMessages, builtFeatures),
priority: 'medium',
evidence: findEvidence('connections', commitMessages)
}
]
},
{
category: 'Content',
pages: [
{
path: '/docs',
title: 'Documentation Pages',
status: 'missing',
priority: 'medium',
evidence: []
},
{
path: '/project/[id]/getting-started',
title: 'Getting Started Guide',
status: detectPageStatus('getting-started', commitMessages, builtFeatures),
priority: 'medium',
evidence: findEvidence('getting-started|onboarding', commitMessages)
}
]
},
{
category: 'Settings',
pages: [
{
path: '/project/[id]/settings',
title: 'Project Settings',
status: detectPageStatus('settings', commitMessages, builtFeatures),
priority: 'high',
evidence: findEvidence('settings', commitMessages)
},
{
path: '/[workspace]/settings',
title: 'User Settings',
status: detectPageStatus('settings', commitMessages, builtFeatures),
priority: 'medium',
evidence: findEvidence('settings', commitMessages)
}
]
}
];
// Calculate statistics
const allPages = corePages.flatMap(c => c.pages);
const builtCount = allPages.filter(p => p.status === 'built').length;
const inProgressCount = allPages.filter(p => p.status === 'in_progress').length;
const missingCount = allPages.filter(p => p.status === 'missing').length;
return {
project: {
name: history.project.name,
vision: history.project.vision,
githubRepo: history.project.githubRepo
},
summary: {
totalPages: allPages.length,
built: builtCount,
inProgress: inProgressCount,
missing: missingCount,
completionPercentage: Math.round((builtCount / allPages.length) * 100)
},
visionSummary: extractVisionPillars(vision),
mvpChecklist: corePages,
nextSteps: generateNextSteps(corePages, missingFeatures),
generatedAt: new Date().toISOString(),
// Empty trees for fallback (will be populated when AI generation works)
journeyTree: { label: "Journey", nodes: [] },
touchpointsTree: { label: "Touchpoints", nodes: [] },
systemTree: { label: "System", nodes: [] },
};
}
function detectPageStatus(pagePath: string, commitMessages: string[], builtFeatures: any[]): string {
const searchTerms = pagePath.split('|');
for (const term of searchTerms) {
const hasCommit = commitMessages.some(msg =>
msg.toLowerCase().includes(term.toLowerCase())
);
const hasFeature = builtFeatures.some(f =>
f.name.toLowerCase().includes(term.toLowerCase()) ||
f.evidence.some((e: string) => e.toLowerCase().includes(term.toLowerCase()))
);
if (hasCommit || hasFeature) {
return 'built';
}
}
return 'missing';
}
function findEvidence(searchTerm: string, commitMessages: string[]): string[] {
const terms = searchTerm.split('|');
const evidence: string[] = [];
for (const term of terms) {
const matches = commitMessages.filter(msg =>
msg.toLowerCase().includes(term.toLowerCase())
);
evidence.push(...matches.slice(0, 2));
}
return evidence;
}
function extractVisionPillars(vision: string): string[] {
const pillars = [];
if (vision.includes('start from scratch') || vision.includes('import')) {
pillars.push('Project ingestion (start from scratch or import existing work)');
}
if (vision.includes('understand') || vision.includes('vision')) {
pillars.push('Project understanding (vision, history, structure, metadata)');
}
if (vision.includes('plan') || vision.includes('checklist')) {
pillars.push('Project planning (auto-generated v1 roadmap/checklist)');
}
if (vision.includes('marketing') || vision.includes('communication') || vision.includes('automation')) {
pillars.push('Automation + AI support (marketing, chat, context-aware support)');
}
return pillars;
}
function generateNextSteps(corePages: any[], missingFeatures: any[]): any[] {
const steps = [];
// Find critical missing pages
const criticalMissing = corePages
.flatMap(c => c.pages)
.filter(p => p.status === 'missing' && p.priority === 'critical');
for (const page of criticalMissing.slice(0, 3)) {
steps.push({
priority: 1,
task: `Build ${page.title}`,
path: page.path || '',
reason: page.note || 'Critical for MVP launch'
});
}
// Add missing features
if (missingFeatures && Array.isArray(missingFeatures)) {
for (const feature of missingFeatures.slice(0, 2)) {
if (feature && (feature.feature || feature.task)) {
steps.push({
priority: 2,
task: feature.feature || feature.task || 'Complete missing feature',
reason: feature.reason || 'Important for MVP'
});
}
}
}
return steps;
}
/**
* Prepare vision input from project data
* Maps project vision to the 3-question format
*/
function prepareVisionInput(projectData: any, history: any) {
const vision = projectData.vision || history.project?.vision || '';
// Try to extract answers from vision field
// If vision is structured with questions, parse them
// Otherwise, treat entire vision as the story (q2)
return {
q1_who_and_problem: {
prompt: "Who has the problem you want to fix and what is it?",
raw_answer: projectData.visionAnswers?.q1 || extractProblemFromVision(vision) || vision
},
q2_story: {
prompt: "Tell me a story of this person using your tool and experiencing your vision?",
raw_answer: projectData.visionAnswers?.q2 || vision
},
q3_improvement: {
prompt: "How much did that improve things for them?",
raw_answer: projectData.visionAnswers?.q3 || extractImprovementFromVision(vision) || 'Significantly faster and more efficient workflow'
}
};
}
/**
* Extract problem statement from unstructured vision
*/
function extractProblemFromVision(vision: string): string {
// Simple heuristic: Look for problem-related keywords
const problemKeywords = ['problem', 'struggle', 'difficult', 'challenge', 'pain', 'need'];
const sentences = vision.split(/[.!?]+/);
for (const sentence of sentences) {
const lowerSentence = sentence.toLowerCase();
if (problemKeywords.some(keyword => lowerSentence.includes(keyword))) {
return sentence.trim();
}
}
return vision.split(/[.!?]+/)[0]?.trim() || vision;
}
/**
* Extract improvement/value from unstructured vision
*/
function extractImprovementFromVision(vision: string): string {
// Look for value/benefit keywords
const valueKeywords = ['faster', 'better', 'easier', 'save', 'improve', 'automate', 'help'];
const sentences = vision.split(/[.!?]+/);
for (const sentence of sentences) {
const lowerSentence = sentence.toLowerCase();
if (valueKeywords.some(keyword => lowerSentence.includes(keyword))) {
return sentence.trim();
}
}
return '';
}
/**
* Transform AI response trees into our existing checklist format
*/
function transformAIResponseToChecklist(aiResponse: any, history: any, analysis: any) {
const { journey_tree, touchpoints_tree, system_tree, summary } = aiResponse;
// Scan commit messages for evidence
const commitMessages = history.chronologicalEvents
?.filter((e: any) => e.type === 'git_commit')
?.map((e: any) => e.data.message) || [];
const builtFeatures = analysis.codebaseAnalysis?.builtFeatures || [];
// Combine touchpoints and system into categories
const categories: any[] = [];
// Process Touchpoints tree
if (touchpoints_tree?.nodes) {
const touchpointCategories = groupAssetsByCategory(
touchpoints_tree.nodes,
'touchpoint',
commitMessages,
builtFeatures
);
categories.push(...touchpointCategories);
}
// Process System tree
if (system_tree?.nodes) {
const systemCategories = groupAssetsByCategory(
system_tree.nodes,
'system',
commitMessages,
builtFeatures
);
categories.push(...systemCategories);
}
// Calculate statistics
const allPages = categories.flatMap(c => c.pages);
const builtCount = allPages.filter((p: any) => p.status === 'built').length;
const inProgressCount = allPages.filter((p: any) => p.status === 'in_progress').length;
const missingCount = allPages.filter((p: any) => p.status === 'missing').length;
return {
project: {
name: history.project.name,
vision: history.project.vision,
githubRepo: history.project.githubRepo
},
summary: {
totalPages: allPages.length,
built: builtCount,
inProgress: inProgressCount,
missing: missingCount,
completionPercentage: Math.round((builtCount / allPages.length) * 100)
},
visionSummary: [summary || 'AI-generated MVP plan'],
mvpChecklist: categories,
nextSteps: generateNextStepsFromAI(allPages),
generatedAt: new Date().toISOString(),
aiGenerated: true,
// Include raw trees for Journey/Design/Tech views
journeyTree: journey_tree,
touchpointsTree: touchpoints_tree,
systemTree: system_tree,
};
}
/**
* Group asset nodes by category
*/
function groupAssetsByCategory(
nodes: any[],
listType: 'touchpoint' | 'system',
commitMessages: string[],
builtFeatures: any[]
) {
const categoryMap = new Map<string, any[]>();
for (const node of nodes) {
const category = inferCategory(node, listType);
if (!categoryMap.has(category)) {
categoryMap.set(category, []);
}
const page = {
id: node.id,
path: inferPath(node),
title: node.name,
status: detectAINodeStatus(node, commitMessages, builtFeatures),
priority: node.must_have_for_v1 ? 'critical' : 'medium',
evidence: findEvidenceForNode(node, commitMessages),
note: node.asset_metadata?.why_it_exists,
metadata: node.asset_metadata,
requirements: flattenChildrenToRequirements(node.children)
};
categoryMap.get(category)!.push(page);
}
return Array.from(categoryMap.entries()).map(([category, pages]) => ({
category,
pages
}));
}
/**
* Infer category from node metadata
*/
function inferCategory(node: any, listType: 'touchpoint' | 'system'): string {
const assetType = node.asset_type;
const journeyStage = node.asset_metadata?.journey_stage || '';
if (listType === 'system') {
if (assetType === 'api_endpoint' || assetType === 'service') return 'Core Features';
if (assetType === 'integration') return 'Settings';
return 'Settings';
}
// Touchpoints
if (assetType === 'flow') return 'Flows';
if (assetType === 'social_post') return 'Social';
if (assetType === 'document') return 'Content';
if (assetType === 'email') return 'Marketing';
if (journeyStage.toLowerCase().includes('aware') || journeyStage.toLowerCase().includes('discover')) {
return 'Marketing';
}
return 'Core Features';
}
/**
* Infer path from node
*/
function inferPath(node: any): string {
// Try to extract path from implementation_notes or name
const implNotes = node.asset_metadata?.implementation_notes || '';
const pathMatch = implNotes.match(/\/[\w\-\/\[\]]+/);
if (pathMatch) return pathMatch[0];
// Generate a reasonable path from name and type
const slug = node.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9\-]/g, '');
if (node.asset_type === 'api_endpoint') return `/api/${slug}`;
if (node.asset_type === 'flow') return `flow/${slug}`;
return `/${slug}`;
}
/**
* Detect status of AI node based on existing work
*/
function detectAINodeStatus(node: any, commitMessages: string[], builtFeatures: any[]): string {
const name = node.name.toLowerCase();
const path = inferPath(node).toLowerCase();
// Check commit messages
const hasCommit = commitMessages.some(msg =>
msg.toLowerCase().includes(name) || msg.toLowerCase().includes(path)
);
// Check built features
const hasFeature = builtFeatures.some((f: any) =>
f.name?.toLowerCase().includes(name) ||
f.evidence?.some((e: string) => e.toLowerCase().includes(name))
);
if (hasCommit || hasFeature) return 'built';
return node.must_have_for_v1 ? 'missing' : 'missing';
}
/**
* Find evidence for a node in commit messages
*/
function findEvidenceForNode(node: any, commitMessages: string[]): string[] {
const name = node.name.toLowerCase();
const evidence = commitMessages
.filter(msg => msg.toLowerCase().includes(name))
.slice(0, 2);
return evidence;
}
/**
* Flatten children nodes to requirements
*/
function flattenChildrenToRequirements(children: any[]): any[] {
if (!children || children.length === 0) return [];
return children.map((child, index) => ({
id: index + 1,
text: child.name,
status: 'missing'
}));
}
/**
* Generate next steps from AI-generated pages
*/
function generateNextStepsFromAI(pages: any[]): any[] {
const criticalMissing = pages
.filter((p: any) => p.status === 'missing' && p.priority === 'critical')
.slice(0, 5);
return criticalMissing.map((page: any, index: number) => ({
priority: index + 1,
task: `Build ${page.title}`,
path: page.path || '',
reason: page.note || 'Critical for MVP V1'
}));
}

View File

@@ -1,346 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getApiUrl } from '@/lib/utils/api-url';
const execAsync = promisify(exec);
/**
* Intelligent V1 Launch Planning
* Analyzes ACTUAL codebase to generate specific, actionable tasks
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params;
// 1. Load project context
const contextResponse = await fetch(
getApiUrl(`/api/projects/${projectId}/context`, request)
);
const context = await contextResponse.json();
// 2. Scan actual codebase structure
const repoPath = '/Users/markhenderson/ai-proxy';
const { stdout: pagesOutput } = await execAsync(
`cd "${repoPath}" && find vibn-frontend/app -name "*.tsx" | grep "page.tsx" | wc -l`
);
const { stdout: apiOutput } = await execAsync(
`cd "${repoPath}" && find vibn-frontend/app/api -name "route.ts" | wc -l`
);
const { stdout: componentsOutput } = await execAsync(
`cd "${repoPath}" && find vibn-frontend/components -name "*.tsx" 2>/dev/null | wc -l || echo 0`
);
const codebaseStats = {
totalPages: parseInt(pagesOutput.trim()),
totalAPIs: parseInt(apiOutput.trim()),
totalComponents: parseInt(componentsOutput.trim())
};
// 3. Analyze what's ACTUALLY built vs vision
const analysis = await analyzeRealCodebase(context, codebaseStats, repoPath);
// 4. Generate intelligent, specific tasks
const intelligentPlan = generateIntelligentPlan(context, analysis);
return NextResponse.json({
projectContext: {
name: context.project.name,
vision: context.project.vision
},
codebaseAnalysis: analysis,
intelligentPlan,
confidence: analysis.confidence
});
} catch (error) {
console.error('Error generating intelligent plan:', error);
return NextResponse.json(
{
error: 'Failed to generate intelligent plan',
details: error instanceof Error ? error.message : String(error)
},
{ status: 500 }
);
}
}
async function analyzeRealCodebase(context: any, stats: any, repoPath: string) {
const analysis: any = {
builtFeatures: [],
missingFeatures: [],
confidence: 'high',
specificInsights: []
};
// Analyze actual file structure
const { stdout: pagesListOutput } = await execAsync(
`cd "${repoPath}" && find vibn-frontend/app -name "page.tsx" | sed 's|vibn-frontend/app/||' | sed 's|/page.tsx||'`
);
const actualPages = pagesListOutput.trim().split('\n').filter(p => p);
const { stdout: apiListOutput } = await execAsync(
`cd "${repoPath}" && find vibn-frontend/app/api -name "route.ts" | sed 's|vibn-frontend/app/api/||' | sed 's|/route.ts||'`
);
const actualAPIs = apiListOutput.trim().split('\n').filter(a => a);
// Analyze based on vision: "VIBN gets your project to v1 launch and beyond"
const vision = context.project.vision || '';
// Check for key features mentioned in vision
const visionKeywords = {
'project plan': { pages: ['plan', 'getting-started'], apis: ['plan/'] },
'marketing automation': { pages: ['marketing'], apis: ['plan/marketing'] },
'communication automation': { pages: ['communication', 'chat'], apis: ['ai/chat'] },
'ai chat support': { pages: ['chat', 'v_ai_chat'], apis: ['ai/chat'] }
};
// Analyze what's built
if (actualPages.some(p => p.includes('plan') || p.includes('getting-started'))) {
analysis.builtFeatures.push({
name: 'Project Planning System',
evidence: actualPages.filter(p => p.includes('plan')).slice(0, 3),
status: 'built'
});
}
if (actualPages.some(p => p.includes('v_ai_chat')) && actualAPIs.some(a => a.includes('ai/chat'))) {
analysis.builtFeatures.push({
name: 'AI Chat Interface',
evidence: ['v_ai_chat page', 'ai/chat API'],
status: 'built'
});
}
if (actualAPIs.some(a => a.includes('github/'))) {
analysis.builtFeatures.push({
name: 'GitHub Integration',
evidence: actualAPIs.filter(a => a.includes('github/')),
status: 'built'
});
}
if (actualAPIs.some(a => a.includes('cursor/'))) {
analysis.builtFeatures.push({
name: 'Cursor History Import',
evidence: actualAPIs.filter(a => a.includes('cursor/')),
status: 'built'
});
}
if (actualAPIs.some(a => a.includes('sessions/'))) {
analysis.builtFeatures.push({
name: 'Session Tracking',
evidence: ['sessions/track', 'sessions/associate-project'],
status: 'built'
});
}
if (actualPages.some(p => p.includes('audit'))) {
analysis.builtFeatures.push({
name: 'Project Audit Report',
evidence: ['audit page', 'audit/generate API'],
status: 'built'
});
}
// Identify gaps based on vision
if (vision.includes('marketing automation') && !actualPages.some(p => p.includes('marketing'))) {
analysis.missingFeatures.push({
name: 'Marketing Automation UI',
reason: 'Mentioned in vision but no UI found',
priority: 'high'
});
}
if (vision.includes('communication automation')) {
const hasCommAutomation = actualAPIs.some(a =>
a.includes('email') || a.includes('slack') || a.includes('notification')
);
if (!hasCommAutomation) {
analysis.missingFeatures.push({
name: 'Communication Automation',
reason: 'Mentioned in vision but no APIs found',
priority: 'high'
});
}
}
// Check for production readiness
if (!actualAPIs.some(a => a.includes('health') || a.includes('status'))) {
analysis.missingFeatures.push({
name: 'Health Check Endpoint',
reason: 'Needed for production monitoring',
priority: 'medium'
});
}
// Check for onboarding
const hasOnboarding = actualPages.some(p => p.includes('getting-started') || p.includes('onboarding'));
if (hasOnboarding) {
analysis.builtFeatures.push({
name: 'User Onboarding Flow',
evidence: actualPages.filter(p => p.includes('getting-started')),
status: 'built'
});
} else {
analysis.missingFeatures.push({
name: 'User Onboarding Tutorial',
reason: 'Critical for first-time users',
priority: 'high'
});
}
// Check for task management
const hasTaskUI = actualPages.some(p => p.includes('task') || p.includes('checklist') || p.includes('todo'));
if (!hasTaskUI && actualAPIs.some(a => a.includes('plan/'))) {
analysis.missingFeatures.push({
name: 'Task Management UI',
reason: 'Have plan APIs but no UI to track tasks',
priority: 'high'
});
}
// Specific insights from commit history
const recentCommits = context.codebase?.topFiles || [];
if (recentCommits.length > 0) {
analysis.specificInsights.push(
`Recently worked on: ${recentCommits.slice(0, 3).map((f: any) => f.filePath.split('/').pop()).join(', ')}`
);
}
// Activity insights
const topFiles = context.activity?.topEditedFiles || [];
if (topFiles.length > 0) {
const topFile = topFiles[0].file.split('/').pop();
analysis.specificInsights.push(`Most edited: ${topFile} (${topFiles[0].count} times)`);
}
return analysis;
}
function generateIntelligentPlan(context: any, analysis: any) {
const plan = {
summary: `Based on ${analysis.builtFeatures.length} built features and ${analysis.missingFeatures.length} identified gaps`,
categories: [] as any[]
};
// Product Completion (based on what's actually missing)
const productTasks = [];
for (const missing of analysis.missingFeatures) {
if (missing.name === 'Task Management UI') {
productTasks.push({
id: `prod-task-ui`,
title: 'Build Task Management UI',
description: `You have plan/simulate API but no UI. Create a checklist interface to show and track V1 launch tasks.`,
status: 'pending',
priority: missing.priority,
specificTo: 'Your codebase has the backend but missing frontend'
});
}
if (missing.name === 'Marketing Automation UI') {
productTasks.push({
id: `prod-mkt-ui`,
title: 'Build Marketing Automation Dashboard',
description: `Your vision mentions marketing automation. Create UI for /plan/marketing API to manage campaigns.`,
status: 'pending',
priority: missing.priority,
specificTo: 'Mentioned in your vision statement'
});
}
if (missing.name === 'Communication Automation') {
productTasks.push({
id: `prod-comm-auto`,
title: 'Add Communication Automation',
description: `Build email/Slack notification system for project updates and milestones.`,
status: 'pending',
priority: missing.priority,
specificTo: 'Core to your vision: "communication automation"'
});
}
if (missing.name === 'User Onboarding Tutorial') {
productTasks.push({
id: `prod-onboard`,
title: 'Create Interactive Onboarding',
description: `Guide new users through: 1) New vs existing project, 2) GitHub connect, 3) Run Cursor import, 4) Define vision.`,
status: 'pending',
priority: missing.priority,
specificTo: 'Your vision flow from earlier conversation'
});
}
}
if (productTasks.length > 0) {
plan.categories.push({
name: 'Product Completion',
status: 'in_progress',
description: 'Missing features identified from your codebase and vision',
tasks: productTasks
});
}
// Polish Existing Features (based on what's built but might need work)
const polishTasks = [];
for (const built of analysis.builtFeatures) {
if (built.name === 'Project Planning System') {
polishTasks.push({
id: 'polish-plan',
title: 'Connect Planning APIs to UI',
description: `You have /plan/mvp, /plan/marketing, /plan/simulate APIs. Ensure they're all wired to your ${built.evidence.length} planning pages.`,
status: 'in_progress',
priority: 'high',
specificTo: `Found ${built.evidence.length} planning pages in your codebase`
});
}
}
if (polishTasks.length > 0) {
plan.categories.push({
name: 'Polish & Integration',
status: 'in_progress',
description: 'Connect your existing features together',
tasks: polishTasks
});
}
// Launch Readiness (production concerns)
const launchTasks = [
{
id: 'launch-monitoring',
title: 'Add Production Monitoring',
description: `Add health check endpoint and error tracking for your ${context.codebase?.totalCommits} commits of code.`,
status: 'pending',
priority: 'high',
specificTo: 'Your 104k lines of code need monitoring'
},
{
id: 'launch-docs',
title: 'Document All Features',
description: `Create docs for your ${analysis.builtFeatures.length} built features: ${analysis.builtFeatures.map((f: any) => f.name).join(', ')}.`,
status: 'pending',
priority: 'medium',
specificTo: `Specific to your ${analysis.builtFeatures.length} features`
},
{
id: 'launch-demo',
title: 'Create Demo Video',
description: `Show: GitHub import → Cursor analysis → AI chat → Launch plan. Highlight your unique value.`,
status: 'pending',
priority: 'high',
specificTo: 'Your specific user journey'
}
];
plan.categories.push({
name: 'Launch Preparation',
status: 'pending',
description: 'Get ready for public launch',
tasks: launchTasks
});
return plan;
}

View File

@@ -1,30 +0,0 @@
import { NextResponse } from 'next/server';
import { GeminiLlmClient } from '@/lib/ai/gemini-client';
import { runMarketingPlanning } from '@/lib/ai/marketing-agent';
export async function POST(
_request: Request,
{ params }: { params: Promise<{ projectId: string }> },
) {
try {
const { projectId } = await params;
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
const llm = new GeminiLlmClient();
const marketingPlan = await runMarketingPlanning(projectId, llm);
return NextResponse.json({ marketingPlan });
} catch (error) {
console.error('[plan/marketing] Failed to generate marketing plan', error);
return NextResponse.json(
{
error: 'Failed to generate marketing plan',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
}

View File

@@ -1,30 +0,0 @@
import { NextResponse } from 'next/server';
import { GeminiLlmClient } from '@/lib/ai/gemini-client';
import { runMvpPlanning } from '@/lib/ai/mvp-agent';
export async function POST(
_request: Request,
{ params }: { params: Promise<{ projectId: string }> },
) {
try {
const { projectId } = await params;
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
const llm = new GeminiLlmClient();
const mvpPlan = await runMvpPlanning(projectId, llm);
return NextResponse.json({ mvpPlan });
} catch (error) {
console.error('[plan/mvp] Failed to generate MVP plan', error);
return NextResponse.json(
{
error: 'Failed to generate MVP plan',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
}

View File

@@ -1,403 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { getApiUrl } from '@/lib/utils/api-url';
/**
* Simulates AI-powered V1 Launch Planning
* Uses complete project context to generate actionable plan
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params;
// 1. Load complete project context
const contextResponse = await fetch(
getApiUrl(`/api/projects/${projectId}/context`, request)
);
if (!contextResponse.ok) {
return NextResponse.json(
{ error: 'Failed to load project context' },
{ status: 500 }
);
}
const context = await contextResponse.json();
// 2. Simulate AI Analysis
const aiAnalysis = analyzeProjectForV1Launch(context);
// 3. Generate V1 Launch Plan
const launchPlan = generateV1LaunchPlan(context, aiAnalysis);
return NextResponse.json({
projectContext: {
name: context.project.name,
vision: context.project.vision,
historicalData: {
totalDays: context.timeline.dateRange.totalDays,
activeDays: context.timeline.dateRange.activeDays,
commits: context.codebase.totalCommits,
sessions: context.activity.totalSessions,
messages: context.timeline.dataSources.cursor.totalMessages
}
},
aiAnalysis,
launchPlan,
nextSteps: generateNextSteps(launchPlan)
});
} catch (error) {
console.error('Error simulating launch plan:', error);
return NextResponse.json(
{
error: 'Failed to simulate launch plan',
details: error instanceof Error ? error.message : String(error)
},
{ status: 500 }
);
}
}
// Analyze project state for V1 launch readiness
function analyzeProjectForV1Launch(context: any) {
const analysis = {
currentState: determineCurrentState(context),
strengths: [],
gaps: [],
estimatedCompleteness: 0,
recommendations: []
};
// Analyze codebase maturity
if (context.codebase.totalCommits > 50) {
analysis.strengths.push('Active development with 63 commits');
}
if (context.codebase.totalLinesAdded > 100000) {
analysis.strengths.push('Substantial codebase (~104k lines added)');
}
// Analyze development activity
if (context.activity.totalSessions > 100) {
analysis.strengths.push(`Consistent development (${context.activity.totalSessions} sessions)`);
}
// Check for gaps
if (!context.project.vision) {
analysis.gaps.push('Product vision not documented');
} else {
analysis.strengths.push('Clear product vision defined');
}
if (context.documents.length === 0) {
analysis.gaps.push('No documentation uploaded (specs, PRDs, designs)');
}
// Check Git history span
const daysSinceStart = context.timeline.dateRange.totalDays;
if (daysSinceStart < 30) {
analysis.gaps.push('Project is in early stages (< 30 days old)');
} else if (daysSinceStart > 90) {
analysis.strengths.push('Mature project (90+ days of development)');
}
// Estimate completeness
const hasVision = context.project.vision ? 20 : 0;
const hasCode = context.codebase.totalCommits > 20 ? 40 : 20;
const hasActivity = context.activity.totalSessions > 50 ? 20 : 10;
const hasDocs = context.documents.length > 0 ? 20 : 0;
analysis.estimatedCompleteness = hasVision + hasCode + hasActivity + hasDocs;
// Generate recommendations
if (analysis.estimatedCompleteness < 60) {
analysis.recommendations.push('Focus on core functionality before launch');
analysis.recommendations.push('Document key features and user flows');
} else if (analysis.estimatedCompleteness < 80) {
analysis.recommendations.push('Prepare for beta testing');
analysis.recommendations.push('Set up monitoring and analytics');
} else {
analysis.recommendations.push('Ready for soft launch preparation');
}
return analysis;
}
// Determine current project state
function determineCurrentState(context: any): string {
const commits = context.codebase.totalCommits;
const days = context.timeline.dateRange.totalDays;
if (commits < 20) return 'Initial Development';
if (commits < 50) return 'Alpha Stage';
if (commits < 100 && days < 60) return 'Active Development';
return 'Pre-Launch';
}
// Generate V1 launch checklist
function generateV1LaunchPlan(context: any, analysis: any) {
const plan = {
phase: analysis.currentState,
estimatedCompletion: `${analysis.estimatedCompleteness}%`,
categories: [
{
name: 'Product Development',
status: analysis.estimatedCompleteness > 60 ? 'in_progress' : 'pending',
tasks: [
{
id: 'pd-1',
title: 'Core Feature Implementation',
status: context.codebase.totalCommits > 40 ? 'complete' : 'in_progress',
description: 'Build primary user-facing features',
dependencies: []
},
{
id: 'pd-2',
title: 'User Authentication & Authorization',
status: 'in_progress',
description: 'Secure login, signup, and permission system',
dependencies: ['pd-1']
},
{
id: 'pd-3',
title: 'Database Schema & Models',
status: context.codebase.totalLinesAdded > 50000 ? 'complete' : 'in_progress',
description: 'Define data structures and relationships',
dependencies: []
},
{
id: 'pd-4',
title: 'API Endpoints',
status: 'in_progress',
description: 'REST/GraphQL APIs for frontend communication',
dependencies: ['pd-3']
},
{
id: 'pd-5',
title: 'Error Handling & Logging',
status: 'pending',
description: 'Comprehensive error management and monitoring',
dependencies: ['pd-4']
}
]
},
{
name: 'Testing & Quality',
status: 'pending',
tasks: [
{
id: 'tq-1',
title: 'Unit Tests',
status: 'pending',
description: 'Test individual components and functions',
dependencies: ['pd-1']
},
{
id: 'tq-2',
title: 'Integration Tests',
status: 'pending',
description: 'Test system interactions',
dependencies: ['pd-4']
},
{
id: 'tq-3',
title: 'User Acceptance Testing',
status: 'pending',
description: 'Beta testing with real users',
dependencies: ['tq-1', 'tq-2']
},
{
id: 'tq-4',
title: 'Performance Testing',
status: 'pending',
description: 'Load testing and optimization',
dependencies: ['tq-2']
}
]
},
{
name: 'Documentation',
status: context.documents.length > 0 ? 'in_progress' : 'pending',
tasks: [
{
id: 'doc-1',
title: 'User Guide',
status: 'pending',
description: 'End-user documentation',
dependencies: ['pd-1']
},
{
id: 'doc-2',
title: 'API Documentation',
status: 'pending',
description: 'Developer-facing API docs',
dependencies: ['pd-4']
},
{
id: 'doc-3',
title: 'Onboarding Flow',
status: 'pending',
description: 'New user tutorial and setup',
dependencies: ['doc-1']
}
]
},
{
name: 'Infrastructure',
status: 'in_progress',
tasks: [
{
id: 'infra-1',
title: 'Production Environment Setup',
status: context.codebase.totalCommits > 30 ? 'complete' : 'in_progress',
description: 'Deploy to production servers',
dependencies: []
},
{
id: 'infra-2',
title: 'CI/CD Pipeline',
status: 'pending',
description: 'Automated testing and deployment',
dependencies: ['infra-1']
},
{
id: 'infra-3',
title: 'Monitoring & Alerts',
status: 'pending',
description: 'System health monitoring',
dependencies: ['infra-1']
},
{
id: 'infra-4',
title: 'Backup & Recovery',
status: 'pending',
description: 'Data backup strategy',
dependencies: ['infra-1']
}
]
},
{
name: 'Marketing & Launch',
status: 'pending',
tasks: [
{
id: 'mkt-1',
title: 'Landing Page',
status: 'pending',
description: 'Public-facing marketing site',
dependencies: []
},
{
id: 'mkt-2',
title: 'Email Marketing Setup',
status: 'pending',
description: 'Email campaigns and automation',
dependencies: ['mkt-1']
},
{
id: 'mkt-3',
title: 'Analytics Integration',
status: 'pending',
description: 'Track user behavior and metrics',
dependencies: ['pd-1']
},
{
id: 'mkt-4',
title: 'Launch Strategy',
status: 'pending',
description: 'Product Hunt, social media, PR',
dependencies: ['mkt-1', 'doc-1']
}
]
},
{
name: 'Legal & Compliance',
status: 'pending',
tasks: [
{
id: 'legal-1',
title: 'Privacy Policy',
status: 'pending',
description: 'GDPR/CCPA compliant privacy policy',
dependencies: []
},
{
id: 'legal-2',
title: 'Terms of Service',
status: 'pending',
description: 'User agreement and terms',
dependencies: []
},
{
id: 'legal-3',
title: 'Security Audit',
status: 'pending',
description: 'Third-party security review',
dependencies: ['pd-5']
}
]
}
],
timeline: {
estimated_days_to_v1: calculateEstimatedDays(analysis),
recommended_milestones: [
{
name: 'Alpha Release',
description: 'Internal testing with core features',
target: 'Week 1-2'
},
{
name: 'Beta Release',
description: 'Limited external user testing',
target: 'Week 3-4'
},
{
name: 'Soft Launch',
description: 'Public but limited announcement',
target: 'Week 5-6'
},
{
name: 'V1 Launch',
description: 'Full public launch',
target: 'Week 7-8'
}
]
}
};
return plan;
}
// Calculate estimated days to V1
function calculateEstimatedDays(analysis: any): number {
const completeness = analysis.estimatedCompleteness;
if (completeness > 80) return 14; // ~2 weeks
if (completeness > 60) return 30; // ~1 month
if (completeness > 40) return 60; // ~2 months
return 90; // ~3 months
}
// Generate immediate next steps
function generateNextSteps(plan: any) {
const nextSteps = [];
// Find first pending task in each category
for (const category of plan.categories) {
const pendingTask = category.tasks.find((t: any) => t.status === 'pending' || t.status === 'in_progress');
if (pendingTask && nextSteps.length < 5) {
nextSteps.push({
category: category.name,
task: pendingTask.title,
priority: nextSteps.length + 1,
description: pendingTask.description
});
}
}
return nextSteps;
}

View File

@@ -1,199 +0,0 @@
import { NextResponse } from 'next/server';
import { getAdminAuth, getAdminDb } from '@/lib/firebase/admin';
import { GeminiLlmClient } from '@/lib/ai/gemini-client';
import { z } from 'zod';
const MarketResearchSchema = z.object({
targetNiches: z.array(z.object({
name: z.string(),
description: z.string(),
marketSize: z.string(),
competitionLevel: z.enum(['low', 'medium', 'high']),
opportunity: z.string(),
})),
competitors: z.array(z.object({
name: z.string(),
positioning: z.string(),
strengths: z.array(z.string()),
weaknesses: z.array(z.string()),
})),
marketGaps: z.array(z.object({
gap: z.string(),
impact: z.enum(['low', 'medium', 'high']),
reasoning: z.string(),
})),
recommendations: z.array(z.string()),
sources: z.array(z.string()),
});
export async function POST(
request: Request,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params;
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const token = authHeader.substring(7);
const auth = getAdminAuth();
const decoded = await auth.verifyIdToken(token);
if (!decoded?.uid) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
// Get project data
const adminDb = getAdminDb();
const projectRef = adminDb.collection('projects').doc(projectId);
const projectDoc = await projectRef.get();
if (!projectDoc.exists) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}
const projectData = projectDoc.data();
const productVision = projectData?.productVision || '';
const productName = projectData?.productName || '';
const phaseData = projectData?.phaseData || {};
const canonicalModel = phaseData.canonicalProductModel || {};
// Build context for the agent
const ideaContext = canonicalModel.oneLiner || productVision ||
`${productName}: Help users build and launch products faster`;
console.log('[Market Research] Starting research for:', ideaContext);
// Initialize LLM client
const llm = new GeminiLlmClient();
// Conduct market research using the agent
const systemPrompt = `You are a market research analyst specializing in finding product-market fit and identifying underserved niches.
Your task is to analyze the given product idea and conduct comprehensive market research to:
1. Identify specific target niches that would benefit most from this product
2. Analyze competitors and their positioning
3. Find market gaps and opportunities
4. Provide actionable recommendations
Be specific, data-driven, and focused on actionable insights.`;
const userPrompt = `Analyze this product idea and conduct market research:
Product Idea: "${ideaContext}"
${canonicalModel.problem ? `Problem Being Solved: ${canonicalModel.problem}` : ''}
${canonicalModel.targetUser ? `Target User: ${canonicalModel.targetUser}` : ''}
${canonicalModel.coreSolution ? `Core Solution: ${canonicalModel.coreSolution}` : ''}
Provide a comprehensive market research analysis including:
- Target niches with high potential
- Competitor analysis
- Market gaps and opportunities
- Strategic recommendations
Focus on finding specific, underserved niches where this product can win.`;
const research = await llm.structuredCall({
model: 'gemini',
systemPrompt,
messages: [
{
role: 'user',
content: userPrompt,
},
],
schema: MarketResearchSchema,
temperature: 0.7,
});
console.log('[Market Research] Research completed:', {
niches: research.targetNiches.length,
competitors: research.competitors.length,
gaps: research.marketGaps.length,
});
// Store research results in Firestore
const researchRef = adminDb.collection('marketResearch').doc();
await researchRef.set({
id: researchRef.id,
projectId,
userId: decoded.uid,
research,
ideaContext,
createdAt: new Date(),
updatedAt: new Date(),
});
// Also store as knowledge items for vector search
const knowledgePromises = [];
// Store each niche as a knowledge item
for (const niche of research.targetNiches) {
const nicheRef = adminDb.collection('knowledge').doc();
knowledgePromises.push(
nicheRef.set({
id: nicheRef.id,
projectId,
userId: decoded.uid,
sourceType: 'research',
title: `Target Niche: ${niche.name}`,
content: `${niche.description}\n\nMarket Size: ${niche.marketSize}\nCompetition: ${niche.competitionLevel}\n\nOpportunity: ${niche.opportunity}`,
sourceMeta: {
origin: 'vibn',
researchType: 'market_niche',
researchId: researchRef.id,
},
createdAt: new Date(),
updatedAt: new Date(),
})
);
}
// Store market gaps
for (const gap of research.marketGaps) {
const gapRef = adminDb.collection('knowledge').doc();
knowledgePromises.push(
gapRef.set({
id: gapRef.id,
projectId,
userId: decoded.uid,
sourceType: 'research',
title: `Market Gap: ${gap.gap.substring(0, 50)}`,
content: `${gap.gap}\n\nImpact: ${gap.impact}\n\nReasoning: ${gap.reasoning}`,
sourceMeta: {
origin: 'vibn',
researchType: 'market_gap',
researchId: researchRef.id,
},
createdAt: new Date(),
updatedAt: new Date(),
})
);
}
await Promise.all(knowledgePromises);
console.log('[Market Research] Stored', knowledgePromises.length, 'knowledge items');
return NextResponse.json({
success: true,
research,
researchId: researchRef.id,
knowledgeItemsCreated: knowledgePromises.length,
});
} catch (error) {
console.error('[Market Research] Error:', error);
return NextResponse.json(
{
error: 'Failed to conduct market research',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
}

View File

@@ -1,62 +0,0 @@
/**
* Manual Extraction Trigger
*
* Endpoint to manually run backend extraction for a project.
* Useful for testing or re-running extraction.
*/
import { NextResponse } from 'next/server';
import { getAdminAuth } from '@/lib/firebase/admin';
import { runBackendExtractionForProject } from '@/lib/server/backend-extractor';
export const maxDuration = 300; // 5 minutes for extraction
export async function POST(
request: Request,
context: { params: Promise<{ projectId: string }> | { projectId: string } }
) {
try {
// Verify auth
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const idToken = authHeader.split('Bearer ')[1];
const adminAuth = getAdminAuth();
try {
await adminAuth.verifyIdToken(idToken);
} catch (error) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
// Handle async params
const params = 'then' in context.params ? await context.params : context.params;
const projectId = params.projectId;
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
console.log(`[API] Manual extraction triggered for project ${projectId}`);
// Run extraction
await runBackendExtractionForProject(projectId);
return NextResponse.json({
success: true,
message: 'Extraction completed successfully',
});
} catch (error) {
console.error('[API] Extraction failed:', error);
return NextResponse.json(
{
error: 'Extraction failed',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}

View File

@@ -1,310 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import admin from '@/lib/firebase/admin';
import { getApiUrl } from '@/lib/utils/api-url';
/**
* Timeline View Data
* Structures MVP checklist pages with their development sessions on a timeline
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params;
// Load project data, MVP checklist, git history, and activity in parallel
const db = admin.firestore();
const projectRef = db.collection('projects').doc(projectId);
const [projectDoc, checklistResponse, gitResponse, activityResponse] = await Promise.all([
projectRef.get(),
fetch(getApiUrl(`/api/projects/${projectId}/mvp-checklist`, request)),
fetch(getApiUrl(`/api/projects/${projectId}/git-history`, request)),
fetch(getApiUrl(`/api/projects/${projectId}/activity`, request))
]);
const projectData = projectDoc.exists ? projectDoc.data() : null;
const checklist = await checklistResponse.json();
const git = await gitResponse.json();
const activity = await activityResponse.json();
// Check if checklist exists and has the expected structure
if (!checklist || checklist.error || !checklist.mvpChecklist || !Array.isArray(checklist.mvpChecklist)) {
return NextResponse.json({
workItems: [],
timeline: {
start: new Date().toISOString(),
end: new Date().toISOString(),
totalDays: 0
},
summary: {
totalWorkItems: 0,
withActivity: 0,
noActivity: 0,
built: 0,
missing: 0
},
projectCreator: projectData?.createdBy || projectData?.owner || 'You',
message: 'No MVP checklist generated yet. Click "Regenerate Plan" to create one.'
});
}
// Build lightweight history object with just what we need
const history = {
chronologicalEvents: [
// Add git commits
...(git.commits || []).map((commit: any) => ({
type: 'git_commit',
timestamp: new Date(commit.date).toISOString(),
data: {
hash: commit.hash,
message: commit.message,
filesChanged: commit.filesChanged,
insertions: commit.insertions,
deletions: commit.deletions
}
})),
// Add extension sessions
...(activity.sessions || []).map((session: any) => ({
type: 'extension_session',
timestamp: session.startTime,
data: {
duration: session.duration,
filesModified: session.filesModified
}
}))
]
};
// Map pages to work items with session data
const workItems = [];
for (const category of checklist.mvpChecklist) {
for (const item of category.pages) {
const relatedSessions = findRelatedSessions(item, history);
const relatedCommits = findRelatedCommits(item, history);
const hasActivity = relatedSessions.length > 0 || relatedCommits.length > 0;
const startDate = hasActivity
? getEarliestDate([...relatedSessions, ...relatedCommits])
: null;
const endDate = hasActivity
? getLatestDate([...relatedSessions, ...relatedCommits])
: null;
workItems.push({
id: `${category.category.toLowerCase().replace(/\s+/g, '-')}-${item.title.toLowerCase().replace(/\s+/g, '-')}`,
title: item.title,
category: category.category,
path: item.path,
status: item.status,
priority: item.priority,
startDate,
endDate,
duration: calculateDuration(startDate, endDate),
sessionsCount: relatedSessions.length,
commitsCount: relatedCommits.length,
totalActivity: relatedSessions.length + relatedCommits.length,
sessions: relatedSessions,
commits: relatedCommits,
requirements: generateRequirements(item, { name: category.category }),
evidence: item.evidence || [],
note: item.note
});
}
}
// Sort by category order and status
// Priority: Core Features -> Marketing -> Social -> Content -> Settings
const categoryOrder = [
'Core Features',
'Marketing',
'Social',
'Content',
'Settings'
];
workItems.sort((a, b) => {
// First by category
const catCompare = categoryOrder.indexOf(a.category) - categoryOrder.indexOf(b.category);
if (catCompare !== 0) return catCompare;
// Then by status (built first, then in_progress, then missing)
const statusOrder = { 'built': 0, 'in_progress': 1, 'missing': 2 };
return (statusOrder[a.status as keyof typeof statusOrder] || 3) -
(statusOrder[b.status as keyof typeof statusOrder] || 3);
});
// Calculate timeline range
const allDates = workItems
.filter(w => w.startDate)
.flatMap(w => [w.startDate, w.endDate].filter(Boolean))
.map(d => new Date(d!));
const timelineStart = allDates.length > 0
? new Date(Math.min(...allDates.map(d => d.getTime())))
: new Date();
const timelineEnd = new Date(); // Today
return NextResponse.json({
workItems,
timeline: {
start: timelineStart.toISOString(),
end: timelineEnd.toISOString(),
totalDays: Math.ceil((timelineEnd.getTime() - timelineStart.getTime()) / (1000 * 60 * 60 * 24))
},
summary: {
totalWorkItems: workItems.length,
withActivity: workItems.filter(w => w.totalActivity > 0).length,
noActivity: workItems.filter(w => w.totalActivity === 0).length,
built: workItems.filter(w => w.status === 'built').length,
missing: workItems.filter(w => w.status === 'missing').length
},
projectCreator: projectData?.createdBy || projectData?.owner || 'You'
});
} catch (error) {
console.error('Error generating timeline view:', error);
return NextResponse.json(
{
error: 'Failed to generate timeline view',
details: error instanceof Error ? error.message : String(error)
},
{ status: 500 }
);
}
}
function findRelatedSessions(page: any, history: any) {
const pagePath = page.path.toLowerCase();
const pageTitle = page.title.toLowerCase();
return history.chronologicalEvents
.filter((e: any) => e.type === 'extension_session')
.filter((e: any) => {
const filesModified = e.data.filesModified || [];
return filesModified.some((f: string) => {
const lowerFile = f.toLowerCase();
return lowerFile.includes(pagePath) ||
lowerFile.includes(pageTitle.replace(/\s+/g, '-')) ||
(page.evidence && page.evidence.some((ev: string) => lowerFile.includes(ev.toLowerCase())));
});
})
.map((e: any) => ({
timestamp: e.timestamp,
duration: e.data.duration,
filesModified: e.data.filesModified
}));
}
function findRelatedCommits(page: any, history: any) {
const pagePath = page.path.toLowerCase();
const pageTitle = page.title.toLowerCase();
return history.chronologicalEvents
.filter((e: any) => e.type === 'git_commit')
.filter((e: any) => {
const message = e.data.message.toLowerCase();
return message.includes(pagePath) ||
message.includes(pageTitle.replace(/\s+/g, ' ')) ||
(page.evidence && page.evidence.some((ev: string) => message.includes(ev.toLowerCase())));
})
.map((e: any) => ({
timestamp: e.timestamp,
hash: e.data.hash,
message: e.data.message,
insertions: e.data.insertions,
deletions: e.data.deletions
}));
}
function getEarliestDate(events: any[]) {
if (events.length === 0) return null;
const dates = events.map(e => new Date(e.timestamp).getTime());
return new Date(Math.min(...dates)).toISOString();
}
function getLatestDate(events: any[]) {
if (events.length === 0) return null;
const dates = events.map(e => new Date(e.timestamp).getTime());
return new Date(Math.max(...dates)).toISOString();
}
function calculateDuration(startDate: string | null, endDate: string | null): number {
if (!startDate || !endDate) return 0;
const diff = new Date(endDate).getTime() - new Date(startDate).getTime();
return Math.ceil(diff / (1000 * 60 * 60 * 24));
}
function generateRequirements(page: any, category: any): any[] {
const requirements = [];
// Generate specific requirements based on page type
if (page.title.includes('Sign In') || page.title.includes('Sign Up')) {
requirements.push(
{ id: 1, text: 'Email/password authentication', status: 'built' },
{ id: 2, text: 'GitHub OAuth integration', status: 'built' },
{ id: 3, text: 'Password reset flow', status: 'missing' },
{ id: 4, text: 'Session management', status: 'built' }
);
} else if (page.title.includes('Checklist')) {
requirements.push(
{ id: 1, text: 'Display generated tasks from API', status: 'missing' },
{ id: 2, text: 'Mark tasks as complete', status: 'missing' },
{ id: 3, text: 'Drag-and-drop reordering', status: 'missing' },
{ id: 4, text: 'Save checklist state', status: 'missing' },
{ id: 5, text: 'Export to markdown/PDF', status: 'missing' }
);
} else if (page.title.includes('Vision') || page.title.includes('Mission')) {
requirements.push(
{ id: 1, text: 'Capture product vision text', status: 'missing' },
{ id: 2, text: 'AI-assisted vision refinement', status: 'missing' },
{ id: 3, text: 'Upload supporting documents', status: 'missing' },
{ id: 4, text: 'Save vision to project metadata', status: 'built' }
);
} else if (page.title.includes('Marketing Automation')) {
requirements.push(
{ id: 1, text: 'Connect to /plan/marketing API', status: 'missing' },
{ id: 2, text: 'Generate landing page copy', status: 'missing' },
{ id: 3, text: 'Generate email sequences', status: 'missing' },
{ id: 4, text: 'Export marketing materials', status: 'missing' }
);
} else if (page.title.includes('Communication Automation')) {
requirements.push(
{ id: 1, text: 'Email template builder', status: 'missing' },
{ id: 2, text: 'Slack integration', status: 'missing' },
{ id: 3, text: 'Automated project updates', status: 'missing' },
{ id: 4, text: 'Team notifications', status: 'missing' }
);
} else if (page.title.includes('Import') && page.title.includes('Modal')) {
requirements.push(
{ id: 1, text: 'Start from scratch option', status: 'built' },
{ id: 2, text: 'Import from GitHub', status: 'built' },
{ id: 3, text: 'Import from local folder', status: 'missing' },
{ id: 4, text: 'Auto-detect project type', status: 'missing' },
{ id: 5, text: 'Trigger Cursor import', status: 'built' },
{ id: 6, text: 'Create .vibn file', status: 'built' }
);
} else if (page.status === 'built') {
requirements.push(
{ id: 1, text: 'Page built and accessible', status: 'built' },
{ id: 2, text: 'Connected to backend API', status: 'built' }
);
} else {
requirements.push(
{ id: 1, text: 'Design page layout', status: 'missing' },
{ id: 2, text: 'Implement core functionality', status: 'missing' },
{ id: 3, text: 'Connect to backend API', status: 'missing' },
{ id: 4, text: 'Add error handling', status: 'missing' }
);
}
return requirements;
}

View File

@@ -1,397 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
interface TimelineDay {
date: string; // YYYY-MM-DD format
dayOfWeek: string;
gitCommits: Array<{
hash: string;
time: string;
author: string;
message: string;
filesChanged: number;
insertions: number;
deletions: number;
}>;
extensionSessions: Array<{
startTime: string;
endTime: string;
duration: number; // minutes
filesModified: string[];
conversationSummary?: string;
}>;
cursorMessages: Array<{
time: string;
type: 'user' | 'assistant';
conversationName: string;
preview: string; // First 100 chars
}>;
summary: {
totalGitCommits: number;
totalExtensionSessions: number;
totalCursorMessages: number;
linesAdded: number;
linesRemoved: number;
uniqueFilesModified: number;
};
}
interface UnifiedTimeline {
projectId: string;
dateRange: {
earliest: string;
latest: string;
totalDays: number;
};
days: TimelineDay[];
dataSources: {
git: { available: boolean; firstDate: string | null; lastDate: string | null; totalRecords: number };
extension: { available: boolean; firstDate: string | null; lastDate: string | null; totalRecords: number };
cursor: { available: boolean; firstDate: string | null; lastDate: string | null; totalRecords: number };
};
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params;
// 1. Load Git commits
const repoPath = '/Users/markhenderson/ai-proxy';
let gitCommits: any[] = [];
let gitFirstDate: string | null = null;
let gitLastDate: string | null = null;
try {
const { stdout: commitsOutput } = await execAsync(
`cd "${repoPath}" && git log --all --pretty=format:"%H|%ai|%an|%s" --numstat`,
{ maxBuffer: 10 * 1024 * 1024 }
);
if (commitsOutput.trim()) {
const lines = commitsOutput.split('\n');
let currentCommit: any = null;
for (const line of lines) {
if (line.includes('|')) {
if (currentCommit) {
gitCommits.push(currentCommit);
}
const [hash, date, author, message] = line.split('|');
currentCommit = {
hash: hash.substring(0, 8),
date,
author,
message,
filesChanged: 0,
insertions: 0,
deletions: 0
};
} else if (line.trim() && currentCommit) {
const parts = line.trim().split('\t');
if (parts.length === 3) {
const [insertStr, delStr] = parts;
const insertions = insertStr === '-' ? 0 : parseInt(insertStr, 10) || 0;
const deletions = delStr === '-' ? 0 : parseInt(delStr, 10) || 0;
currentCommit.filesChanged++;
currentCommit.insertions += insertions;
currentCommit.deletions += deletions;
}
}
}
if (currentCommit) {
gitCommits.push(currentCommit);
}
gitCommits.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
if (gitCommits.length > 0) {
gitFirstDate = gitCommits[0].date;
gitLastDate = gitCommits[gitCommits.length - 1].date;
}
}
} catch (error) {
console.log('⚠️ Could not load Git commits:', error);
}
// 2. Load Extension sessions
let extensionSessions: any[] = [];
let extensionFirstDate: string | null = null;
let extensionLastDate: string | null = null;
try {
// Try to find sessions by projectId first
let sessionsSnapshot = await adminDb
.collection('sessions')
.where('projectId', '==', projectId)
.get();
// If no sessions found by projectId, try by workspacePath
if (sessionsSnapshot.empty) {
const workspacePath = '/Users/markhenderson/ai-proxy';
sessionsSnapshot = await adminDb
.collection('sessions')
.where('workspacePath', '==', workspacePath)
.get();
}
extensionSessions = sessionsSnapshot.docs.map(doc => {
const data = doc.data();
const startTime = data.startTime?.toDate?.() || new Date(data.startTime);
const endTime = data.endTime?.toDate?.() || new Date(data.endTime);
return {
startTime,
endTime,
filesModified: data.filesModified || [],
conversationSummary: data.conversationSummary || '',
conversation: data.conversation || []
};
});
extensionSessions.sort((a, b) =>
new Date(a.startTime).getTime() - new Date(b.startTime).getTime()
);
if (extensionSessions.length > 0) {
extensionFirstDate = extensionSessions[0].startTime.toISOString();
extensionLastDate = extensionSessions[extensionSessions.length - 1].endTime.toISOString();
}
} catch (error) {
console.log('⚠️ Could not load extension sessions:', error);
}
// 3. Load Cursor messages (from both cursorConversations and extension sessions)
let cursorMessages: any[] = [];
let cursorFirstDate: string | null = null;
let cursorLastDate: string | null = null;
try {
// Load from cursorConversations (backfilled historical data)
const conversationsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.get();
for (const convDoc of conversationsSnapshot.docs) {
const conv = convDoc.data();
const messagesSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.doc(convDoc.id)
.collection('messages')
.orderBy('createdAt', 'asc')
.get();
const messages = messagesSnapshot.docs.map(msgDoc => {
const msg = msgDoc.data();
return {
createdAt: msg.createdAt,
type: msg.type === 1 ? 'user' : 'assistant',
text: msg.text || '',
conversationName: conv.name || 'Untitled'
};
});
cursorMessages = cursorMessages.concat(messages);
}
// Also load from extension sessions conversation data
for (const session of extensionSessions) {
if (session.conversation && Array.isArray(session.conversation)) {
for (const msg of session.conversation) {
cursorMessages.push({
createdAt: msg.timestamp || session.startTime.toISOString(),
type: msg.role === 'user' ? 'user' : 'assistant',
text: msg.message || '',
conversationName: 'Extension Session'
});
}
}
}
cursorMessages.sort((a, b) =>
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
if (cursorMessages.length > 0) {
cursorFirstDate = cursorMessages[0].createdAt;
cursorLastDate = cursorMessages[cursorMessages.length - 1].createdAt;
}
} catch (error) {
console.log('⚠️ Could not load Cursor messages:', error);
}
// 4. Find overall date range
const allFirstDates = [
gitFirstDate ? new Date(gitFirstDate) : null,
extensionFirstDate ? new Date(extensionFirstDate) : null,
cursorFirstDate ? new Date(cursorFirstDate) : null
].filter(d => d !== null) as Date[];
const allLastDates = [
gitLastDate ? new Date(gitLastDate) : null,
extensionLastDate ? new Date(extensionLastDate) : null,
cursorLastDate ? new Date(cursorLastDate) : null
].filter(d => d !== null) as Date[];
if (allFirstDates.length === 0 && allLastDates.length === 0) {
return NextResponse.json({
error: 'No timeline data available',
projectId,
dateRange: { earliest: null, latest: null, totalDays: 0 },
days: [],
dataSources: {
git: { available: false, firstDate: null, lastDate: null, totalRecords: 0 },
extension: { available: false, firstDate: null, lastDate: null, totalRecords: 0 },
cursor: { available: false, firstDate: null, lastDate: null, totalRecords: 0 }
}
});
}
const earliestDate = new Date(Math.min(...allFirstDates.map(d => d.getTime())));
const latestDate = new Date(Math.max(...allLastDates.map(d => d.getTime())));
const totalDays = Math.ceil((latestDate.getTime() - earliestDate.getTime()) / (1000 * 60 * 60 * 24)) + 1;
// 5. Group data by day
const dayMap = new Map<string, TimelineDay>();
// Initialize all days
for (let i = 0; i < totalDays; i++) {
const date = new Date(earliestDate);
date.setDate(date.getDate() + i);
const dateKey = date.toISOString().split('T')[0];
const dayOfWeek = date.toLocaleDateString('en-US', { weekday: 'long' });
dayMap.set(dateKey, {
date: dateKey,
dayOfWeek,
gitCommits: [],
extensionSessions: [],
cursorMessages: [],
summary: {
totalGitCommits: 0,
totalExtensionSessions: 0,
totalCursorMessages: 0,
linesAdded: 0,
linesRemoved: 0,
uniqueFilesModified: 0
}
});
}
// Add Git commits to days
for (const commit of gitCommits) {
const dateKey = commit.date.split(' ')[0];
const day = dayMap.get(dateKey);
if (day) {
day.gitCommits.push({
hash: commit.hash,
time: commit.date,
author: commit.author,
message: commit.message,
filesChanged: commit.filesChanged,
insertions: commit.insertions,
deletions: commit.deletions
});
day.summary.totalGitCommits++;
day.summary.linesAdded += commit.insertions;
day.summary.linesRemoved += commit.deletions;
}
}
// Add Extension sessions to days
for (const session of extensionSessions) {
const dateKey = new Date(session.startTime).toISOString().split('T')[0];
const day = dayMap.get(dateKey);
if (day) {
const startTime = new Date(session.startTime);
const endTime = new Date(session.endTime);
const duration = Math.round((endTime.getTime() - startTime.getTime()) / (1000 * 60));
day.extensionSessions.push({
startTime: session.startTime.toISOString(),
endTime: session.endTime.toISOString(),
duration,
filesModified: session.filesModified,
conversationSummary: session.conversationSummary
});
day.summary.totalExtensionSessions++;
// Track unique files
const uniqueFiles = new Set([...session.filesModified]);
day.summary.uniqueFilesModified += uniqueFiles.size;
}
}
// Add Cursor messages to days
for (const message of cursorMessages) {
const dateKey = new Date(message.createdAt).toISOString().split('T')[0];
const day = dayMap.get(dateKey);
if (day) {
day.cursorMessages.push({
time: message.createdAt,
type: message.type,
conversationName: message.conversationName,
preview: message.text.substring(0, 100)
});
day.summary.totalCursorMessages++;
}
}
// Convert to array and sort by date
const days = Array.from(dayMap.values()).sort((a, b) =>
new Date(a.date).getTime() - new Date(b.date).getTime()
);
const timeline: UnifiedTimeline = {
projectId,
dateRange: {
earliest: earliestDate.toISOString(),
latest: latestDate.toISOString(),
totalDays
},
days,
dataSources: {
git: {
available: gitCommits.length > 0,
firstDate: gitFirstDate,
lastDate: gitLastDate,
totalRecords: gitCommits.length
},
extension: {
available: extensionSessions.length > 0,
firstDate: extensionFirstDate,
lastDate: extensionLastDate,
totalRecords: extensionSessions.length
},
cursor: {
available: cursorMessages.length > 0,
firstDate: cursorFirstDate,
lastDate: cursorLastDate,
totalRecords: cursorMessages.length
}
}
};
return NextResponse.json(timeline);
} catch (error) {
console.error('Error generating unified timeline:', error);
return NextResponse.json(
{
error: 'Failed to generate unified timeline',
details: error instanceof Error ? error.message : String(error)
},
{ status: 500 }
);
}
}

View File

@@ -1,172 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import admin from '@/lib/firebase/admin';
/**
* Post a new message/comment on a work item
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string; itemId: string }> }
) {
try {
const { projectId, itemId } = await params;
const { message, author, authorId, type } = await request.json();
if (!message || !author) {
return NextResponse.json(
{ error: 'Message and author are required' },
{ status: 400 }
);
}
const db = admin.firestore();
// Create new message
const messageRef = db
.collection('projects')
.doc(projectId)
.collection('workItems')
.doc(itemId)
.collection('messages')
.doc();
await messageRef.set({
message,
author,
authorId: authorId || 'anonymous',
type: type || 'comment', // comment, feedback, question, etc.
createdAt: admin.firestore.FieldValue.serverTimestamp(),
reactions: [],
});
// Update message count on work item metadata
await db
.collection('projects')
.doc(projectId)
.collection('workItemStates')
.doc(itemId)
.set(
{
messageCount: admin.firestore.FieldValue.increment(1),
lastMessageAt: admin.firestore.FieldValue.serverTimestamp(),
},
{ merge: true }
);
return NextResponse.json({
success: true,
messageId: messageRef.id,
});
} catch (error) {
console.error('Error posting message:', error);
return NextResponse.json(
{
error: 'Failed to post message',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}
/**
* Get messages/comments for a work item
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string; itemId: string }> }
) {
try {
const { projectId, itemId } = await params;
const db = admin.firestore();
const messagesSnapshot = await db
.collection('projects')
.doc(projectId)
.collection('workItems')
.doc(itemId)
.collection('messages')
.orderBy('createdAt', 'desc')
.get();
const messages = messagesSnapshot.docs.map(doc => ({
id: doc.id,
...doc.data(),
createdAt: doc.data().createdAt?.toDate().toISOString(),
}));
return NextResponse.json({
messages,
count: messages.length,
});
} catch (error) {
console.error('Error fetching messages:', error);
return NextResponse.json(
{
error: 'Failed to fetch messages',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}
/**
* Delete a message
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string; itemId: string }> }
) {
try {
const { projectId, itemId } = await params;
const { searchParams } = new URL(request.url);
const messageId = searchParams.get('messageId');
if (!messageId) {
return NextResponse.json(
{ error: 'Message ID is required' },
{ status: 400 }
);
}
const db = admin.firestore();
// Delete message
await db
.collection('projects')
.doc(projectId)
.collection('workItems')
.doc(itemId)
.collection('messages')
.doc(messageId)
.delete();
// Update message count
await db
.collection('projects')
.doc(projectId)
.collection('workItemStates')
.doc(itemId)
.set(
{
messageCount: admin.firestore.FieldValue.increment(-1),
},
{ merge: true }
);
return NextResponse.json({
success: true,
});
} catch (error) {
console.error('Error deleting message:', error);
return NextResponse.json(
{
error: 'Failed to delete message',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}

View File

@@ -1,94 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import admin from '@/lib/firebase/admin';
/**
* Update work item state (draft/final)
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string; itemId: string }> }
) {
try {
const { projectId, itemId } = await params;
const { state } = await request.json();
if (!state || !['draft', 'final'].includes(state)) {
return NextResponse.json(
{ error: 'Invalid state. Must be "draft" or "final"' },
{ status: 400 }
);
}
const db = admin.firestore();
// Update state in work item
// For now, store in a separate collection since work items are generated from MVP checklist
await db
.collection('projects')
.doc(projectId)
.collection('workItemStates')
.doc(itemId)
.set(
{
state,
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
},
{ merge: true }
);
return NextResponse.json({
success: true,
state,
});
} catch (error) {
console.error('Error updating work item state:', error);
return NextResponse.json(
{
error: 'Failed to update state',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}
/**
* Get work item state
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string; itemId: string }> }
) {
try {
const { projectId, itemId } = await params;
const db = admin.firestore();
const stateDoc = await db
.collection('projects')
.doc(projectId)
.collection('workItemStates')
.doc(itemId)
.get();
if (!stateDoc.exists) {
return NextResponse.json({
state: 'draft', // Default state
});
}
return NextResponse.json({
state: stateDoc.data()?.state || 'draft',
updatedAt: stateDoc.data()?.updatedAt,
});
} catch (error) {
console.error('Error fetching work item state:', error);
return NextResponse.json(
{
error: 'Failed to fetch state',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}

View File

@@ -1,106 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import admin from '@/lib/firebase/admin';
/**
* Create a new version of a work item
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string; itemId: string }> }
) {
try {
const { projectId, itemId } = await params;
const { description, changes, createdBy } = await request.json();
const db = admin.firestore();
// Get current version count
const versionsSnapshot = await db
.collection('projects')
.doc(projectId)
.collection('workItems')
.doc(itemId)
.collection('versions')
.orderBy('versionNumber', 'desc')
.limit(1)
.get();
const currentVersion = versionsSnapshot.empty ? 0 : versionsSnapshot.docs[0].data().versionNumber;
const newVersionNumber = currentVersion + 1;
// Create new version
const versionRef = db
.collection('projects')
.doc(projectId)
.collection('workItems')
.doc(itemId)
.collection('versions')
.doc();
await versionRef.set({
versionNumber: newVersionNumber,
description: description || `Version ${newVersionNumber}`,
changes: changes || {},
createdBy: createdBy || 'system',
createdAt: admin.firestore.FieldValue.serverTimestamp(),
});
return NextResponse.json({
success: true,
versionId: versionRef.id,
versionNumber: newVersionNumber,
});
} catch (error) {
console.error('Error creating version:', error);
return NextResponse.json(
{
error: 'Failed to create version',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}
/**
* Get version history for a work item
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string; itemId: string }> }
) {
try {
const { projectId, itemId } = await params;
const db = admin.firestore();
const versionsSnapshot = await db
.collection('projects')
.doc(projectId)
.collection('workItems')
.doc(itemId)
.collection('versions')
.orderBy('versionNumber', 'desc')
.get();
const versions = versionsSnapshot.docs.map(doc => ({
id: doc.id,
...doc.data(),
createdAt: doc.data().createdAt?.toDate().toISOString(),
}));
return NextResponse.json({
versions,
count: versions.length,
});
} catch (error) {
console.error('Error fetching versions:', error);
return NextResponse.json(
{
error: 'Failed to fetch versions',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}

View File

@@ -1,166 +0,0 @@
import { NextResponse } from 'next/server';
import { getAdminAuth, getAdminDb } from '@/lib/firebase/admin';
import { FieldValue } from 'firebase-admin/firestore';
import type { ProjectPhase, PhaseStatus } from '@/lib/types/phases';
/**
* GET - Get current phase for a project
* POST - Update phase (start, complete, or add data)
*/
export async function GET(request: Request) {
try {
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const idToken = authHeader.split('Bearer ')[1];
const adminAuth = getAdminAuth();
const adminDb = getAdminDb();
await adminAuth.verifyIdToken(idToken);
const { searchParams } = new URL(request.url);
const projectId = searchParams.get('projectId');
if (!projectId) {
return NextResponse.json({ error: 'Project ID required' }, { status: 400 });
}
// Get project phase data
const projectDoc = await adminDb.collection('projects').doc(projectId).get();
if (!projectDoc.exists) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}
const projectData = projectDoc.data();
// Return current phase info
return NextResponse.json({
currentPhase: projectData?.currentPhase || 'gathering',
phaseStatus: projectData?.phaseStatus || 'not_started',
phaseData: projectData?.phaseData || {},
phaseHistory: projectData?.phaseHistory || []
});
} catch (error) {
console.error('Error getting project phase:', error);
return NextResponse.json(
{ error: 'Failed to get phase', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const idToken = authHeader.split('Bearer ')[1];
const adminAuth = getAdminAuth();
const adminDb = getAdminDb();
const decodedToken = await adminAuth.verifyIdToken(idToken);
const userId = decodedToken.uid;
const body = await request.json();
const { projectId, action, phase, data } = body;
if (!projectId || !action) {
return NextResponse.json(
{ error: 'projectId and action are required' },
{ status: 400 }
);
}
const projectRef = adminDb.collection('projects').doc(projectId);
const projectDoc = await projectRef.get();
if (!projectDoc.exists) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}
const projectData = projectDoc.data();
// Handle different actions
switch (action) {
case 'start': {
// Start a new phase
if (!phase) {
return NextResponse.json({ error: 'phase required for start action' }, { status: 400 });
}
await projectRef.update({
currentPhase: phase,
phaseStatus: 'in_progress',
[`phaseData.${phase}.startedAt`]: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp()
});
console.log(`[Phase] Started ${phase} for project ${projectId}`);
return NextResponse.json({ success: true, phase, status: 'in_progress' });
}
case 'complete': {
// Complete current phase
const currentPhase = projectData?.currentPhase || 'gathering';
await projectRef.update({
phaseStatus: 'completed',
[`phaseData.${currentPhase}.completedAt`]: FieldValue.serverTimestamp(),
phaseHistory: FieldValue.arrayUnion({
phase: currentPhase,
completedAt: FieldValue.serverTimestamp()
}),
updatedAt: FieldValue.serverTimestamp()
});
console.log(`[Phase] Completed ${currentPhase} for project ${projectId}`);
return NextResponse.json({ success: true, phase: currentPhase, status: 'completed' });
}
case 'save_data': {
// Save phase-specific data (insights, vision board, etc.)
const currentPhase = projectData?.currentPhase || 'gathering';
await projectRef.update({
[`phaseData.${currentPhase}.data`]: data,
[`phaseData.${currentPhase}.lastUpdated`]: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp()
});
console.log(`[Phase] Saved data for ${currentPhase} in project ${projectId}`);
return NextResponse.json({ success: true, phase: currentPhase });
}
case 'add_insight': {
// Add a gathering insight
if (!data || !data.insight) {
return NextResponse.json({ error: 'insight data required' }, { status: 400 });
}
await projectRef.update({
'phaseData.gathering.insights': FieldValue.arrayUnion(data),
updatedAt: FieldValue.serverTimestamp()
});
console.log(`[Phase] Added insight to project ${projectId}`);
return NextResponse.json({ success: true });
}
default:
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
}
} catch (error) {
console.error('Error updating project phase:', error);
return NextResponse.json(
{ error: 'Failed to update phase', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,73 +0,0 @@
import { NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
import { FieldValue } from 'firebase-admin/firestore';
export async function POST(request: Request) {
try {
const body = await request.json();
const { workspacePath, projectId, userId } = body;
if (!workspacePath || !projectId || !userId) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
);
}
// Verify the project belongs to the user
const projectDoc = await adminDb.collection('projects').doc(projectId).get();
if (!projectDoc.exists || projectDoc.data()?.userId !== userId) {
return NextResponse.json(
{ error: 'Project not found or unauthorized' },
{ status: 403 }
);
}
// Update all sessions with this workspace path to associate with the project
const sessionsSnapshot = await adminDb
.collection('sessions')
.where('userId', '==', userId)
.where('workspacePath', '==', workspacePath)
.where('needsProjectAssociation', '==', true)
.get();
const batch = adminDb.batch();
let count = 0;
sessionsSnapshot.docs.forEach((doc: FirebaseFirestore.QueryDocumentSnapshot) => {
batch.update(doc.ref, {
projectId,
needsProjectAssociation: false,
updatedAt: FieldValue.serverTimestamp(),
});
count++;
});
await batch.commit();
// Update the project's workspace path if not set
if (!projectDoc.data()?.workspacePath) {
await projectDoc.ref.update({
workspacePath,
updatedAt: FieldValue.serverTimestamp(),
});
}
return NextResponse.json({
success: true,
sessionsUpdated: count,
message: `Associated ${count} sessions with project`,
});
} catch (error) {
console.error('Error associating sessions:', error);
return NextResponse.json(
{
error: 'Failed to associate sessions',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}

View File

@@ -1,70 +0,0 @@
import { NextResponse } from 'next/server';
import { getAdminDb } from '@/lib/firebase/admin';
import type { DashboardStats } from '@/lib/types';
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const projectId = searchParams.get('projectId');
console.log(`[API] Fetching stats for project ${projectId}`);
const adminDb = getAdminDb();
// Query sessions for this project
let sessionsQuery = adminDb.collection('sessions');
if (projectId) {
sessionsQuery = sessionsQuery.where('projectId', '==', projectId) as any;
}
const sessionsSnapshot = await sessionsQuery.get();
// Calculate stats
let totalSessions = 0;
let totalCost = 0;
let totalTokens = 0;
let totalDuration = 0;
sessionsSnapshot.docs.forEach(doc => {
const data = doc.data();
totalSessions++;
totalCost += data.cost || 0;
totalTokens += data.tokensUsed || 0;
totalDuration += data.duration || 0;
});
// Query work completed for this project
let workQuery = adminDb.collection('workCompleted');
if (projectId) {
workQuery = workQuery.where('projectId', '==', projectId) as any;
}
const workSnapshot = await workQuery.get();
const workCompleted = workSnapshot.size;
const stats: DashboardStats = {
totalSessions,
totalCost,
totalTokens,
totalFeatures: workCompleted,
completedFeatures: workCompleted,
totalDuration: Math.round(totalDuration / 60), // Convert to minutes
};
console.log(`[API] Stats fetched successfully:`, stats);
return NextResponse.json(stats);
} catch (error) {
console.error('[API] Error fetching stats:', error);
const emptyStats: DashboardStats = {
totalSessions: 0,
totalCost: 0,
totalTokens: 0,
totalFeatures: 0,
completedFeatures: 0,
totalDuration: 0,
};
return NextResponse.json(emptyStats);
}
}

View File

@@ -1,40 +0,0 @@
import { NextResponse } from 'next/server';
import { authSession } from "@/lib/auth/session-server";
import { query } from '@/lib/db-postgres';
import { v4 as uuidv4 } from 'uuid';
export async function GET(request: Request) {
try {
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: 'No authorization token provided' }, { status: 401 });
}
const rows = await query<{ data: any }>(
`SELECT data FROM fs_users WHERE data->>'email' = $1 LIMIT 1`,
[session.user.email]
);
if (rows.length > 0 && rows[0].data?.apiKey) {
return NextResponse.json({ apiKey: rows[0].data.apiKey });
}
// Generate new API key and store it
const apiKey = `vibn_${uuidv4().replace(/-/g, '')}`;
await query(
`UPDATE fs_users
SET data = data || $1::jsonb, updated_at = NOW()
WHERE data->>'email' = $2`,
[JSON.stringify({ apiKey, apiKeyCreatedAt: new Date().toISOString() }), session.user.email]
);
return NextResponse.json({ apiKey, isNew: true });
} catch (error) {
console.error('[API] Error getting/creating API key:', error);
return NextResponse.json(
{ error: 'Failed to get API key', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,101 +0,0 @@
import { NextResponse } from 'next/server';
import { v0 } from 'v0-sdk';
export async function POST(request: Request) {
try {
const { chatId, chatUrl } = await request.json();
if (!chatId && !chatUrl) {
return NextResponse.json(
{ error: 'Either chatId or chatUrl is required' },
{ status: 400 }
);
}
// Check for API key
const apiKey = process.env.V0_API_KEY;
if (!apiKey) {
return NextResponse.json(
{ error: 'V0_API_KEY not configured' },
{ status: 500 }
);
}
// Extract chat ID from URL if provided
let extractedChatId = chatId;
if (chatUrl && !chatId) {
// v0.dev URLs look like: https://v0.dev/chat/abc123xyz
const match = chatUrl.match(/\/chat\/([^/?]+)/);
if (match) {
extractedChatId = match[1];
} else {
return NextResponse.json(
{ error: 'Invalid v0 chat URL format' },
{ status: 400 }
);
}
}
console.log(`[v0] Attempting to import chat: ${extractedChatId}`);
// The v0 SDK doesn't support retrieving individual chats by ID
// We'll store a reference to the chat URL for the user to access it
const fullChatUrl = chatUrl || `https://v0.dev/chat/${extractedChatId}`;
console.log(`[v0] Importing chat reference: ${extractedChatId}`);
const chatInfo = {
id: extractedChatId,
webUrl: fullChatUrl,
message: 'Chat link saved. You can access it via the web URL.',
note: 'The v0 API does not currently support retrieving chat history via API. Use the web URL to view and continue the conversation.'
};
return NextResponse.json({
success: true,
chat: chatInfo,
message: 'Chat reference saved successfully'
});
} catch (error) {
console.error('[v0] Error importing chat:', error);
return NextResponse.json(
{
error: 'Failed to import chat',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
}
// Also support GET for testing
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const chatId = searchParams.get('chatId');
const chatUrl = searchParams.get('chatUrl');
if (!chatId && !chatUrl) {
return NextResponse.json({
message: 'Import a v0 chat',
usage: 'POST /api/v0/import-chat with { "chatId": "abc123" } or { "chatUrl": "https://v0.dev/chat/abc123" }',
example: {
method: 'POST',
body: {
chatUrl: 'https://v0.dev/chat/your-chat-id'
}
}
});
}
// Forward to POST handler
const body = JSON.stringify({ chatId, chatUrl });
const req = new Request(request.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
});
return POST(req);
}

View File

@@ -1,41 +0,0 @@
import { NextResponse } from 'next/server';
import { v0 } from 'v0-sdk';
export async function POST(request: Request) {
try {
const { chatId, message, projectId } = await request.json();
if (!chatId || !message) {
return NextResponse.json(
{ error: 'Missing required fields: chatId and message' },
{ status: 400 }
);
}
console.log(`[API] Iterate request for chat ${chatId}`);
// The v0 SDK doesn't support sending follow-up messages via API
// Users need to continue the conversation on v0.dev
const webUrl = `https://v0.dev/chat/${chatId}`;
return NextResponse.json({
success: true,
chatId: chatId,
webUrl: webUrl,
message: 'To continue this conversation, please visit the chat on v0.dev',
note: 'The v0 API does not currently support sending follow-up messages. Use the web interface to iterate on your design.',
yourMessage: message,
});
} catch (error) {
console.error('[API] Error processing iteration request:', error);
return NextResponse.json(
{
error: error instanceof Error ? error.message : 'Failed to process request',
details: error instanceof Error ? error.stack : undefined,
},
{ status: 500 }
);
}
}

View File

@@ -1,68 +0,0 @@
import { NextResponse } from 'next/server';
import { v0 } from 'v0-sdk';
export async function GET(request: Request) {
try {
// Check for API key
const apiKey = process.env.V0_API_KEY;
if (!apiKey) {
return NextResponse.json(
{ error: 'V0_API_KEY not configured' },
{ status: 500 }
);
}
console.log('[v0] Attempting to list chats...');
// Try to list chats - this may or may not be supported
// The v0 SDK documentation shows chats.create() but we need to check if list() exists
try {
// @ts-ignore - Checking if this method exists
const chats = await v0.chats.list();
console.log('[v0] Chats retrieved:', chats);
return NextResponse.json({
success: true,
chats,
count: chats?.length || 0,
});
} catch (listError) {
console.error('[v0] List method error:', listError);
// Try alternative: Get account info or projects
try {
// @ts-ignore - Checking if this method exists
const projects = await v0.projects?.list();
console.log('[v0] Projects retrieved:', projects);
return NextResponse.json({
success: true,
projects,
count: projects?.length || 0,
note: 'Retrieved projects instead of chats'
});
} catch (projectError) {
console.error('[v0] Projects error:', projectError);
return NextResponse.json({
error: 'Unable to list chats or projects',
details: 'The v0 SDK may not support listing existing chats via API',
suggestion: 'You may need to manually provide chat IDs to import existing designs',
sdkError: listError instanceof Error ? listError.message : 'Unknown error'
}, { status: 400 });
}
}
} catch (error) {
console.error('[v0] Error:', error);
return NextResponse.json(
{
error: 'Failed to access v0 API',
details: error instanceof Error ? error.message : 'Unknown error',
tip: 'The v0 SDK primarily supports creating new chats. To import existing designs, you may need to provide specific chat IDs or URLs.'
},
{ status: 500 }
);
}
}

View File

@@ -1,120 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { adminDb } from "@/lib/firebase/admin";
import { getAuth } from "firebase-admin/auth";
interface VisionBoardUpdate {
vision?: {
problemSolving?: string;
changeCreated?: string;
};
targetUser?: {
who?: string;
whereTheyHangOut?: string;
};
needs?: {
problemSolved?: string;
benefitProvided?: string;
};
product?: {
description?: string;
differentiation?: string;
};
validationGoals?: {
firstUser?: string;
pathTo10Users?: string;
pricing?: string;
};
}
export async function POST(request: NextRequest) {
try {
const authHeader = request.headers.get("authorization");
if (!authHeader?.startsWith("Bearer ")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const token = authHeader.split("Bearer ")[1];
const decodedToken = await getAuth().verifyIdToken(token);
const userId = decodedToken.uid;
const { projectId, updates } = (await request.json()) as {
projectId: string;
updates: VisionBoardUpdate;
};
if (!projectId || !updates) {
return NextResponse.json(
{ error: "Missing projectId or updates" },
{ status: 400 }
);
}
// Verify user has access to this project
const projectRef = adminDb.collection("projects").doc(projectId);
const projectSnap = await projectRef.get();
if (!projectSnap.exists) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
const projectData = projectSnap.data();
if (projectData?.userId !== userId) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// Update vision board
const visionRef = projectRef.collection("visionBoard").doc("current");
const visionSnap = await visionRef.get();
const now = new Date();
if (visionSnap.exists()) {
// Merge updates
const currentData = visionSnap.data();
const mergedData = {
...currentData,
...updates,
vision: { ...currentData?.vision, ...updates.vision },
targetUser: { ...currentData?.targetUser, ...updates.targetUser },
needs: { ...currentData?.needs, ...updates.needs },
product: { ...currentData?.product, ...updates.product },
validationGoals: {
...currentData?.validationGoals,
...updates.validationGoals,
},
updatedAt: now,
};
await visionRef.update(mergedData);
return NextResponse.json({ success: true, data: mergedData });
} else {
// Create new vision board
const newData = {
vision: updates.vision || { problemSolving: "", changeCreated: "" },
targetUser: updates.targetUser || { who: "", whereTheyHangOut: "" },
needs: updates.needs || { problemSolved: "", benefitProvided: "" },
product: updates.product || { description: "", differentiation: "" },
validationGoals: updates.validationGoals || {
firstUser: "",
pathTo10Users: "",
pricing: "",
},
createdAt: now,
updatedAt: now,
};
await visionRef.set(newData);
return NextResponse.json({ success: true, data: newData });
}
} catch (error) {
console.error("Vision board update error:", error);
return NextResponse.json(
{
error: "Failed to update vision board",
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}

View File

@@ -1,65 +0,0 @@
/**
* GET /api/work-completed?projectId=<uuid>&limit=<n>
*
* Returns the work_completed rows for a project the caller owns.
*
* Closes S-05: this route used to accept any projectId off the query string
* with no auth and silently fell back to `projectId = 1` on missing input.
*/
import { NextResponse } from "next/server";
import { query } from "@/lib/db-postgres";
import type { WorkCompleted } from "@/lib/types";
import { withTenantProject } from "@/lib/server/api-handler";
import { log } from "@/lib/server/logger";
export const GET = withTenantProject(
async (request) => {
const { searchParams } = new URL(request.url);
const projectId = searchParams.get("projectId");
if (!projectId) {
return NextResponse.json(
{ error: "projectId is required" },
{ status: 400 },
);
}
const limit = Math.min(
200,
Math.max(1, parseInt(searchParams.get("limit") ?? "20", 10) || 20),
);
try {
const workItems = await query<WorkCompleted>(
`SELECT
wc.*,
s.session_id,
s.primary_ai_model,
s.duration_minutes
FROM work_completed wc
LEFT JOIN sessions s ON wc.session_id = s.id
WHERE wc.project_id = $1
ORDER BY wc.completed_at DESC
LIMIT $2`,
[projectId, limit],
);
const parsedWork = workItems.map((item) => ({
...item,
files_modified:
typeof item.files_modified === "string"
? JSON.parse(item.files_modified)
: item.files_modified,
}));
return NextResponse.json(parsedWork);
} catch (err) {
log.error("work-completed: db error", {
route: "api.work-completed",
projectId,
err: err instanceof Error ? err.message : String(err),
});
return NextResponse.json(
{ error: "Failed to fetch work completed" },
{ status: 500 },
);
}
},
{ source: "search", paramName: "projectId" },
);

View File

@@ -5,30 +5,47 @@
* workspace's Coolify project before we forward the call.
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import { NextResponse } from "next/server";
import { requireWorkspacePrincipal } from "@/lib/auth/workspace-auth";
import {
deployApplication,
getApplicationInProject,
getServiceInProject,
TenantError,
} from '@/lib/coolify';
} from "@/lib/coolify";
export async function POST(
request: Request,
{ params }: { params: Promise<{ slug: string; uuid: string }> }
{ params }: { params: Promise<{ slug: string; uuid: string }> },
) {
const { slug, uuid } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
const principal = await requireWorkspacePrincipal(request, {
targetSlug: slug,
});
if (principal instanceof NextResponse) return principal;
const ws = principal.workspace;
if (!ws.coolify_project_uuid) {
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
return NextResponse.json(
{ error: "Workspace has no Coolify project yet" },
{ status: 503 },
);
}
try {
// Tenant check before any mutation.
await getApplicationInProject(uuid, ws.coolify_project_uuid);
// In Coolify v4, "deploying" a UUID can apply to an Application or a Service.
// Try application first; if it throws a 404/not found, try checking if it's a service.
try {
await getApplicationInProject(uuid, ws.coolify_project_uuid);
} catch (e: any) {
if (e?.message?.includes("404") || e?.status === 404) {
await getServiceInProject(uuid, ws.coolify_project_uuid);
} else {
throw e;
}
}
const result = await deployApplication(uuid);
return NextResponse.json({
ok: true,
@@ -40,8 +57,11 @@ export async function POST(
return NextResponse.json({ error: err.message }, { status: 403 });
}
return NextResponse.json(
{ error: 'Deploy failed', details: err instanceof Error ? err.message : String(err) },
{ status: 502 }
{
error: "Deploy failed",
details: err instanceof Error ? err.message : String(err),
},
{ status: 502 },
);
}
}

View File

@@ -8,37 +8,51 @@
* global.
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import { NextResponse } from "next/server";
import { requireWorkspacePrincipal } from "@/lib/auth/workspace-auth";
import {
getApplicationInProject,
getServiceInProject,
getDeploymentLogs,
TenantError,
} from '@/lib/coolify';
} from "@/lib/coolify";
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string; deploymentUuid: string }> }
{ params }: { params: Promise<{ slug: string; deploymentUuid: string }> },
) {
const { slug, deploymentUuid } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
const principal = await requireWorkspacePrincipal(request, {
targetSlug: slug,
});
if (principal instanceof NextResponse) return principal;
const ws = principal.workspace;
if (!ws.coolify_project_uuid) {
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
return NextResponse.json(
{ error: "Workspace has no Coolify project yet" },
{ status: 503 },
);
}
const appUuid = new URL(request.url).searchParams.get('appUuid');
const appUuid = new URL(request.url).searchParams.get("appUuid");
if (!appUuid) {
return NextResponse.json(
{ error: 'Query param "appUuid" is required for tenant enforcement' },
{ status: 400 }
{ status: 400 },
);
}
try {
await getApplicationInProject(appUuid, ws.coolify_project_uuid);
try {
await getApplicationInProject(appUuid, ws.coolify_project_uuid);
} catch (e: any) {
if (e?.message?.includes("404") || e?.status === 404) {
await getServiceInProject(appUuid, ws.coolify_project_uuid);
} else {
throw e;
}
}
const logs = await getDeploymentLogs(deploymentUuid);
return NextResponse.json(logs);
} catch (err) {
@@ -46,8 +60,8 @@ export async function GET(
return NextResponse.json({ error: err.message }, { status: 403 });
}
return NextResponse.json(
{ error: 'Coolify request failed', details: String(err) },
{ status: 502 }
{ error: "Coolify request failed", details: String(err) },
{ status: 502 },
);
}
}

View File

@@ -1,98 +0,0 @@
/**
* GET /api/workspaces/[slug]/storage/buckets — describe the workspace's
* provisioned GCS state (default bucket name, SA email, HMAC accessId,
* provision status). Does NOT return the HMAC secret.
*
* POST /api/workspaces/[slug]/storage/buckets — idempotently provisions
* the per-workspace GCS substrate:
* 1. dedicated GCP service account (vibn-ws-{slug}@…)
* 2. SA JSON keyfile (encrypted at rest)
* 3. default bucket vibn-ws-{slug}-{6char} in northamerica-northeast1
* 4. roles/storage.objectAdmin binding for the SA on that bucket
* 5. HMAC key on the SA so app code can use AWS S3 SDKs
* Safe to re-run; each step short-circuits when already complete.
*
* Auth: session OR `Bearer vibn_sk_...`. Same workspace-scope rules as
* every other /api/workspaces/[slug]/* endpoint.
*
* P5.3 — vertical slice. The full storage.* tool family (presign,
* list_objects, delete_object, set_lifecycle) lands once this
* provisioning step is verified end-to-end.
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import {
ensureWorkspaceGcsProvisioned,
getWorkspaceGcsState,
} from '@/lib/workspace-gcs';
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string }> },
) {
const { slug } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const ws = await getWorkspaceGcsState(principal.workspace.id);
if (!ws) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 });
}
return NextResponse.json({
workspace: { slug: ws.slug },
storage: {
status: ws.gcp_provision_status ?? 'pending',
error: ws.gcp_provision_error ?? null,
serviceAccountEmail: ws.gcp_service_account_email ?? null,
defaultBucketName: ws.gcs_default_bucket_name ?? null,
hmacAccessId: ws.gcs_hmac_access_id ?? null,
location: 'northamerica-northeast1',
},
});
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ slug: string }> },
) {
const { slug } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
try {
const result = await ensureWorkspaceGcsProvisioned(principal.workspace);
return NextResponse.json(
{
workspace: { slug: principal.workspace.slug },
storage: {
status: result.status,
serviceAccountEmail: result.serviceAccountEmail,
bucket: result.bucket,
hmacAccessId: result.hmac.accessId,
location: result.bucket.location,
},
},
{ status: 200 },
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
// Schema-not-applied detection: makes the failure mode obvious in
// dev before the operator runs scripts/migrate-workspace-gcs.sql.
if (/column .* does not exist/i.test(message)) {
return NextResponse.json(
{
error:
'GCS columns missing on vibn_workspaces. Run scripts/migrate-workspace-gcs.sql.',
details: message,
},
{ status: 503 },
);
}
return NextResponse.json(
{ error: 'GCS provisioning failed', details: message },
{ status: 502 },
);
}
}

View File

@@ -2,7 +2,7 @@
import { useSession } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, Suspense } from "react";
import React, { useEffect, Suspense } from "react";
import NextAuthComponent from "@/app/components/NextAuthComponent";
import "../styles/new-site.css";
@@ -24,7 +24,18 @@ function AuthPageInner() {
useEffect(() => {
if (status === "authenticated" && session?.user?.email) {
const workspace = deriveWorkspace(session.user.email);
router.push(`/${workspace}/projects`);
// Check if user has projects. If 0, go to onboarding, else go to projects.
fetch("/api/projects")
.then(r => r.json())
.then(d => {
if (d.projects && d.projects.length > 0) {
router.push(`/${workspace}/projects`);
} else {
router.push(`/onboarding`);
}
})
.catch(() => router.push(`/${workspace}/projects`));
}
}, [status, session, router, searchParams]);

View File

@@ -1,418 +1,17 @@
"use client";
import { signIn } from "next-auth/react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Suspense, useState } from "react";
function authErrorMessage(code: string | null): string | null {
if (!code) return null;
if (code === "Callback") {
return (
"Google could not complete sign-in. Most often: DATABASE_URL in vibn-frontend/.env.local must reach Postgres from " +
"this machine (Coolify internal hostnames only work inside Docker). Use a public host/port, tunnel, or proxy; " +
"then run npx prisma db push. Also confirm NEXTAUTH_URL matches the browser (http://localhost:3000) and " +
"Google redirect URI http://localhost:3000/api/auth/callback/google. Dev check: GET /api/debug/prisma — see terminal for [next-auth] logs."
);
}
if (code === "Configuration") {
return "Auth is misconfigured (check GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, NEXTAUTH_SECRET).";
}
if (code === "AccessDenied") {
return "Access was denied. You may need to be added as a test user if the OAuth app is in testing mode.";
}
return `Sign-in error: ${code}`;
}
const showDevLocalSignIn =
process.env.NODE_ENV === "development" &&
Boolean(process.env.NEXT_PUBLIC_DEV_LOCAL_AUTH_EMAIL?.trim());
import React from "react";
export default function NextAuthComponent() {
return (
<Suspense>
<NextAuthForm />
</Suspense>
);
}
function NextAuthForm() {
const [isLoading, setIsLoading] = useState(false);
const [devSecret, setDevSecret] = useState("");
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") ?? "/auth";
const errorCode = searchParams.get("error");
const errorHint = authErrorMessage(errorCode);
const handleGoogleSignIn = async () => {
setIsLoading(true);
try {
await signIn("google", { callbackUrl });
} catch (error) {
console.error("Google sign-in error:", error);
setIsLoading(false);
}
};
const handleDevLocalSignIn = async () => {
setIsLoading(true);
try {
await signIn("dev-local", {
callbackUrl,
password: devSecret,
redirect: true,
});
} catch (error) {
console.error("Dev local sign-in error:", error);
setIsLoading(false);
}
};
const isNewUser = searchParams.get("new") === "1";
const title = isNewUser ? "Create your account" : "Sign in or sign up";
const subtitle = isNewUser
? "Continue with Google to set up your Vibn workspace."
: "Continue with Google. New here? An account is created automatically on first sign-in.";
return (
<div
style={{
width: "100%",
maxWidth: "440px",
margin: "0 auto",
position: "relative",
zIndex: 10,
}}
>
{/* Logo */}
<div
style={{
display: "flex",
justifyContent: "center",
marginBottom: "32px",
}}
<div style={{ display: "flex", flexDirection: "column", gap: "16px", alignItems: "center" }}>
<button
onClick={() => signIn("google")}
style={{ padding: "12px 24px", background: "var(--accent)", color: "#fff", borderRadius: "8px", fontWeight: "bold", border: "none", cursor: "pointer" }}
>
<span className="logo" style={{ fontSize: "20px" }}>
<a
href="/"
style={{
display: "inline-flex",
alignItems: "center",
gap: "9px",
textDecoration: "none",
color: "inherit",
}}
>
<span
className="logo-mark"
style={{
width: "32px",
height: "32px",
borderRadius: "50%",
background:
"linear-gradient(135deg, var(--accent) 0%, oklch(0.65 0.20 18) 100%)",
boxShadow:
"0 0 22px var(--accent-glow), inset 0 1px 0 oklch(1 0 0 / 0.25)",
display: "grid",
placeItems: "center",
color: "var(--accent-fg)",
flexShrink: 0,
}}
>
<svg
viewBox="0 0 36 32"
width="74%"
height="74%"
fill="currentColor"
stroke="currentColor"
strokeWidth="1.2"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M4 5 L10 5 L12 18 L14 5 L20 5 L12 27 Z" />
<rect
x="22.5"
y="23"
width="9.5"
height="3.8"
rx="0.7"
className="logo-caret"
/>
</svg>
</span>
<span>vibn</span>
</a>
</span>
</div>
{/* Card */}
<div className="auth-card card">
<style>{`
.auth-card {
position: relative;
z-index: 2;
width: 100%; max-width: 440px;
padding: 36px clamp(24px, 4vw, 40px) 32px;
background: linear-gradient(180deg, oklch(0.20 0.009 60 / 0.85), oklch(0.17 0.008 60 / 0.85));
border: 1px solid var(--hairline);
border-radius: 22px;
backdrop-filter: blur(20px);
box-shadow:
0 30px 80px -20px oklch(0 0 0 / 0.7),
0 0 80px -30px var(--accent-glow);
display: flex;
flex-direction: column;
gap: 24px;
}
.auth-card::before {
content: "";
position: absolute; left: 0; right: 0; top: 0; height: 1px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
opacity: .6;
}
.auth-eye {
display: inline-flex; align-items: center; gap: 8px;
font-family: var(--font-mono);
font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase;
color: var(--fg-mute);
}
.auth-eye::before {
content: ""; width: 5px; height: 5px; border-radius: 50%;
background: var(--accent); box-shadow: 0 0 12px var(--accent-glow);
}
.auth-title {
margin-top: 14px;
font-size: clamp(26px, 3.4vw, 34px);
font-weight: 500;
letter-spacing: -0.022em;
line-height: 1.1;
text-wrap: balance;
}
.auth-title em {
font-style: normal;
color: var(--accent);
text-shadow: 0 0 30px var(--accent-glow);
}
.auth-sub {
margin-top: 10px;
color: var(--fg-mute);
font-size: 14.5px;
line-height: 1.5;
text-wrap: balance;
}
`}</style>
{/* Glows */}
<div
style={{
position: "absolute",
inset: 0,
overflow: "hidden",
pointerEvents: "none",
borderRadius: "inherit",
}}
>
<div
style={{
position: "absolute",
top: "-20%",
left: "-10%",
width: "50%",
height: "50%",
background: "var(--accent)",
filter: "blur(80px)",
opacity: 0.15,
}}
/>
<div
style={{
position: "absolute",
bottom: "-20%",
right: "-10%",
width: "50%",
height: "50%",
background: "oklch(0.65 0.20 18)",
filter: "blur(80px)",
opacity: 0.15,
}}
/>
</div>
<div style={{ position: "relative", zIndex: 2 }}>
<div className="auth-eye">Welcome back</div>
<h1 className="auth-title">
Sign in and <em>keep building</em>.
</h1>
<p className="auth-sub">{subtitle}</p>
</div>
{errorHint && (
<div
role="alert"
style={{
padding: "12px 14px",
borderRadius: 10,
fontSize: 13,
lineHeight: 1.55,
color: "#ffae9a",
background: "oklch(0.74 0.175 35 / 0.15)",
border: "1px solid oklch(0.74 0.175 35 / 0.3)",
}}
>
{errorHint}
</div>
)}
<button
type="button"
className="auth-btn-ghost"
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: "10px",
height: "50px",
padding: "0 22px",
borderRadius: "999px",
fontWeight: 500,
fontSize: "15px",
transition:
"transform .12s, box-shadow .2s, background .2s, color .15s",
whiteSpace: "nowrap",
width: "100%",
background: "oklch(0.20 0.009 60 / 0.6)",
border: "1px solid var(--hairline)",
color: "var(--fg-dim)",
cursor: "pointer",
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = "var(--fg)";
e.currentTarget.style.borderColor = "var(--hairline-2)";
e.currentTarget.style.background = "oklch(0.22 0.010 60 / 0.8)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = "var(--fg-dim)";
e.currentTarget.style.borderColor = "var(--hairline)";
e.currentTarget.style.background = "oklch(0.20 0.009 60 / 0.6)";
}}
onClick={handleGoogleSignIn}
disabled={isLoading}
>
<svg
width={18}
height={18}
viewBox="0 0 24 24"
fill="none"
aria-hidden
>
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
{isLoading ? "Signing in…" : "Continue with Google"}
</button>
{showDevLocalSignIn && (
<div
style={{
marginTop: 8,
paddingTop: 24,
borderTop: "1px solid var(--hairline)",
}}
>
<p
style={{
fontSize: "13px",
color: "var(--fg-dim)",
marginBottom: 12,
textAlign: "center",
}}
>
Local only: sign in without Google as <br />
<strong style={{ color: "var(--fg)" }}>
{process.env.NEXT_PUBLIC_DEV_LOCAL_AUTH_EMAIL}
</strong>
</p>
<form
onSubmit={(e) => {
e.preventDefault();
void handleDevLocalSignIn();
}}
style={{ display: "flex", gap: 8 }}
>
<input
type="password"
name="dev-local-secret"
autoComplete="current-password"
placeholder="Dev secret"
value={devSecret}
onChange={(e) => setDevSecret(e.target.value)}
style={{
flex: 1,
padding: "0 14px",
height: "40px",
borderRadius: "8px",
background: "oklch(0.16 0.008 60)",
border: "1px solid var(--hairline)",
color: "var(--fg)",
fontSize: "14px",
outline: "none",
}}
/>
<button
type="submit"
disabled={isLoading}
className="btn btn-primary"
style={{
height: "40px",
padding: "0 16px",
borderRadius: "8px",
}}
>
Sign in
</button>
</form>
</div>
)}
</div>
<div
style={{
textAlign: "center",
fontSize: "12px",
color: "var(--fg-faint)",
marginTop: "24px",
lineHeight: 1.5,
}}
>
By continuing, you agree to Vibn's{" "}
<a
href="#"
style={{ color: "var(--fg-dim)", textDecoration: "underline" }}
>
Terms of Service
</a>{" "}
and{" "}
<a
href="#"
style={{ color: "var(--fg-dim)", textDecoration: "underline" }}
>
Privacy Policy
</a>
.
</div>
Sign in with Google
</button>
</div>
);
}

677
app/styles/onboarding.css Normal file
View File

@@ -0,0 +1,677 @@
/* Onboarding shared styles — same tokens as the rest of the site. */
:root {
--bg: oklch(0.155 0.008 60);
--bg-1: oklch(0.185 0.009 60);
--bg-2: oklch(0.225 0.010 60);
--hairline: oklch(0.32 0.010 60 / 0.55);
--hairline-2: oklch(0.40 0.012 60 / 0.35);
--fg: oklch(0.97 0.005 80);
--fg-dim: oklch(0.78 0.006 80);
--fg-mute: oklch(0.58 0.006 80);
--fg-faint: oklch(0.42 0.006 80);
--accent: oklch(0.74 0.175 35);
--accent-soft: oklch(0.74 0.175 35 / 0.18);
--accent-glow: oklch(0.74 0.175 35 / 0.35);
--accent-fg: #1a0f0a;
--ok: oklch(0.78 0.16 155);
--font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; min-height: 100%; }
body {
background: var(--bg);
color: var(--fg);
font-family: var(--font-sans);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow-x: hidden;
}
body::before {
content: "";
position: fixed; inset: 0;
background-image:
linear-gradient(to right, oklch(0.30 0.01 60 / 0.10) 1px, transparent 1px),
linear-gradient(to bottom, oklch(0.30 0.01 60 / 0.10) 1px, transparent 1px);
background-size: 56px 56px;
mask-image: radial-gradient(ellipse 80% 80% at 50% 40%, #000 30%, transparent 80%);
-webkit-mask-image: radial-gradient(ellipse 80% 80% at 50% 40%, #000 30%, transparent 80%);
pointer-events: none;
z-index: 0;
}
body::after {
content: "";
position: fixed; inset: 0;
pointer-events: none;
z-index: 1;
opacity: 0.035;
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%25' height='100%25' filter='url(%23n)' opacity='0.85'/></svg>");
}
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
h1, h2, h3 { margin: 0; font-weight: 500; letter-spacing: -0.02em; line-height: 1.05; }
p { margin: 0; }
::selection { background: var(--accent); color: var(--accent-fg); }
.mono { font-family: var(--font-mono); }
/* App shell */
.app {
position: relative;
z-index: 2;
min-height: 100dvh;
display: flex; flex-direction: column;
}
.app-bar {
position: relative; z-index: 5;
padding: 20px clamp(20px, 4vw, 48px);
display: flex; align-items: center; justify-content: space-between;
border-bottom: 1px solid transparent;
}
.app-bar-left { display: flex; align-items: center; gap: 24px; }
.app-step {
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-faint);
letter-spacing: 0.12em;
text-transform: uppercase;
display: inline-flex; align-items: center; gap: 8px;
}
.app-step::before {
content: "";
width: 5px; height: 5px; border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 12px var(--accent-glow);
}
.app-bar-right {
display: flex; gap: 18px; align-items: center;
}
.app-bar-right a, .app-bar-right button {
font-size: 13px; color: var(--fg-mute);
}
.app-bar-right a:hover, .app-bar-right button:hover { color: var(--fg); }
/* Logo */
.logo {
display: inline-flex; align-items: center; gap: 9px;
font-weight: 600; font-size: 17px; letter-spacing: -0.02em;
color: var(--fg);
}
.logo-mark {
width: 26px; height: 26px; border-radius: 50%;
background: linear-gradient(135deg, var(--accent) 0%, oklch(0.65 0.20 18) 100%);
box-shadow: 0 0 22px var(--accent-glow), inset 0 1px 0 oklch(1 0 0 / 0.25);
display: grid; place-items: center;
color: var(--accent-fg);
flex-shrink: 0;
}
.logo-mark svg { display: block; }
.logo-caret { animation: caret-blink 1.4s steps(2) infinite; }
@keyframes caret-blink { 50% { opacity: 0.25; } }
/* Main */
.screen {
flex: 1;
position: relative;
padding: clamp(40px, 7vh, 80px) clamp(20px, 4vw, 48px) clamp(40px, 6vh, 60px);
display: flex; flex-direction: column;
align-items: center;
text-align: center;
}
.screen-wide {
align-items: stretch;
text-align: left;
}
.screen-content {
position: relative; z-index: 2;
width: 100%;
max-width: 720px;
display: flex; flex-direction: column;
align-items: center; text-align: center;
}
.screen-content-wide {
max-width: 1100px;
align-items: stretch; text-align: left;
}
/* Ambient glows */
.glow {
position: absolute;
pointer-events: none;
filter: blur(20px);
z-index: 0;
}
/* Typography */
.eyebrow {
display: inline-flex; align-items: center; gap: 10px;
font-family: var(--font-mono);
font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase;
color: var(--fg-mute);
}
.eyebrow::before {
content: ""; width: 5px; height: 5px; border-radius: 50%;
background: var(--accent); box-shadow: 0 0 12px var(--accent-glow);
}
.eyebrow-accent { color: var(--accent); }
.h1 {
margin-top: 20px;
font-size: clamp(36px, 5.4vw, 64px);
font-weight: 500; letter-spacing: -0.03em; line-height: 1.04;
text-wrap: balance;
}
.h1 em {
font-style: normal;
color: var(--accent);
text-shadow: 0 0 30px var(--accent-glow);
}
.sub {
margin-top: 18px;
font-size: clamp(15px, 1.55vw, 18px);
color: var(--fg-mute);
line-height: 1.55;
text-wrap: balance;
max-width: 540px;
}
.sub b { color: var(--fg); font-weight: 500; }
.tagline {
display: inline-flex; align-items: center; gap: 14px;
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.06em;
color: var(--fg-faint);
margin-bottom: 8px;
}
.tagline::before, .tagline::after {
content: ""; width: 28px; height: 1px;
background: linear-gradient(90deg, transparent, var(--hairline), transparent);
}
/* Buttons */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 10px;
height: 50px; padding: 0 22px;
border-radius: 999px;
font-weight: 500;
font-size: 15px;
transition: transform .12s, box-shadow .2s, background .2s, border-color .15s;
white-space: nowrap;
}
.btn-primary {
background: var(--accent);
color: var(--accent-fg);
box-shadow:
0 0 0 1px oklch(0.84 0.16 35 / 0.5) inset,
0 10px 40px -10px var(--accent-glow),
0 0 40px -8px var(--accent-glow);
}
.btn-primary:hover { transform: translateY(-1px); }
.btn-primary[disabled] { opacity: .55; cursor: not-allowed; transform: none; }
.btn-primary .arrow { transition: transform .15s; }
.btn-primary:hover .arrow { transform: translateX(3px); }
.btn-ghost {
background: oklch(0.20 0.009 60 / 0.6);
border: 1px solid var(--hairline);
color: var(--fg-dim);
}
.btn-ghost:hover { color: var(--fg); border-color: var(--hairline-2); background: oklch(0.22 0.010 60 / 0.8); }
.link-quiet {
font-size: 13px;
color: var(--fg-mute);
display: inline-flex; align-items: center; gap: 6px;
border-bottom: 1px dashed var(--hairline);
padding-bottom: 2px;
}
.link-quiet:hover { color: var(--fg); border-color: var(--accent); }
/* Or divider */
.or-divider {
display: flex; align-items: center; gap: 14px;
margin: 28px 0 18px;
width: 100%; max-width: 360px;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--fg-faint);
}
.or-divider::before, .or-divider::after {
content: ""; flex: 1; height: 1px; background: var(--hairline);
}
/* Form */
.field {
width: 100%;
display: flex; flex-direction: column; gap: 8px;
margin-top: 24px;
text-align: left;
}
.field-label {
font-size: 15px;
font-weight: 500;
color: var(--fg);
letter-spacing: -0.005em;
}
.field-hint {
font-size: 13px;
color: var(--fg-mute);
line-height: 1.5;
}
.input {
width: 100%;
padding: 14px 16px;
background: oklch(0.16 0.008 60 / 0.8);
border: 1px solid var(--hairline);
border-radius: 12px;
color: var(--fg);
font: 15px/1.5 var(--font-sans);
outline: none;
transition: border-color .15s, background .15s, box-shadow .15s;
resize: vertical;
}
.input::placeholder { color: var(--fg-faint); }
.input:focus {
border-color: oklch(0.74 0.175 35 / 0.65);
background: oklch(0.18 0.009 60 / 0.95);
box-shadow: 0 0 0 3px oklch(0.74 0.175 35 / 0.12), 0 0 30px -10px var(--accent-glow);
}
.input-textarea { min-height: 110px; resize: vertical; }
.input-large { padding: 20px 22px; font-size: 17px; border-radius: 16px; }
/* Hero prompt input */
.prompt {
width: 100%;
position: relative;
margin-top: 24px;
}
.prompt-frame {
position: relative;
border-radius: 22px;
padding: 1px;
background: linear-gradient(180deg,
oklch(0.50 0.06 35 / 0.6),
oklch(0.30 0.012 60 / 0.4) 40%,
oklch(0.25 0.012 60 / 0.4));
box-shadow:
0 30px 80px -20px oklch(0 0 0 / 0.6),
0 0 80px -20px var(--accent-glow);
}
.prompt-inner {
background: linear-gradient(180deg, oklch(0.19 0.009 60 / 0.92), oklch(0.17 0.008 60 / 0.92));
border-radius: 21px;
padding: 18px 20px 14px;
backdrop-filter: blur(20px);
display: flex; flex-direction: column;
gap: 12px;
}
.prompt-inner textarea {
width: 100%;
min-height: 92px;
background: transparent;
border: 0;
color: var(--fg);
font: 17px/1.5 var(--font-sans);
resize: none;
outline: none;
padding: 4px;
}
.prompt-typed {
position: absolute;
top: 22px; left: 24px; right: 24px;
pointer-events: none;
color: var(--fg-faint);
font: 17px/1.5 var(--font-sans);
text-align: left;
}
.prompt-typed::after {
content: "";
display: inline-block;
width: 8px; height: 18px;
background: var(--accent);
vertical-align: -3px;
margin-left: 2px;
animation: blink 1s steps(2) infinite;
box-shadow: 0 0 12px var(--accent-glow);
}
@keyframes blink { 50% { opacity: 0; } }
.prompt-bar {
display: flex; align-items: center; justify-content: space-between;
padding-top: 8px;
border-top: 1px solid var(--hairline);
}
.prompt-hint {
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-faint);
letter-spacing: 0.02em;
}
/* Chip / option grid */
.chips {
display: flex; flex-wrap: wrap; gap: 8px;
}
.chip {
padding: 9px 14px;
border-radius: 999px;
border: 1px solid var(--hairline);
background: oklch(0.20 0.009 60 / 0.5);
color: var(--fg-dim);
font-size: 13.5px;
transition: border-color .15s, color .15s, background .15s, transform .12s;
}
.chip:hover { border-color: var(--hairline-2); color: var(--fg); transform: translateY(-1px); }
.chip.active {
border-color: var(--accent);
background: oklch(0.20 0.04 35 / 0.4);
color: var(--fg);
}
/* Preset chips */
.preset-row {
display: flex; gap: 8px; flex-wrap: wrap;
margin-top: 4px;
}
.preset-chip {
padding: 11px 18px;
border-radius: 12px;
border: 1px solid var(--hairline);
background: oklch(0.18 0.009 60 / 0.6);
color: var(--fg-dim);
font: 500 14.5px var(--font-mono);
letter-spacing: -0.005em;
transition: all .15s;
}
.preset-chip:hover { border-color: var(--hairline-2); color: var(--fg); }
.preset-chip.active {
border-color: var(--accent);
background: oklch(0.20 0.04 35 / 0.4);
color: var(--fg);
box-shadow: 0 0 0 3px oklch(0.74 0.175 35 / 0.1);
}
/* Trust strip */
.trust {
margin-top: 36px;
display: flex; gap: 14px; justify-content: center; align-items: center;
flex-wrap: wrap;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.03em;
color: var(--fg-faint);
}
.trust .sep { opacity: 0.5; }
/* CTA row */
.cta-row {
margin-top: 36px;
display: flex; gap: 14px; align-items: center; flex-wrap: wrap;
justify-content: center;
}
/* Spinner */
.spinner {
width: 16px; height: 16px; border-radius: 50%;
border: 2px solid oklch(0 0 0 / 0.2);
border-top-color: var(--accent-fg);
animation: spin .9s linear infinite;
display: inline-block;
}
.spinner-line {
width: 12px; height: 12px;
border-color: var(--hairline);
border-top-color: var(--accent);
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Surface card */
.surface {
background: linear-gradient(180deg, oklch(0.20 0.009 60 / 0.55), oklch(0.17 0.008 60 / 0.55));
border: 1px solid var(--hairline);
border-radius: 18px;
}
/* ── Wizard chrome ───────────────────────────────────────────────────── */
/* The persistent top strip with progress bar + back + step text + close. */
.wiz-top {
position: sticky; top: 0; z-index: 50;
background: oklch(0.155 0.008 60 / 0.85);
backdrop-filter: blur(14px) saturate(140%);
-webkit-backdrop-filter: blur(14px) saturate(140%);
border-bottom: 1px solid var(--hairline);
}
.wiz-top-row {
height: 54px;
padding: 0 clamp(16px, 3vw, 28px);
display: flex; align-items: center; gap: 14px;
}
.wiz-iconbtn {
width: 32px; height: 32px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 8px;
color: var(--fg-mute);
border: 1px solid transparent;
transition: color .15s, border-color .15s, background .15s;
flex-shrink: 0;
}
.wiz-iconbtn:hover {
color: var(--fg);
background: oklch(0.20 0.009 60 / 0.6);
border-color: var(--hairline);
}
.wiz-iconbtn[disabled] { opacity: 0; pointer-events: none; }
.wiz-logo {
display: inline-flex; align-items: center; gap: 8px;
font-weight: 500; font-size: 14px; letter-spacing: -0.01em;
color: var(--fg);
flex-shrink: 0;
}
.wiz-logo .logo-mark { width: 22px; height: 22px; }
.wiz-step {
flex: 1;
display: flex; align-items: center; gap: 10px;
min-width: 0;
justify-content: center;
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--fg-mute);
letter-spacing: 0.04em;
overflow: hidden;
}
.wiz-step b { color: var(--fg); font-weight: 500; }
.wiz-step .dot {
width: 4px; height: 4px; border-radius: 50%;
background: var(--fg-faint);
flex-shrink: 0;
}
.wiz-step .lane {
color: var(--accent);
letter-spacing: 0.08em;
text-transform: uppercase;
font-size: 10.5px;
display: inline-flex; align-items: center; gap: 6px;
}
.wiz-step .lane::before {
content: ""; width: 5px; height: 5px; border-radius: 50%;
background: var(--accent); box-shadow: 0 0 10px var(--accent-glow);
}
.wiz-progress {
position: relative;
height: 2px;
background: oklch(0.30 0.010 60 / 0.35);
}
.wiz-progress-fill {
position: absolute; left: 0; top: 0; bottom: 0;
background: var(--accent);
box-shadow: 0 0 14px var(--accent-glow);
transition: width .35s cubic-bezier(.4,0,.2,1);
}
@media (max-width: 640px) {
.wiz-step .lane { display: none; }
.wiz-step .dot:first-of-type { display: none; }
}
/* ── Wizard body ─────────────────────────────────────────────────────── */
.wiz-body {
flex: 1;
position: relative;
padding: clamp(40px, 7vh, 88px) clamp(20px, 4vw, 32px) clamp(40px, 6vh, 64px);
display: flex; flex-direction: column;
align-items: center;
}
.wiz-card {
width: 100%;
max-width: 520px;
display: flex; flex-direction: column;
gap: 28px;
}
.wiz-card.wide { max-width: 760px; }
.wiz-card.xwide { max-width: 1040px; }
/* Question heading — quiet, one line, no em accents */
.wiz-q { display: flex; flex-direction: column; gap: 10px; }
.wiz-q h2 {
font-size: clamp(22px, 2.4vw, 28px);
font-weight: 500;
letter-spacing: -0.018em;
line-height: 1.22;
color: var(--fg);
text-wrap: balance;
}
.wiz-q p {
font-size: 14.5px;
color: var(--fg-mute);
line-height: 1.55;
max-width: 460px;
}
/* Footer with back/continue */
.wiz-foot {
display: flex; align-items: center; justify-content: space-between;
gap: 14px;
margin-top: 8px;
}
.wiz-foot-left {
display: flex; align-items: center; gap: 10px;
font-size: 13px;
color: var(--fg-mute);
}
.wiz-foot-right {
display: flex; align-items: center; gap: 12px;
}
.wiz-hint {
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-faint);
letter-spacing: 0.06em;
}
.wiz-skip {
font-size: 13.5px;
color: var(--fg-mute);
padding: 8px 12px;
border-radius: 8px;
}
.wiz-skip:hover { color: var(--fg); background: oklch(0.20 0.009 60 / 0.5); }
.btn-wiz {
height: 42px;
padding: 0 18px;
font-size: 14px;
border-radius: 10px;
}
/* Fields tightened up for wizard context */
.wiz-field {
display: flex; flex-direction: column; gap: 8px;
}
.wiz-field-label {
font-size: 13.5px;
font-weight: 500;
color: var(--fg-dim);
letter-spacing: -0.005em;
}
.wiz-field-hint {
font-size: 12.5px;
color: var(--fg-mute);
line-height: 1.5;
}
.wiz-input {
width: 100%;
padding: 12px 14px;
background: oklch(0.16 0.008 60 / 0.8);
border: 1px solid var(--hairline);
border-radius: 10px;
color: var(--fg);
font: 14.5px/1.5 var(--font-sans);
outline: none;
transition: border-color .15s, background .15s, box-shadow .15s;
}
.wiz-input::placeholder { color: var(--fg-faint); }
.wiz-input:focus {
border-color: oklch(0.74 0.175 35 / 0.6);
background: oklch(0.18 0.009 60 / 0.95);
box-shadow: 0 0 0 3px oklch(0.74 0.175 35 / 0.12);
}
textarea.wiz-input { min-height: 96px; resize: vertical; }
/* Debug navigator panel */
.debug {
position: fixed; bottom: 16px; right: 16px;
z-index: 1000;
font-family: var(--font-mono);
font-size: 11px;
display: flex; flex-direction: column; gap: 6px;
align-items: flex-end;
}
.debug-toggle {
padding: 8px 12px;
border-radius: 999px;
background: oklch(0.18 0.009 60 / 0.85);
border: 1px solid var(--hairline);
color: var(--fg-mute);
letter-spacing: 0.06em;
text-transform: uppercase;
backdrop-filter: blur(12px);
}
.debug-toggle:hover { color: var(--fg); border-color: var(--hairline-2); }
.debug-panel {
width: 240px;
padding: 12px;
background: oklch(0.16 0.008 60 / 0.95);
border: 1px solid var(--hairline);
border-radius: 12px;
backdrop-filter: blur(20px);
display: flex; flex-direction: column; gap: 4px;
max-height: 60vh; overflow-y: auto;
}
.debug-row {
display: flex; align-items: center; gap: 8px;
padding: 6px 8px;
border-radius: 6px;
color: var(--fg-mute);
cursor: pointer;
letter-spacing: 0.04em;
text-transform: uppercase;
font-size: 10px;
}
.debug-row:hover { background: oklch(0.20 0.009 60); color: var(--fg-dim); }
.debug-row.active {
background: oklch(0.74 0.175 35 / 0.18);
color: var(--accent);
}
.debug-row b { color: inherit; font-weight: 600; }

View File

@@ -1,29 +0,0 @@
#!/bin/bash
# Quick diagnostic to check what's in Firestore for the handoff
PROJECT_ID="$1"
if [ -z "$PROJECT_ID" ]; then
echo "Usage: ./check-firestore-handoff.sh <projectId>"
echo ""
echo "Get your project ID from the URL:"
echo " http://localhost:3000/default/project/[PROJECT_ID]/v_ai_chat"
exit 1
fi
echo "Checking Firestore for project: $PROJECT_ID"
echo ""
echo "Run this in your browser console to check the data:"
echo ""
echo "// Copy-paste this into browser console (F12):"
echo "const { getFirestore, doc, getDoc } = await import('https://www.gstatic.com/firebasejs/10.7.1/firebase-firestore.js');"
echo "const db = getFirestore();"
echo "const snap = await getDoc(doc(db, 'projects', '$PROJECT_ID'));"
echo "console.log('Project data:', snap.data());"
echo "console.log('Handoff:', snap.data()?.phaseData?.phaseHandoffs?.collector);"
echo ""
echo "Or just paste this shortened version:"
echo ""
echo "firebase.firestore().doc('projects/$PROJECT_ID').get().then(d => console.log('Handoff:', d.data()?.phaseData?.phaseHandoffs?.collector));"

View File

@@ -1,366 +0,0 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Send,
Loader2,
Wrench,
Bot,
User,
RotateCcw,
ChevronDown,
ChevronUp,
Sparkles,
} from "lucide-react";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface Message {
role: "user" | "assistant";
content: string;
toolCalls?: string[];
turns?: number;
model?: string;
reasoning?: string | null;
error?: boolean;
}
interface OrchestratorChatProps {
projectId: string;
projectName?: string;
placeholder?: string;
}
// ---------------------------------------------------------------------------
// Friendly labels for tool call names
// ---------------------------------------------------------------------------
const TOOL_LABELS: Record<string, string> = {
spawn_agent: "Dispatched agent",
get_job_status: "Checked job",
list_repos: "Listed repos",
list_all_issues: "Checked issues",
list_all_apps: "Checked deployments",
get_app_status: "Checked app status",
read_repo_file: "Read file",
deploy_app: "Triggered deploy",
gitea_create_issue: "Created issue",
gitea_list_issues: "Listed issues",
gitea_close_issue: "Closed issue",
gitea_comment_issue: "Added comment",
save_memory: "Saved to memory",
};
function friendlyToolName(raw: string): string {
return TOOL_LABELS[raw] ?? raw.replace(/_/g, " ");
}
// ---------------------------------------------------------------------------
// Suggestion chips shown before the first message
// ---------------------------------------------------------------------------
const SUGGESTIONS = [
"What's the current status of this project?",
"Check if there are any open issues or PRs",
"What was the last deployment?",
"Write a quick summary of what's been built so far",
];
// ---------------------------------------------------------------------------
// Single message bubble
// ---------------------------------------------------------------------------
function MessageBubble({ msg }: { msg: Message }) {
const [showReasoning, setShowReasoning] = useState(false);
const isUser = msg.role === "user";
return (
<div className={`flex gap-3 ${isUser ? "flex-row-reverse" : ""}`}>
{/* Avatar */}
<div
className={`shrink-0 w-7 h-7 rounded-full flex items-center justify-center mt-0.5
${isUser
? "bg-primary text-primary-foreground"
: "bg-muted border border-border"
}`}
>
{isUser ? <User className="h-3.5 w-3.5" /> : <Bot className="h-3.5 w-3.5" />}
</div>
{/* Bubble */}
<div className={`flex-1 max-w-[85%] space-y-1.5 ${isUser ? "items-end flex flex-col" : ""}`}>
<div
className={`rounded-2xl px-4 py-3 text-sm leading-relaxed whitespace-pre-wrap
${isUser
? "bg-primary text-primary-foreground rounded-tr-sm"
: msg.error
? "bg-destructive/10 text-destructive border border-destructive/20 rounded-tl-sm"
: "bg-muted/60 border border-border rounded-tl-sm"
}`}
>
{msg.content}
</div>
{/* Tool calls & meta */}
{!isUser && (
<div className="flex flex-wrap items-center gap-1.5 px-1">
{msg.toolCalls && msg.toolCalls.length > 0 && (
<div className="flex flex-wrap gap-1">
{/* Deduplicate tool calls before rendering */}
{[...new Set(msg.toolCalls)].map((t, i) => (
<span
key={i}
className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded bg-muted border border-border text-muted-foreground"
>
<Wrench className="h-2.5 w-2.5 shrink-0" />
{friendlyToolName(t)}
</span>
))}
</div>
)}
{msg.reasoning && (
<button
onClick={() => setShowReasoning(v => !v)}
className="inline-flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors"
>
<Sparkles className="h-2.5 w-2.5" />
reasoning
{showReasoning ? <ChevronUp className="h-2.5 w-2.5" /> : <ChevronDown className="h-2.5 w-2.5" />}
</button>
)}
{msg.model && msg.model !== "unknown" && (
<span className="text-[10px] text-muted-foreground/60 ml-auto">
{msg.model.includes("glm") ? "GLM-5" : msg.model.includes("gemini") ? "Gemini" : msg.model}
</span>
)}
</div>
)}
{/* Reasoning panel */}
{!isUser && showReasoning && msg.reasoning && (
<div className="mx-1 rounded-lg border border-border/50 bg-muted/30 px-3 py-2 text-[11px] text-muted-foreground leading-relaxed font-mono max-h-40 overflow-y-auto">
{msg.reasoning}
</div>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Typing indicator
// ---------------------------------------------------------------------------
function TypingIndicator() {
return (
<div className="flex gap-3">
<div className="shrink-0 w-7 h-7 rounded-full bg-muted border border-border flex items-center justify-center">
<Bot className="h-3.5 w-3.5" />
</div>
<div className="bg-muted/60 border border-border rounded-2xl rounded-tl-sm px-4 py-3">
<div className="flex gap-1 items-center h-4">
{[0, 1, 2].map(i => (
<div
key={i}
className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 animate-bounce"
style={{ animationDelay: `${i * 0.15}s` }}
/>
))}
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function OrchestratorChat({
projectId,
projectName,
placeholder = "Ask your AI team anything…",
}: OrchestratorChatProps) {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const hasMessages = messages.length > 0;
const scrollToBottom = useCallback(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, loading]);
const sendMessage = useCallback(
async (text: string) => {
const trimmed = text.trim();
if (!trimmed || loading) return;
setInput("");
setMessages(prev => [...prev, { role: "user", content: trimmed }]);
setLoading(true);
try {
const res = await fetch(`/api/projects/${projectId}/agent-chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: trimmed }),
});
const data = await res.json();
if (!res.ok) {
setMessages(prev => [
...prev,
{ role: "assistant", content: data.error ?? "Something went wrong.", error: true },
]);
} else {
setMessages(prev => [
...prev,
{
role: "assistant",
content: data.reply || "(no reply)",
toolCalls: data.toolCalls,
turns: data.turns,
model: data.model,
reasoning: data.reasoning,
},
]);
}
} catch (err) {
setMessages(prev => [
...prev,
{
role: "assistant",
content: err instanceof Error ? err.message : "Network error — is the agent runner online?",
error: true,
},
]);
} finally {
setLoading(false);
setTimeout(() => textareaRef.current?.focus(), 50);
}
},
[projectId, loading]
);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage(input);
}
};
const clearChat = async () => {
setMessages([]);
try {
await fetch(`/api/projects/${projectId}/agent-chat`, { method: "DELETE" });
} catch { /* best-effort */ }
};
return (
<div className="flex flex-col rounded-xl border border-border bg-background overflow-hidden shadow-sm">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-muted/20">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
<span className="text-sm font-medium">
{projectName ? `${projectName} AI` : "Project AI"}
</span>
<Badge variant="outline" className="text-[10px] h-4 px-1.5">GLM-5</Badge>
</div>
{hasMessages && (
<Button
variant="ghost"
size="sm"
onClick={clearChat}
className="h-7 text-xs text-muted-foreground"
>
<RotateCcw className="h-3 w-3 mr-1" />
Clear
</Button>
)}
</div>
{/* Messages */}
<div className="flex-1 min-h-0">
{!hasMessages ? (
/* Empty state — Lovable-style centered prompt */
<div className="flex flex-col items-center justify-center px-6 py-10 gap-6 text-center">
<div>
<p className="text-lg font-semibold">What should we build?</p>
<p className="text-sm text-muted-foreground mt-1">
Your AI team is ready. Ask them anything about this project.
</p>
</div>
<div className="flex flex-wrap justify-center gap-2 max-w-lg">
{SUGGESTIONS.map(s => (
<button
key={s}
onClick={() => sendMessage(s)}
className="text-xs px-3 py-1.5 rounded-full border border-border bg-muted/40 hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
{s}
</button>
))}
</div>
</div>
) : (
<ScrollArea className="h-[360px]">
<div className="px-4 py-4 space-y-5">
{messages.map((msg, i) => (
<MessageBubble key={i} msg={msg} />
))}
{loading && <TypingIndicator />}
<div ref={bottomRef} />
</div>
</ScrollArea>
)}
</div>
{/* Input */}
<div className="border-t border-border px-3 py-3 bg-muted/10">
<div className="flex gap-2 items-end">
<Textarea
ref={textareaRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
rows={1}
className="resize-none min-h-[40px] max-h-[120px] flex-1 text-sm bg-background border-border focus-visible:ring-1 rounded-xl"
style={{ fieldSizing: "content" } as React.CSSProperties}
disabled={loading}
/>
<Button
size="icon"
onClick={() => sendMessage(input)}
disabled={!input.trim() || loading}
className="h-10 w-10 shrink-0 rounded-xl"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
<p className="text-[10px] text-muted-foreground/50 mt-1.5 pl-1">
Enter to send · Shift+Enter for newline
</p>
</div>
</div>
);
}

View File

@@ -1,586 +0,0 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useSession } from "next-auth/react";
import { Plus_Jakarta_Sans } from "next/font/google";
import { Loader2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { ProjectCreationModal } from "@/components/project-creation-modal";
import { isClientDevProjectBypass } from "@/lib/dev-bypass";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
const justineJakarta = Plus_Jakarta_Sans({
subsets: ["latin"],
weight: ["400", "500", "600", "700", "800"],
variable: "--font-justine-jakarta",
display: "swap",
});
interface ProjectWithStats {
id: string;
productName: string;
productVision?: string;
status?: string;
updatedAt: string | null;
stats: { sessions: number; costs: number };
}
const ICON_BG = ["#6366F1", "#8B5CF6", "#06B6D4", "#EC4899", "#9CA3AF"];
function timeAgo(dateStr?: string | null): string {
if (!dateStr) return "—";
const date = new Date(dateStr);
if (isNaN(date.getTime())) return "—";
const diff = (Date.now() - date.getTime()) / 1000;
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
const days = Math.floor(diff / 86400);
if (days === 1) return "Yesterday";
if (days < 7) return `${days}d ago`;
if (days < 30) return `${Math.floor(days / 7)}w ago`;
return `${Math.floor(days / 30)}mo ago`;
}
function greetingName(session: { user?: { name?: string | null; email?: string | null } } | null): string {
const n = session?.user?.name?.trim();
if (n) return n.split(/\s+/)[0] ?? "there";
const e = session?.user?.email;
if (e) return e.split("@")[0] ?? "there";
return "there";
}
function greetingPrefix(): string {
const h = new Date().getHours();
if (h < 12) return "Good morning";
if (h < 17) return "Good afternoon";
return "Good evening";
}
function StatusPill({ status }: { status?: string }) {
if (status === "live") {
return (
<span className="pill pill-live">
<span className="dot-live" />
Live
</span>
);
}
if (status === "building") {
return (
<span className="pill pill-building">
<span className="dot-building" />
Building
</span>
);
}
return <span className="pill pill-draft">Defining</span>;
}
export function JustineWorkspaceProjectsDashboard({ workspace }: { workspace: string }) {
const { data: session, status: authStatus } = useSession();
const [projects, setProjects] = useState<ProjectWithStats[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [showNew, setShowNew] = useState(false);
const [projectToDelete, setProjectToDelete] = useState<ProjectWithStats | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [theme, setTheme] = useState<"light" | "dark">("light");
useEffect(() => {
try {
const t = localStorage.getItem("jd-dashboard-theme");
if (t === "dark") setTheme("dark");
} catch {
/* ignore */
}
}, []);
const toggleTheme = useCallback(() => {
setTheme((prev) => {
const next = prev === "light" ? "dark" : "light";
try {
localStorage.setItem("jd-dashboard-theme", next);
} catch {
/* ignore */
}
return next;
});
}, []);
const fetchProjects = useCallback(async () => {
try {
setLoading(true);
const res = await fetch("/api/projects");
if (!res.ok) throw new Error("Failed");
const data = await res.json();
setProjects(data.projects ?? []);
} catch {
/* silent */
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (isClientDevProjectBypass()) {
void fetchProjects();
return;
}
if (authStatus === "authenticated") fetchProjects();
else if (authStatus === "unauthenticated") setLoading(false);
}, [authStatus, fetchProjects]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return projects;
return projects.filter((p) => p.productName.toLowerCase().includes(q));
}, [projects, search]);
const liveN = projects.filter((p) => p.status === "live").length;
const buildingN = projects.filter((p) => p.status === "building").length;
const totalCosts = projects.reduce((s, p) => s + (p.stats?.costs ?? 0), 0);
const userInitial =
session?.user?.name?.[0]?.toUpperCase() ?? session?.user?.email?.[0]?.toUpperCase() ?? "?";
const displayName = session?.user?.name?.trim() || session?.user?.email?.split("@")[0] || "Account";
const handleDelete = async () => {
if (!projectToDelete) return;
setIsDeleting(true);
try {
const res = await fetch("/api/projects/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectId: projectToDelete.id }),
});
if (res.ok) {
toast.success("Project deleted");
setProjectToDelete(null);
fetchProjects();
} else {
const err = await res.json();
toast.error(err.error || "Failed to delete");
}
} catch {
toast.error("An error occurred");
} finally {
setIsDeleting(false);
}
};
const firstName = greetingName(session);
return (
<div
data-justine-dashboard
data-theme={theme === "dark" ? "dark" : undefined}
className={`${justineJakarta.variable} justine-dashboard-root`}
>
<nav
className="jd-topnav"
style={{
background: "rgba(250,250,250,0.97)",
borderBottom: "1px solid #E5E7EB",
height: 56,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0 24px",
flexShrink: 0,
backdropFilter: "blur(8px)",
}}
>
<Link href="/" style={{ display: "flex", alignItems: "center", gap: 9, textDecoration: "none" }}>
<div
style={{
width: 27,
height: 27,
background: "linear-gradient(135deg,#2E2A5E,#4338CA)",
borderRadius: 6,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<span className="f" style={{ fontSize: 13, fontWeight: 700, color: "#FFFFFF" }}>
V
</span>
</div>
<span className="f" style={{ fontSize: 17, fontWeight: 700, color: "var(--ink)", letterSpacing: "-0.02em" }}>
vibn
</span>
</Link>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<button type="button" className="btn-secondary mob-hide" onClick={toggleTheme} style={{ fontSize: 12.5, padding: "7px 14px" }}>
{theme === "dark" ? "☀️ Light" : "🌙 Dark"}
</button>
<Link
href={`/${workspace}/settings`}
style={{
display: "flex",
alignItems: "center",
gap: 8,
cursor: "pointer",
padding: "5px 8px",
borderRadius: 8,
border: "none",
background: "transparent",
fontFamily: "var(--sans)",
textDecoration: "none",
}}
>
<div
style={{
width: 29,
height: 29,
borderRadius: "50%",
background: "#6366F1",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 11,
color: "#FFFFFF",
fontWeight: 600,
flexShrink: 0,
}}
>
{userInitial}
</div>
<div className="mob-hide" style={{ textAlign: "left" }}>
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--ink)", lineHeight: 1.2 }}>{displayName}</div>
<div style={{ fontSize: 10.5, color: "var(--muted)" }}>Workspace · {workspace}</div>
</div>
</Link>
</div>
</nav>
<div className="app-shell">
<aside className="proj-nav">
<div style={{ flexShrink: 0 }}>
<div style={{ padding: "10px 8px 8px" }}>
<Link
href={`/${workspace}/projects`}
id="nav-dashboard"
style={{
display: "flex",
alignItems: "center",
gap: 11,
padding: "11px 12px",
borderRadius: 10,
border: "none",
background: "rgba(255,255,255,0.55)",
cursor: "pointer",
width: "100%",
textAlign: "left",
fontFamily: "var(--sans)",
transition: "background 0.18s ease",
backdropFilter: "blur(4px)",
textDecoration: "none",
}}
>
<div
style={{
width: 32,
height: 32,
borderRadius: 8,
background: "linear-gradient(135deg,#4338CA,#6366F1)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 15,
flexShrink: 0,
color: "#FFFFFF",
}}
>
</div>
<div>
<div style={{ fontSize: 14, fontWeight: 700, color: "var(--ink)" }}>Dashboard</div>
<div style={{ fontSize: 10.5, color: "var(--muted)" }}>Overview</div>
</div>
</Link>
</div>
<div style={{ padding: "10px 8px 8px", marginTop: 2 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "0 4px", marginBottom: 8 }}>
<div className="nav-group-label" style={{ margin: 0 }}>
Projects
</div>
<button
type="button"
onClick={() => setShowNew(true)}
style={{
background: "none",
border: "none",
cursor: "pointer",
fontSize: 18,
color: "var(--indigo)",
padding: "0 4px",
lineHeight: 1,
fontWeight: 300,
}}
title="New project"
>
+
</button>
</div>
<div className="nav-search-wrap">
<svg
style={{ position: "absolute", left: 9, top: "50%", transform: "translateY(-50%)", pointerEvents: "none" }}
width={12}
height={12}
viewBox="0 0 20 20"
fill="none"
aria-hidden
>
<path
d="M8.5 3a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 8.5a6.5 6.5 0 1111.436 4.23l3.857 3.857a.75.75 0 01-1.06 1.06l-3.857-3.857A6.5 6.5 0 012 8.5z"
fill="#9CA3AF"
/>
</svg>
<input
className="nav-search"
type="search"
placeholder="Search projects…"
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label="Search projects"
/>
</div>
</div>
</div>
<div className="proj-list">
{loading && (
<div style={{ display: "flex", justifyContent: "center", padding: 24 }}>
<Loader2 style={{ width: 22, height: 22, color: "var(--muted)" }} className="animate-spin" />
</div>
)}
{!loading && filtered.length === 0 && projects.length === 0 && (
<div style={{ padding: "20px 8px 12px", textAlign: "center" }}>
<div
style={{
width: 36,
height: 36,
borderRadius: 10,
background: "var(--indigo-dim)",
display: "flex",
alignItems: "center",
justifyContent: "center",
margin: "0 auto 10px",
fontSize: 18,
}}
>
</div>
<div style={{ fontSize: 12, fontWeight: 600, color: "var(--ink)", marginBottom: 4 }}>No projects yet</div>
<div style={{ fontSize: 11, color: "var(--muted)", marginBottom: 12, lineHeight: 1.5 }}>
Start building your first product with vibn.
</div>
<button type="button" className="btn-primary" style={{ fontSize: 11.5, padding: "7px 14px", width: "100%" }} onClick={() => setShowNew(true)}>
+ Create first project
</button>
</div>
)}
{!loading && projects.length > 0 && filtered.length === 0 && (
<div style={{ padding: "16px 12px", textAlign: "center", fontSize: 12, color: "var(--muted)" }}>No projects match your search.</div>
)}
{!loading &&
filtered.map((p, i) => (
<div key={p.id} className="proj-row">
<Link
href={`/${workspace}/project/${p.id}`}
style={{ flex: 1, minWidth: 0, display: "flex", alignItems: "flex-start", gap: 10, textDecoration: "none", color: "inherit" }}
>
<div
className="proj-icon"
style={{
background: ICON_BG[i % ICON_BG.length],
}}
>
{(p.productName[0] ?? "P").toUpperCase()}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="proj-row-name">
{p.productName} <StatusPill status={p.status} />
</div>
<div className="proj-row-metric" style={{ fontWeight: 400, color: "var(--muted)" }}>
{p.productVision ? `${p.productVision.slice(0, 42)}${p.productVision.length > 42 ? "…" : ""}` : "Personal"}
</div>
<div className="proj-row-time">{timeAgo(p.updatedAt)}</div>
</div>
</Link>
<button
type="button"
className="proj-edit-btn"
title="Delete project"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setProjectToDelete(p);
}}
>
<Trash2 style={{ width: 13, height: 13 }} />
</button>
</div>
))}
</div>
<div style={{ flex: 1, padding: "12px 8px 16px", display: "flex", flexDirection: "column" }}>
<div className="nav-group-label" style={{ marginBottom: 4 }}>
Workspace
</div>
<Link href={`/${workspace}/activity`} className="nav-item-btn" style={{ textDecoration: "none", color: "inherit" }}>
<div className="nav-icon"></div>
<div>
<div className="nav-label">Activity</div>
<div className="nav-sub">Timeline & runs</div>
</div>
</Link>
<Link href={`/${workspace}/settings`} className="nav-item-btn" style={{ textDecoration: "none", color: "inherit" }}>
<div className="nav-icon"></div>
<div className="nav-label">Settings</div>
</Link>
<div style={{ height: 1, background: "var(--border)", margin: "8px 4px" }} />
<div className="nav-group-label" style={{ marginBottom: 4 }}>
Account
</div>
<button type="button" className="nav-item-btn" onClick={() => toast.message("Help — docs coming soon.")}>
<div className="nav-icon">?</div>
<div className="nav-label">Help</div>
</button>
</div>
</aside>
<main className="workspace">
<div id="ws-dashboard" className="ws-section active">
<div className="ws-inner">
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 20, marginBottom: 28 }}>
<div>
<h1 className="f" style={{ fontSize: 28, fontWeight: 700, color: "var(--ink)", letterSpacing: "-0.03em", marginBottom: 7 }}>
{greetingPrefix()}, {firstName}.
</h1>
<p style={{ fontSize: 14, color: "var(--muted)", lineHeight: 1.5 }}>
Open a project from the sidebar or start a new one.
</p>
</div>
<div className="dash-header-actions" style={{ display: "flex", gap: 9, alignItems: "center", flexShrink: 0, paddingTop: 4 }}>
<button type="button" className="btn-primary" onClick={() => setShowNew(true)}>
+ New project
</button>
</div>
</div>
{!loading && projects.length === 0 && (
<div style={{ marginBottom: 36 }}>
<div
style={{
background: "var(--white)",
border: "1px solid var(--border)",
borderRadius: 16,
padding: "48px 36px",
textAlign: "center",
maxWidth: 480,
margin: "0 auto",
}}
>
<div
style={{
width: 56,
height: 56,
borderRadius: 14,
background: "linear-gradient(135deg,rgba(99,102,241,0.12),rgba(139,92,246,0.12))",
display: "flex",
alignItems: "center",
justifyContent: "center",
margin: "0 auto 20px",
fontSize: 26,
}}
>
</div>
<h2 className="f" style={{ fontSize: 20, fontWeight: 700, color: "var(--ink)", marginBottom: 8, letterSpacing: "-0.02em" }}>
Build your first product
</h2>
<p style={{ fontSize: 14, color: "var(--muted)", lineHeight: 1.6, marginBottom: 24 }}>
Describe your idea, and vibn will architect, design, and help you ship it no code required.
</p>
<button type="button" className="btn-primary" style={{ padding: "11px 28px", fontSize: 14 }} onClick={() => setShowNew(true)}>
+ Start a new project
</button>
</div>
</div>
)}
<div className="dash-section-title">Portfolio snapshot</div>
<div className="snap-grid">
<div className="snap-card">
<div className="snap-value">{projects.length}</div>
<div className="snap-label">Active projects</div>
</div>
<div className="snap-card">
<div className="snap-value" style={{ color: "var(--green)" }}>
{liveN}
</div>
<div className="snap-label">Live products</div>
</div>
<div className="snap-card">
<div className="snap-value" style={{ color: "#4338CA" }}>
{buildingN}
</div>
<div className="snap-label">Building now</div>
</div>
<div className="snap-card" style={{ borderColor: "var(--amber-border)", background: "var(--amber-dim)" }}>
<div className="snap-value" style={{ color: "#92400E" }}>
{totalCosts > 0 ? `$${totalCosts.toFixed(2)}` : "—"}
</div>
<div className="snap-label" style={{ color: "#B45309" }}>
API spend (est.)
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<ProjectCreationModal
open={showNew}
onOpenChange={(open) => {
setShowNew(open);
if (!open) fetchProjects();
}}
workspace={workspace}
/>
<AlertDialog open={!!projectToDelete} onOpenChange={(open) => !open && setProjectToDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete &quot;{projectToDelete?.productName}&quot;?</AlertDialogTitle>
<AlertDialogDescription>
This will remove the project record. Sessions will be preserved but unlinked. The Gitea repo will not be deleted automatically.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={isDeleting} className="bg-red-600 hover:bg-red-700">
{isDeleting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Trash2 className="mr-2 h-4 w-4" />}
Delete project
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -1,287 +0,0 @@
"use client";
import { useState, useEffect, useRef } from "react";
interface CooMessage {
id: string;
role: "user" | "assistant";
content: string;
source?: "atlas" | "coo"; // atlas = discovery history, coo = orchestrator response
streaming?: boolean;
}
export function CooChat({ projectId }: { projectId: string }) {
const [messages, setMessages] = useState<CooMessage[]>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [historyLoaded, setHistoryLoaded] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Scroll to bottom whenever messages change
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
// Pre-load Atlas discovery history on mount
useEffect(() => {
fetch(`/api/projects/${projectId}/atlas-chat`)
.then(r => r.json())
.then((data: { messages?: Array<{ role: "user" | "assistant"; content: string }> }) => {
const atlasMessages: CooMessage[] = (data.messages ?? [])
.filter(m => m.content?.trim())
.map((m, i) => ({
id: `atlas_${i}`,
role: m.role,
content: m.content,
source: "atlas" as const,
}));
if (atlasMessages.length > 0) {
// Add a small divider message at the bottom of Atlas history
setMessages([
...atlasMessages,
{
id: "coo_divider",
role: "assistant",
content: "Discovery complete. I'm your product COO — I have the full context above. What do you need?",
source: "coo" as const,
},
]);
} else {
// No Atlas history — show default COO welcome
setMessages([{
id: "welcome",
role: "assistant",
content: "Hi. I'm your product COO — I know your codebase, your goals, and what's been built. What do you need?",
source: "coo" as const,
}]);
}
setHistoryLoaded(true);
})
.catch(() => {
setMessages([{
id: "welcome",
role: "assistant",
content: "Hi. I'm your product COO — I know your codebase, your goals, and what's been built. What do you need?",
source: "coo" as const,
}]);
setHistoryLoaded(true);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId]);
const send = async () => {
const text = input.trim();
if (!text || loading) return;
setInput("");
const userMsg: CooMessage = { id: Date.now().toString(), role: "user", content: text, source: "coo" };
const assistantId = (Date.now() + 1).toString();
const assistantMsg: CooMessage = { id: assistantId, role: "assistant", content: "", source: "coo", streaming: true };
setMessages(prev => [...prev, userMsg, assistantMsg]);
setLoading(true);
// Build history from COO messages only (skip atlas history for context to orchestrator)
const history = messages
.filter(m => m.source === "coo" && m.id !== "coo_divider" && m.content)
.map(m => ({ role: m.role === "assistant" ? "model" as const : "user" as const, content: m.content }));
try {
const res = await fetch(`/api/projects/${projectId}/advisor`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text, history }),
});
if (!res.ok || !res.body) {
setMessages(prev => prev.map(m => m.id === assistantId
? { ...m, content: "Something went wrong. Please try again.", streaming: false }
: m));
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
setMessages(prev => prev.map(m => m.id === assistantId
? { ...m, content: m.content + chunk }
: m));
}
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, streaming: false } : m));
} catch {
setMessages(prev => prev.map(m => m.id === assistantId
? { ...m, content: "Connection error. Please try again.", streaming: false }
: m));
} finally {
setLoading(false);
textareaRef.current?.focus();
}
};
if (!historyLoaded) {
return (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>
<div style={{ display: "flex", gap: 4 }}>
{[0, 1, 2].map(i => (
<span key={i} style={{
width: 4, height: 4, borderRadius: "50%",
background: "#d4cfc6", display: "inline-block",
animation: `cooBounce 1.2s ${i * 0.2}s ease-in-out infinite`,
}} />
))}
</div>
</div>
);
}
return (
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
{/* Messages */}
<div style={{ flex: 1, overflow: "auto", padding: "12px 14px 8px", display: "flex", flexDirection: "column", gap: 10 }}>
{messages.map((msg, idx) => {
const isAtlas = msg.source === "atlas";
const isUser = msg.role === "user";
const isCoo = !isUser && !isAtlas;
// Separator before the divider message
const prevMsg = messages[idx - 1];
const showSeparator = msg.id === "coo_divider" && prevMsg?.source === "atlas";
return (
<div key={msg.id}>
{showSeparator && (
<div style={{
display: "flex", alignItems: "center", gap: 8,
margin: "8px 0 4px", opacity: 0.5,
}}>
<div style={{ flex: 1, height: 1, background: "#e8e4dc" }} />
<span style={{ fontSize: "0.58rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", whiteSpace: "nowrap" }}>
Discovery · COO
</span>
<div style={{ flex: 1, height: 1, background: "#e8e4dc" }} />
</div>
)}
<div style={{
display: "flex",
flexDirection: isUser ? "row-reverse" : "row",
alignItems: "flex-end",
gap: 6,
}}>
{/* Avatar */}
{!isUser && (
<span style={{
width: 18, height: 18, borderRadius: 5,
background: isAtlas ? "#4a6fa5" : "#1a1a1a",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: isAtlas ? "0.48rem" : "0.48rem",
color: "#fff", flexShrink: 0,
fontFamily: isAtlas ? "var(--font-lora), ui-serif, serif" : "inherit",
fontWeight: isAtlas ? 700 : 400,
}}>
{isAtlas ? "A" : "◈"}
</span>
)}
<div style={{
maxWidth: "88%",
padding: isUser ? "7px 10px" : "0",
background: isUser ? "#f0ece4" : "transparent",
borderRadius: isUser ? 10 : 0,
fontSize: isAtlas ? "0.75rem" : "0.79rem",
color: isAtlas ? "#4a4540" : "#1a1a1a",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
lineHeight: 1.6,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
opacity: isAtlas ? 0.85 : 1,
}}>
{msg.content}
{msg.streaming && msg.content === "" && (
<span style={{ display: "inline-flex", gap: 3, alignItems: "center", height: "1em" }}>
{[0, 1, 2].map(i => (
<span key={i} style={{
width: 4, height: 4, borderRadius: "50%",
background: "#b5b0a6", display: "inline-block",
animation: `cooBounce 1.2s ${i * 0.2}s ease-in-out infinite`,
}} />
))}
</span>
)}
{msg.streaming && msg.content !== "" && (
<span style={{
display: "inline-block", width: 2, height: "0.85em",
background: "#1a1a1a", marginLeft: 1,
verticalAlign: "text-bottom",
animation: "cooBlink 1s step-end infinite",
}} />
)}
</div>
</div>
</div>
);
})}
<div ref={bottomRef} />
</div>
{/* Input */}
<div style={{ flexShrink: 0, borderTop: "1px solid #e8e4dc", padding: "10px 12px 10px", background: "#fff" }}>
<div style={{ display: "flex", gap: 7, alignItems: "flex-end" }}>
<textarea
ref={textareaRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); }
}}
placeholder={loading ? "Thinking…" : "Ask anything…"}
disabled={loading}
rows={2}
style={{
flex: 1, resize: "none",
border: "1px solid #e8e4dc", borderRadius: 10,
padding: "8px 10px", fontSize: "0.79rem",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
color: "#1a1a1a", outline: "none",
background: "#faf8f5", lineHeight: 1.5,
}}
/>
<button
onClick={send}
disabled={!input.trim() || loading}
style={{
width: 32, height: 32, flexShrink: 0,
border: "none", borderRadius: 8,
background: input.trim() && !loading ? "#1a1a1a" : "#e8e4dc",
color: input.trim() && !loading ? "#fff" : "#b5b0a6",
cursor: input.trim() && !loading ? "pointer" : "default",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.85rem",
}}
></button>
</div>
<div style={{ fontSize: "0.6rem", color: "#c5c0b8", marginTop: 5, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
send · Shift+ newline
</div>
</div>
<style>{`
@keyframes cooBounce {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-4px); }
}
@keyframes cooBlink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`}</style>
</div>
);
}

View File

@@ -1,165 +0,0 @@
"use client";
import { usePathname } from "next/navigation";
import { ReactNode, Suspense } from "react";
import Link from "next/link";
import { signOut, useSession } from "next-auth/react";
import { Toaster } from "sonner";
interface ProjectShellProps {
children: ReactNode;
workspace: string;
projectId: string;
projectName: string;
projectDescription?: string;
projectStatus?: string;
projectProgress?: number;
discoveryPhase?: number;
capturedData?: Record<string, string>;
createdAt?: string;
updatedAt?: string;
featureCount?: number;
creationMode?: "fresh" | "chat-import" | "code-import" | "migration";
}
const SECTIONS = [
{ id: "overview", label: "Vibn", path: "overview" },
{ id: "mvp-setup", label: "Plan", path: "mvp-setup" },
{ id: "tasks", label: "Task", path: "tasks" },
{ id: "build", label: "Build", path: "build" },
{ id: "run", label: "Run", path: "run" },
{ id: "growth", label: "Grow", path: "growth" },
{ id: "assist", label: "Assist", path: "assist" },
{ id: "analytics", label: "Analyze", path: "analytics" },
] as const;
function ProjectShellInner({
children,
workspace,
projectId,
projectName,
}: ProjectShellProps) {
const pathname = usePathname();
const { data: session } = useSession();
const activeSection =
pathname?.includes("/overview") ? "overview" :
pathname?.includes("/mvp-setup") ? "mvp-setup" :
pathname?.includes("/tasks") ? "tasks" :
pathname?.includes("/prd") ? "tasks" :
pathname?.includes("/build") ? "build" :
pathname?.includes("/run") ? "run" :
pathname?.includes("/infrastructure") ? "run" :
pathname?.includes("/growth") ? "growth" :
pathname?.includes("/assist") ? "assist" :
pathname?.includes("/analytics") ? "analytics" :
"overview";
const userInitial = (
session?.user?.name?.[0] ?? session?.user?.email?.[0] ?? "?"
).toUpperCase();
return (
<>
<div style={{
display: "flex", flexDirection: "column",
height: "100dvh", overflow: "hidden",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
background: "var(--background)",
}}>
{/* ── Top bar ── */}
<header style={{
height: 48, flexShrink: 0,
display: "flex", alignItems: "stretch",
background: "var(--card)", borderBottom: "1px solid var(--border)",
zIndex: 10,
}}>
{/* Logo + project name */}
<div style={{
display: "flex", alignItems: "center",
padding: "0 16px", gap: 9, flexShrink: 0,
borderRight: "1px solid var(--border)",
}}>
<Link
href={`/${workspace}/projects`}
style={{ display: "flex", alignItems: "center", textDecoration: "none", flexShrink: 0 }}
>
<div style={{ width: 22, height: 22, borderRadius: 6, overflow: "hidden" }}>
<img src="/vibn-black-circle-logo.png" alt="VIBN" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
</div>
</Link>
<span style={{
fontSize: "0.82rem", fontWeight: 600, color: "var(--foreground)",
maxWidth: 160, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
}}>
{projectName}
</span>
</div>
{/* Tab nav */}
<div style={{ flex: 1, display: "flex", alignItems: "center", padding: "0 12px", gap: 2 }}>
{SECTIONS.map(s => {
const isActive = activeSection === s.id;
return (
<Link
key={s.id}
href={`/${workspace}/project/${projectId}/${s.path}`}
style={{
padding: "5px 12px", borderRadius: 8,
fontSize: "0.8rem",
fontWeight: isActive ? 600 : 440,
color: isActive ? "var(--foreground)" : "var(--muted-foreground)",
background: isActive ? "var(--secondary)" : "transparent",
textDecoration: "none",
transition: "background 0.1s, color 0.1s",
whiteSpace: "nowrap",
}}
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "var(--muted)"; }}
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
{s.label}
</Link>
);
})}
{/* Spacer */}
<div style={{ flex: 1 }} />
{/* User avatar */}
<button
onClick={() => signOut({ callbackUrl: "/auth" })}
title={`${session?.user?.name ?? session?.user?.email ?? "Account"} — Sign out`}
style={{
width: 28, height: 28, borderRadius: "50%",
background: "var(--secondary)", border: "none", cursor: "pointer",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.65rem", fontWeight: 700, color: "var(--muted-foreground)", flexShrink: 0,
}}
>
{userInitial}
</button>
</div>
</header>
{/* ── Full-width content ── */}
<div style={{ flex: 1, overflow: "hidden", display: "flex", flexDirection: "column" }}>
{children}
</div>
</div>
<Toaster position="top-center" />
</>
);
}
// Wrap in Suspense because useSearchParams requires it
export function ProjectShell(props: ProjectShellProps) {
return (
<Suspense fallback={null}>
<ProjectShellInner {...props} />
</Suspense>
);
}

View File

@@ -1,306 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { db, auth } from '@/lib/firebase/config';
import { collection, query, where, limit, getDocs, orderBy } from 'firebase/firestore';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { FolderOpen, Plus, Link as LinkIcon } from 'lucide-react';
import { toast } from 'sonner';
import { ProjectCreationModal } from './project-creation-modal';
interface UnassociatedWorkspace {
workspacePath: string;
workspaceName: string;
sessionCount: number;
}
interface Project {
id: string;
name: string;
productName: string;
slug: string;
}
export function ProjectAssociationPrompt({ workspace }: { workspace: string }) {
// Temporarily disabled - will be re-enabled with better UX
return null;
const [unassociatedWorkspace, setUnassociatedWorkspace] = useState<UnassociatedWorkspace | null>(null);
const [projects, setProjects] = useState<Project[]>([]);
const [showDialog, setShowDialog] = useState(false);
const [showCreationModal, setShowCreationModal] = useState(false);
const [loading, setLoading] = useState(false);
const [dismissedWorkspaces, setDismissedWorkspaces] = useState<Set<string>>(new Set());
const [hasCheckedThisSession, setHasCheckedThisSession] = useState(false);
// Load dismissed workspaces from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem('dismissedWorkspaces');
if (stored) {
try {
setDismissedWorkspaces(new Set(JSON.parse(stored)));
} catch (e) {
console.error('Error loading dismissed workspaces:', e);
}
}
}, []);
useEffect(() => {
let unsubscribe: () => void;
const checkForUnassociatedSessions = async (user: any) => {
// Check if we've already shown the prompt in this browser session
const lastPromptTime = sessionStorage.getItem('vibn_last_workspace_prompt');
const now = Date.now();
const fiveMinutes = 5 * 60 * 1000;
if (lastPromptTime && (now - parseInt(lastPromptTime)) < fiveMinutes) {
console.log('⏭️ Already checked recently, skipping');
return;
}
try {
// Mark that we've checked
sessionStorage.setItem('vibn_last_workspace_prompt', now.toString());
// Check for sessions that need project association
const sessionsRef = collection(db, 'sessions');
const q = query(
sessionsRef,
where('userId', '==', user.uid),
where('needsProjectAssociation', '==', true),
orderBy('createdAt', 'desc'),
limit(1)
);
const snapshot = await getDocs(q);
if (!snapshot.empty) {
const session = snapshot.docs[0].data();
// Check if this workspace was dismissed
if (dismissedWorkspaces.has(session.workspacePath)) {
console.log('⏭️ Workspace was dismissed, skipping prompt');
return;
}
// Count sessions from this workspace
const countQuery = query(
sessionsRef,
where('userId', '==', user.uid),
where('workspacePath', '==', session.workspacePath),
where('needsProjectAssociation', '==', true)
);
const countSnapshot = await getDocs(countQuery);
setUnassociatedWorkspace({
workspacePath: session.workspacePath,
workspaceName: session.workspaceName || 'Unknown',
sessionCount: countSnapshot.size,
});
// Fetch user's projects for linking
const projectsRef = collection(db, 'projects');
const projectsQuery = query(
projectsRef,
where('userId', '==', user.uid),
orderBy('createdAt', 'desc')
);
const projectsSnapshot = await getDocs(projectsQuery);
const userProjects = projectsSnapshot.docs.map(doc => ({
id: doc.id,
name: doc.data().name,
productName: doc.data().productName,
slug: doc.data().slug,
}));
setProjects(userProjects);
setShowDialog(true);
}
} catch (error: any) {
// Silently handle index building errors - the feature will work once indexes are ready
if (error?.message?.includes('index')) {
console.log('⏳ Firestore indexes are still building. Project detection will be available shortly.');
} else {
console.error('Error checking for unassociated sessions:', error);
}
}
};
unsubscribe = auth.onAuthStateChanged((user) => {
if (user) {
checkForUnassociatedSessions(user);
}
});
return () => {
if (unsubscribe) unsubscribe();
};
}, []); // Empty dependency array - only run once on mount
const handleCreateNewProject = () => {
setShowDialog(false);
setShowCreationModal(true);
};
const handleRemindLater = () => {
if (!unassociatedWorkspace) return;
// Add to dismissed list
const newDismissed = new Set(dismissedWorkspaces);
newDismissed.add(unassociatedWorkspace.workspacePath);
setDismissedWorkspaces(newDismissed);
// Save to localStorage
localStorage.setItem('dismissedWorkspaces', JSON.stringify(Array.from(newDismissed)));
// Close dialog
setShowDialog(false);
setUnassociatedWorkspace(null);
toast.info('💡 We\'ll remind you next time you visit');
};
const handleLinkToProject = async (projectId: string) => {
if (!unassociatedWorkspace || !auth.currentUser) return;
setLoading(true);
try {
const response = await fetch('/api/sessions/associate-project', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
workspacePath: unassociatedWorkspace.workspacePath,
projectId,
userId: auth.currentUser.uid,
}),
});
if (response.ok) {
const data = await response.json();
toast.success(`✅ Linked ${data.sessionsUpdated} sessions to project!`);
setShowDialog(false);
setUnassociatedWorkspace(null);
} else {
toast.error('Failed to link sessions to project');
}
} catch (error) {
console.error('Error linking project:', error);
toast.error('An error occurred while linking');
} finally {
setLoading(false);
}
};
if (!unassociatedWorkspace) return null;
return (
<>
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FolderOpen className="h-5 w-5" />
New Workspace Detected
</DialogTitle>
<DialogDescription>
We detected coding activity in a new workspace
</DialogDescription>
</DialogHeader>
{unassociatedWorkspace && (
<div className="my-4 p-4 bg-muted rounded-lg">
<div className="flex items-center gap-2 mb-2">
<span className="text-2xl">📂</span>
<div>
<p className="font-semibold">{unassociatedWorkspace?.workspaceName}</p>
<p className="text-xs text-muted-foreground font-mono truncate" title={unassociatedWorkspace?.workspacePath}>
{unassociatedWorkspace?.workspacePath}
</p>
</div>
</div>
<p className="text-sm text-muted-foreground mt-2">
{unassociatedWorkspace?.sessionCount} coding session{(unassociatedWorkspace?.sessionCount || 0) > 1 ? 's' : ''} tracked
</p>
</div>
)}
<div className="space-y-3">
<p className="text-sm font-medium">What would you like to do?</p>
<Button
className="w-full justify-start"
variant="outline"
onClick={handleCreateNewProject}
disabled={loading}
>
<Plus className="mr-2 h-4 w-4" />
Create New Project
</Button>
{projects.length > 0 && (
<>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or link to existing project
</span>
</div>
</div>
<div className="space-y-2 max-h-[200px] overflow-y-auto">
{projects.map((project) => (
<Button
key={project.id}
className="w-full justify-start"
variant="ghost"
onClick={() => handleLinkToProject(project.id)}
disabled={loading}
>
<LinkIcon className="mr-2 h-4 w-4" />
{project.productName}
</Button>
))}
</div>
</>
)}
</div>
<DialogFooter className="mt-4">
<Button variant="ghost" onClick={handleRemindLater} disabled={loading}>
Remind Me Later
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Project Creation Modal */}
<ProjectCreationModal
open={showCreationModal}
onOpenChange={(open) => {
setShowCreationModal(open);
if (!open) {
// Refresh to check for newly created project
setUnassociatedWorkspace(null);
}
}}
workspace={workspace}
/>
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,330 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter, useParams } from "next/navigation";
interface AnalysisResult {
decisions: string[];
ideas: string[];
openQuestions: string[];
architecture: string[];
targetUsers: string[];
}
interface ChatImportMainProps {
projectId: string;
projectName: string;
sourceData?: { chatText?: string };
analysisResult?: AnalysisResult;
}
type Stage = "intake" | "extracting" | "review";
function EditableList({
label,
items,
accent,
onChange,
}: {
label: string;
items: string[];
accent: string;
onChange: (items: string[]) => void;
}) {
const handleEdit = (i: number, value: string) => {
const next = [...items];
next[i] = value;
onChange(next);
};
const handleDelete = (i: number) => {
onChange(items.filter((_, idx) => idx !== i));
};
const handleAdd = () => {
onChange([...items, ""]);
};
return (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: "0.68rem", fontWeight: 700, color: accent, letterSpacing: "0.06em", textTransform: "uppercase", marginBottom: 8 }}>
{label}
</div>
{items.length === 0 && (
<p style={{ fontSize: "0.75rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", margin: "0 0 6px" }}>
Nothing captured.
</p>
)}
{items.map((item, i) => (
<div key={i} style={{ display: "flex", gap: 6, marginBottom: 5 }}>
<input
type="text"
value={item}
onChange={e => handleEdit(i, e.target.value)}
style={{
flex: 1, padding: "7px 10px", borderRadius: 6,
border: "1px solid #e0dcd4", background: "#faf8f5",
fontSize: "0.81rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
color: "#1a1a1a", outline: "none",
}}
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
/>
<button
onClick={() => handleDelete(i)}
style={{ background: "none", border: "none", cursor: "pointer", color: "#c5c0b8", fontSize: "0.85rem", padding: "4px 6px" }}
onMouseEnter={e => (e.currentTarget.style.color = "#e53e3e")}
onMouseLeave={e => (e.currentTarget.style.color = "#c5c0b8")}
>
×
</button>
</div>
))}
<button
onClick={handleAdd}
style={{
background: "none", border: "1px dashed #e0dcd4", cursor: "pointer",
borderRadius: 6, padding: "5px 10px", fontSize: "0.72rem", color: "#a09a90",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", width: "100%",
}}
onMouseEnter={e => (e.currentTarget.style.borderColor = "#b5b0a6")}
onMouseLeave={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
>
+ Add
</button>
</div>
);
}
export function ChatImportMain({
projectId,
projectName,
sourceData,
analysisResult: initialResult,
}: ChatImportMainProps) {
const router = useRouter();
const params = useParams();
const workspace = params?.workspace as string;
const hasChatText = !!sourceData?.chatText;
const [stage, setStage] = useState<Stage>(
initialResult ? "review" : hasChatText ? "extracting" : "intake"
);
const [chatText, setChatText] = useState(sourceData?.chatText ?? "");
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<AnalysisResult>(
initialResult ?? { decisions: [], ideas: [], openQuestions: [], architecture: [], targetUsers: [] }
);
// Kick off extraction automatically if chatText is ready
useEffect(() => {
if (stage === "extracting") {
runExtraction();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stage]);
const runExtraction = async () => {
setError(null);
try {
const res = await fetch(`/api/projects/${projectId}/analyze-chats`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chatText }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Extraction failed");
setResult(data.analysisResult);
setStage("review");
} catch (e) {
setError(e instanceof Error ? e.message : "Something went wrong");
setStage("intake");
}
};
const handlePRD = () => router.push(`/${workspace}/project/${projectId}/tasks`);
const handleMVP = () => router.push(`/${workspace}/project/${projectId}/mvp-setup/launch`);
// ── Stage: intake ─────────────────────────────────────────────────────────
if (stage === "intake") {
return (
<div style={{ height: "100%", overflow: "auto", display: "flex", alignItems: "center", justifyContent: "center", padding: 32 }}>
<div style={{ width: "100%", maxWidth: 640, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ marginBottom: 28 }}>
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
Paste your chat history
</h2>
<p style={{ fontSize: "0.82rem", color: "#a09a90", margin: 0 }}>
{projectName} Atlas will extract decisions, ideas, architecture notes, and more.
</p>
</div>
{error && (
<div style={{ padding: "12px 16px", borderRadius: 8, background: "#fff0f0", border: "1px solid #fca5a5", color: "#991b1b", fontSize: "0.8rem", marginBottom: 16 }}>
{error}
</div>
)}
<textarea
value={chatText}
onChange={e => setChatText(e.target.value)}
placeholder={"Paste conversations from ChatGPT, Claude, Gemini, or any AI tool.\n\nCopy the full conversation — Atlas handles the cleanup."}
rows={14}
style={{
width: "100%", padding: "14px 16px", marginBottom: 16,
borderRadius: 10, border: "1px solid #e0dcd4",
background: "#faf8f5", fontSize: "0.85rem", lineHeight: 1.6,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a",
outline: "none", resize: "vertical", boxSizing: "border-box",
}}
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
/>
<button
onClick={() => {
if (chatText.trim().length > 20) {
setStage("extracting");
}
}}
disabled={chatText.trim().length < 20}
style={{
width: "100%", padding: "13px",
borderRadius: 8, border: "none",
background: chatText.trim().length > 20 ? "#1a1a1a" : "#e0dcd4",
color: chatText.trim().length > 20 ? "#fff" : "#b5b0a6",
fontSize: "0.9rem", fontWeight: 600,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
cursor: chatText.trim().length > 20 ? "pointer" : "not-allowed",
}}
>
Extract insights
</button>
</div>
</div>
);
}
// ── Stage: extracting ─────────────────────────────────────────────────────
if (stage === "extracting") {
return (
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ textAlign: "center" }}>
<div style={{
width: 48, height: 48, borderRadius: "50%",
border: "3px solid #e0dcd4", borderTopColor: "#1a1a1a",
animation: "vibn-chat-spin 0.8s linear infinite",
margin: "0 auto 20px",
}} />
<style>{`@keyframes vibn-chat-spin { to { transform:rotate(360deg); } }`}</style>
<h3 style={{ fontSize: "1.05rem", fontWeight: 600, color: "#1a1a1a", margin: "0 0 6px" }}>
Analysing your chats
</h3>
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: 0 }}>
Atlas is extracting decisions, ideas, and insights
</p>
</div>
</div>
);
}
// ── Stage: review ─────────────────────────────────────────────────────────
return (
<div style={{ height: "100%", overflow: "auto", padding: "32px 40px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ maxWidth: 760, margin: "0 auto" }}>
<div style={{ marginBottom: 28 }}>
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
What Atlas found
</h2>
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: 0 }}>
Review and edit the extracted insights for <strong>{projectName}</strong>. These will seed your PRD or MVP plan.
</p>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20, marginBottom: 28 }}>
{/* Left column */}
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "20px 22px" }}>
<EditableList
label="Decisions made"
items={result.decisions}
accent="#1a3a5c"
onChange={items => setResult(r => ({ ...r, decisions: items }))}
/>
</div>
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "20px 22px" }}>
<EditableList
label="Ideas & features"
items={result.ideas}
accent="#2e5a4a"
onChange={items => setResult(r => ({ ...r, ideas: items }))}
/>
</div>
</div>
{/* Right column */}
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "20px 22px" }}>
<EditableList
label="Open questions"
items={result.openQuestions}
accent="#9a7b3a"
onChange={items => setResult(r => ({ ...r, openQuestions: items }))}
/>
</div>
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "20px 22px" }}>
<EditableList
label="Architecture notes"
items={result.architecture}
accent="#4a3728"
onChange={items => setResult(r => ({ ...r, architecture: items }))}
/>
</div>
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", padding: "20px 22px" }}>
<EditableList
label="Target users"
items={result.targetUsers}
accent="#4a2a5a"
onChange={items => setResult(r => ({ ...r, targetUsers: items }))}
/>
</div>
</div>
</div>
{/* Decision buttons */}
<div style={{
background: "#1a1a1a", borderRadius: 12, padding: "22px 24px",
display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16, flexWrap: "wrap",
}}>
<div>
<div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#fff", marginBottom: 3 }}>Ready to move forward?</div>
<div style={{ fontSize: "0.75rem", color: "#8a8478" }}>Choose how you want to proceed with {projectName}.</div>
</div>
<div style={{ display: "flex", gap: 10 }}>
<button
onClick={handlePRD}
style={{
padding: "11px 22px", borderRadius: 8, border: "none",
background: "#fff", color: "#1a1a1a",
fontSize: "0.85rem", fontWeight: 700, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
}}
onMouseEnter={e => (e.currentTarget.style.opacity = "0.88")}
onMouseLeave={e => (e.currentTarget.style.opacity = "1")}
>
Generate PRD
</button>
<button
onClick={handleMVP}
style={{
padding: "11px 22px", borderRadius: 8,
border: "1px solid rgba(255,255,255,0.2)", background: "transparent", color: "#fff",
fontSize: "0.85rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
}}
onMouseEnter={e => (e.currentTarget.style.background = "rgba(255,255,255,0.08)")}
onMouseLeave={e => (e.currentTarget.style.background = "transparent")}
>
Plan MVP
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,363 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter, useParams } from "next/navigation";
interface ArchRow {
category: string;
item: string;
status: "found" | "partial" | "missing";
detail?: string;
}
interface AnalysisResult {
summary: string;
rows: ArchRow[];
suggestedSurfaces: string[];
}
interface CodeImportMainProps {
projectId: string;
projectName: string;
sourceData?: { repoUrl?: string };
analysisResult?: AnalysisResult;
creationStage?: string;
}
type Stage = "input" | "cloning" | "mapping" | "surfaces";
const STATUS_COLORS = {
found: { bg: "#f0fdf4", text: "#15803d", label: "Found" },
partial: { bg: "#fffbeb", text: "#b45309", label: "Partial" },
missing: { bg: "#fff1f2", text: "#be123c", label: "Missing" },
};
const CATEGORY_ORDER = [
"Tech Stack", "Infrastructure", "Database", "API Surface",
"Frontend", "Auth", "Third-party", "Missing / Gaps",
];
const PROGRESS_STEPS = [
{ key: "cloning", label: "Cloning repository" },
{ key: "reading", label: "Reading key files" },
{ key: "analyzing", label: "Mapping architecture" },
{ key: "done", label: "Analysis complete" },
];
export function CodeImportMain({
projectId,
projectName,
sourceData,
analysisResult: initialResult,
creationStage,
}: CodeImportMainProps) {
const router = useRouter();
const params = useParams();
const workspace = params?.workspace as string;
const hasRepo = !!sourceData?.repoUrl;
const getInitialStage = (): Stage => {
if (initialResult) return "mapping";
if (creationStage === "surfaces") return "surfaces";
if (hasRepo) return "cloning";
return "input";
};
const [stage, setStage] = useState<Stage>(getInitialStage);
const [repoUrl, setRepoUrl] = useState(sourceData?.repoUrl ?? "");
const [progressStep, setProgressStep] = useState<string>("cloning");
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<AnalysisResult | null>(initialResult ?? null);
const [confirmedSurfaces, setConfirmedSurfaces] = useState<string[]>(
initialResult?.suggestedSurfaces ?? []
);
// Kick off analysis when in cloning stage
useEffect(() => {
if (stage !== "cloning") return;
startAnalysis();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stage]);
// Poll for analysis status when cloning
useEffect(() => {
if (stage !== "cloning") return;
const interval = setInterval(async () => {
try {
const res = await fetch(`/api/projects/${projectId}/analysis-status`);
const data = await res.json();
setProgressStep(data.stage ?? "cloning");
if (data.stage === "done" && data.analysisResult) {
setResult(data.analysisResult);
setConfirmedSurfaces(data.analysisResult.suggestedSurfaces ?? []);
clearInterval(interval);
setStage("mapping");
}
} catch { /* keep polling */ }
}, 2500);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stage]);
const startAnalysis = async () => {
setError(null);
try {
await fetch(`/api/projects/${projectId}/analyze-repo`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ repoUrl }),
});
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to start analysis");
setStage("input");
}
};
const handleConfirmSurfaces = async () => {
try {
await fetch(`/api/projects/${projectId}/design-surfaces`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ surfaces: confirmedSurfaces }),
});
router.push(`/${workspace}/project/${projectId}/design`);
} catch { /* navigate anyway */ }
};
const toggleSurface = (s: string) => {
setConfirmedSurfaces(prev =>
prev.includes(s) ? prev.filter(x => x !== s) : [...prev, s]
);
};
// ── Stage: input ──────────────────────────────────────────────────────────
if (stage === "input") {
const isValid = repoUrl.trim().startsWith("http");
return (
<div style={{ height: "100%", overflow: "auto", display: "flex", alignItems: "center", justifyContent: "center", padding: 32 }}>
<div style={{ width: "100%", maxWidth: 540, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ marginBottom: 28 }}>
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
Import your repository
</h2>
<p style={{ fontSize: "0.82rem", color: "#a09a90", margin: 0 }}>
{projectName} paste a clone URL to map your existing stack.
</p>
</div>
{error && (
<div style={{ padding: "12px 16px", borderRadius: 8, background: "#fff0f0", border: "1px solid #fca5a5", color: "#991b1b", fontSize: "0.8rem", marginBottom: 16 }}>
{error}
</div>
)}
<label style={{ display: "block", fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6, letterSpacing: "0.02em" }}>
Repository URL (HTTPS)
</label>
<input
type="text"
value={repoUrl}
onChange={e => setRepoUrl(e.target.value)}
placeholder="https://github.com/yourorg/your-repo"
style={{
width: "100%", padding: "12px 14px", marginBottom: 16,
borderRadius: 8, border: "1px solid #e0dcd4",
background: "#faf8f5", fontSize: "0.9rem",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a",
outline: "none", boxSizing: "border-box",
}}
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
onKeyDown={e => { if (e.key === "Enter" && isValid) setStage("cloning"); }}
autoFocus
/>
<div style={{ fontSize: "0.75rem", color: "#a09a90", marginBottom: 20, lineHeight: 1.55, padding: "12px 14px", background: "#faf8f5", borderRadius: 8, border: "1px solid #f0ece4" }}>
Atlas will clone and map your stack tech, database, auth, APIs, and what's missing for a complete go-to-market build.
</div>
<button
onClick={() => { if (isValid) setStage("cloning"); }}
disabled={!isValid}
style={{
width: "100%", padding: "13px", borderRadius: 8, border: "none",
background: isValid ? "#1a1a1a" : "#e0dcd4",
color: isValid ? "#fff" : "#b5b0a6",
fontSize: "0.9rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
cursor: isValid ? "pointer" : "not-allowed",
}}
>
Map this repo
</button>
</div>
</div>
);
}
// ── Stage: cloning ────────────────────────────────────────────────────────
if (stage === "cloning") {
const currentIdx = PROGRESS_STEPS.findIndex(s => s.key === progressStep);
return (
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ textAlign: "center", maxWidth: 400 }}>
<div style={{
width: 52, height: 52, borderRadius: "50%",
border: "3px solid #e0dcd4", borderTopColor: "#1a1a1a",
animation: "vibn-repo-spin 0.85s linear infinite",
margin: "0 auto 24px",
}} />
<style>{`@keyframes vibn-repo-spin { to { transform:rotate(360deg); } }`}</style>
<h3 style={{ fontSize: "1.1rem", fontWeight: 600, color: "#1a1a1a", margin: "0 0 8px" }}>
Mapping your codebase
</h3>
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: "0 0 28px" }}>
{repoUrl || sourceData?.repoUrl || "Repository"}
</p>
<div style={{ display: "flex", flexDirection: "column", gap: 8, textAlign: "left" }}>
{PROGRESS_STEPS.map((step, i) => {
const done = i < currentIdx;
const active = i === currentIdx;
return (
<div key={step.key} style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{
width: 22, height: 22, borderRadius: "50%", flexShrink: 0,
background: done ? "#1a1a1a" : active ? "#f6f4f0" : "#f6f4f0",
border: active ? "2px solid #1a1a1a" : done ? "none" : "2px solid #e0dcd4",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.6rem", fontWeight: 700, color: done ? "#fff" : "#a09a90",
}}>
{done ? "✓" : active ? <span style={{ width: 8, height: 8, borderRadius: "50%", background: "#1a1a1a", display: "block" }} /> : ""}
</div>
<span style={{ fontSize: "0.8rem", fontWeight: active ? 600 : 400, color: done ? "#6b6560" : active ? "#1a1a1a" : "#b5b0a6" }}>
{step.label}
</span>
</div>
);
})}
</div>
</div>
</div>
);
}
// ── Stage: mapping ────────────────────────────────────────────────────────
if (stage === "mapping" && result) {
const byCategory: Record<string, ArchRow[]> = {};
for (const row of result.rows) {
const cat = row.category || "Other";
if (!byCategory[cat]) byCategory[cat] = [];
byCategory[cat].push(row);
}
const categories = [
...CATEGORY_ORDER.filter(c => byCategory[c]),
...Object.keys(byCategory).filter(c => !CATEGORY_ORDER.includes(c)),
];
return (
<div style={{ height: "100%", overflow: "auto", padding: "32px 40px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ maxWidth: 800, margin: "0 auto" }}>
<div style={{ marginBottom: 24 }}>
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
Architecture map
</h2>
<p style={{ fontSize: "0.8rem", color: "#a09a90", margin: "0 0 4px" }}>
{projectName} {result.summary}
</p>
</div>
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid #e8e4dc", overflow: "hidden", marginBottom: 24 }}>
{categories.map((cat, catIdx) => (
<div key={cat}>
{catIdx > 0 && <div style={{ height: 1, background: "#f0ece4" }} />}
<div style={{ padding: "12px 20px", background: "#faf8f5", fontSize: "0.68rem", fontWeight: 700, color: "#6b6560", letterSpacing: "0.06em", textTransform: "uppercase" }}>
{cat}
</div>
{byCategory[cat].map((row, i) => {
const sc = STATUS_COLORS[row.status];
return (
<div key={i} style={{ display: "flex", alignItems: "center", gap: 12, padding: "11px 20px", borderTop: "1px solid #f6f4f0" }}>
<div style={{ flex: 1, fontSize: "0.82rem", color: "#1a1a1a", fontWeight: 500 }}>{row.item}</div>
{row.detail && <div style={{ fontSize: "0.75rem", color: "#8a8478", flex: 2 }}>{row.detail}</div>}
<div style={{ padding: "3px 10px", borderRadius: 4, background: sc.bg, color: sc.text, fontSize: "0.68rem", fontWeight: 700, flexShrink: 0 }}>
{sc.label}
</div>
</div>
);
})}
</div>
))}
</div>
<button
onClick={() => setStage("surfaces")}
style={{
width: "100%", padding: "13px", borderRadius: 8, border: "none",
background: "#1a1a1a", color: "#fff",
fontSize: "0.9rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
}}
onMouseEnter={e => (e.currentTarget.style.opacity = "0.88")}
onMouseLeave={e => (e.currentTarget.style.opacity = "1")}
>
Choose what to build next
</button>
</div>
</div>
);
}
// ── Stage: surfaces ───────────────────────────────────────────────────────
const SURFACE_OPTIONS = [
{ id: "marketing", label: "Marketing Site", icon: "◎", desc: "Landing page, pricing, blog" },
{ id: "web-app", label: "Web App", icon: "⬡", desc: "Core SaaS product with auth" },
{ id: "admin", label: "Admin Panel", icon: "◫", desc: "Ops dashboard, content management" },
{ id: "api", label: "API Layer", icon: "⌁", desc: "REST/GraphQL endpoints" },
];
return (
<div style={{ height: "100%", overflow: "auto", display: "flex", alignItems: "center", justifyContent: "center", padding: 32 }}>
<div style={{ width: "100%", maxWidth: 540, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<div style={{ marginBottom: 28 }}>
<h2 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.7rem", fontWeight: 400, color: "#1a1a1a", margin: 0, marginBottom: 6 }}>
What should Atlas build?
</h2>
<p style={{ fontSize: "0.82rem", color: "#a09a90", margin: 0 }}>
Based on the gap analysis, Atlas suggests the surfaces below. Confirm or adjust.
</p>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 24 }}>
{SURFACE_OPTIONS.map(s => {
const selected = confirmedSurfaces.includes(s.id);
return (
<button
key={s.id}
onClick={() => toggleSurface(s.id)}
style={{
padding: "18px", borderRadius: 10, textAlign: "left",
border: `2px solid ${selected ? "#1a1a1a" : "#e8e4dc"}`,
background: selected ? "#1a1a1a08" : "#fff",
cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
transition: "all 0.12s",
}}
onMouseEnter={e => { if (!selected) e.currentTarget.style.borderColor = "#d0ccc4"; }}
onMouseLeave={e => { if (!selected) e.currentTarget.style.borderColor = "#e8e4dc"; }}
>
<div style={{ fontSize: "1.2rem", marginBottom: 8 }}>{s.icon}</div>
<div style={{ fontSize: "0.84rem", fontWeight: 700, color: "#1a1a1a", marginBottom: 3 }}>{s.label}</div>
<div style={{ fontSize: "0.73rem", color: "#8a8478" }}>{s.desc}</div>
</button>
);
})}
</div>
<button
onClick={handleConfirmSurfaces}
disabled={confirmedSurfaces.length === 0}
style={{
width: "100%", padding: "13px", borderRadius: 8, border: "none",
background: confirmedSurfaces.length > 0 ? "#1a1a1a" : "#e0dcd4",
color: confirmedSurfaces.length > 0 ? "#fff" : "#b5b0a6",
fontSize: "0.9rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
cursor: confirmedSurfaces.length > 0 ? "pointer" : "not-allowed",
}}
>
Go to Design
</button>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More