feat(ai): optimize tool loops, fix deployments, and integrate new onboarding flow
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"projects": {
|
||||
"default": "gen-lang-client-0980079410"
|
||||
}
|
||||
}
|
||||
|
||||
447
app/(onboarding)/onboarding/onboarding-build.tsx
Normal file
447
app/(onboarding)/onboarding/onboarding-build.tsx
Normal 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" },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
296
app/(onboarding)/onboarding/onboarding-consultant.tsx
Normal file
296
app/(onboarding)/onboarding/onboarding-consultant.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
276
app/(onboarding)/onboarding/onboarding-entrepreneur.tsx
Normal file
276
app/(onboarding)/onboarding/onboarding-entrepreneur.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
136
app/(onboarding)/onboarding/onboarding-fork.tsx
Normal file
136
app/(onboarding)/onboarding/onboarding-fork.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
264
app/(onboarding)/onboarding/onboarding-owner.tsx
Normal file
264
app/(onboarding)/onboarding/onboarding-owner.tsx
Normal 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: "1–3 years" },
|
||||
{ id: "3_10", label: "3–10 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
459
app/(onboarding)/onboarding/onboarding-primitives.tsx
Normal file
459
app/(onboarding)/onboarding/onboarding-primitives.tsx
Normal 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",
|
||||
};
|
||||
677
app/(onboarding)/onboarding/onboarding.css
Normal file
677
app/(onboarding)/onboarding/onboarding.css
Normal 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; }
|
||||
193
app/(onboarding)/onboarding/page.tsx
Normal file
193
app/(onboarding)/onboarding/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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" />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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.",
|
||||
});
|
||||
}
|
||||
@@ -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.",
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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" },
|
||||
);
|
||||
@@ -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" },
|
||||
);
|
||||
@@ -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" },
|
||||
);
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"}`;
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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" },
|
||||
);
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, "&").replace(/"/g, """).replace(/</g, "<");
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(' | ');
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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
677
app/styles/onboarding.css
Normal 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; }
|
||||
@@ -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));"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 "{projectToDelete?.productName}"?</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user