diff --git a/app/[workspace]/project/[projectId]/analytics/page.tsx b/app/[workspace]/project/[projectId]/analytics/page.tsx new file mode 100644 index 0000000..a907135 --- /dev/null +++ b/app/[workspace]/project/[projectId]/analytics/page.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { Suspense } from "react"; +import { useParams, useSearchParams, useRouter } from "next/navigation"; + +const SECTIONS = [ + { + id: "customers", + label: "Customers", + icon: "◉", + title: "Customer List", + desc: "Every user who has signed up, their plan, usage, last seen, and lifecycle stage. Filter, search, and act on any segment.", + items: ["User Directory", "Lifecycle Stages", "Plan & Billing", "Activity Timeline", "Segment Builder"], + }, + { + id: "usage", + label: "Usage", + icon: "∿", + title: "Usage & Activity", + desc: "How users interact with your product — feature adoption, session frequency, retention curves, and activation funnels.", + items: ["Feature Adoption", "Session Metrics", "Retention Curves", "Activation Funnel", "Power Users"], + }, + { + id: "events", + label: "Events", + icon: "◬", + title: "Events & Tracking", + desc: "Every event your product fires — page views, clicks, conversions, and custom events — all tagged and queryable.", + items: ["Event Stream", "Custom Events", "Page Views", "Conversion Events", "Tag Manager"], + }, + { + id: "reports", + label: "Reports", + icon: "▭", + title: "Reports", + desc: "MRR, churn, DAU/MAU, cohort analysis, and revenue reports. Export or share with your team on a schedule.", + items: ["Revenue (MRR/ARR)", "Churn Report", "DAU / MAU", "Cohort Analysis", "Custom Reports", "Scheduled Exports"], + }, +] as const; + +type SectionId = typeof SECTIONS[number]["id"]; + +const NAV_GROUP: React.CSSProperties = { + fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6", + letterSpacing: "0.09em", textTransform: "uppercase", + padding: "14px 12px 6px", fontFamily: "Outfit, sans-serif", +}; + +function AnalyticsInner() { + const params = useParams(); + const searchParams = useSearchParams(); + const router = useRouter(); + const workspace = params.workspace as string; + const projectId = params.projectId as string; + + const activeId = (searchParams.get("section") ?? "customers") as SectionId; + const active = SECTIONS.find(s => s.id === activeId) ?? SECTIONS[0]; + + const setSection = (id: string) => + router.push(`/${workspace}/project/${projectId}/analytics?section=${id}`, { scroll: false }); + + return ( +
+ + {/* Left nav */} +
+
Analytics
+ {SECTIONS.map(s => { + const isActive = activeId === s.id; + return ( + + ); + })} +
+ + {/* Content */} +
+
+
+
{active.title}
+
{active.desc}
+
+ +
+ {active.items.map(item => ( +
+ {item} + Soon +
+ ))} +
+ +
+
+
{active.title} is coming to VIBN
+
We're building this section next. Shape it by telling us what you need.
+
+ +
+
+
+
+ ); +} + +export default function AnalyticsPage() { + return ( + Loading…}> + + + ); +} diff --git a/app/[workspace]/project/[projectId]/assist/page.tsx b/app/[workspace]/project/[projectId]/assist/page.tsx new file mode 100644 index 0000000..521eed5 --- /dev/null +++ b/app/[workspace]/project/[projectId]/assist/page.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { Suspense } from "react"; +import { useParams, useSearchParams, useRouter } from "next/navigation"; + +const SECTIONS = [ + { + id: "emails", + label: "Emails", + icon: "◈", + title: "Email", + desc: "Transactional and support emails — onboarding sequences, password resets, billing receipts, and support replies — all in one place.", + items: ["Onboarding Sequence", "Transactional Emails", "Support Replies", "Billing Notices", "Digests & Summaries"], + }, + { + id: "chat", + label: "Chat Support", + icon: "◎", + title: "Chat Support", + desc: "Live chat and AI-powered support widget embedded in your product. Routes to human agents when needed, logs every conversation.", + items: ["Live Chat Widget", "AI First Response", "Agent Handoff", "Conversation History", "Canned Responses"], + }, + { + id: "support-site", + label: "Support Site", + icon: "▭", + title: "Support Site", + desc: "Your public help centre — searchable docs, FAQs, guides, and tutorials. Deflects support tickets before they're created.", + items: ["Help Articles", "FAQs", "Video Guides", "Release Notes", "Status Page"], + }, + { + id: "communications", + label: "Communications", + icon: "↗", + title: "In-App Communications", + desc: "Announcements, tooltips, banners, and nudges shown directly inside your product to guide and inform users.", + items: ["In-App Banners", "Tooltips & Tours", "Feature Announcements", "NPS Surveys", "Feedback Prompts"], + }, +] as const; + +type SectionId = typeof SECTIONS[number]["id"]; + +const NAV_GROUP: React.CSSProperties = { + fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6", + letterSpacing: "0.09em", textTransform: "uppercase", + padding: "14px 12px 6px", fontFamily: "Outfit, sans-serif", +}; + +function AssistInner() { + const params = useParams(); + const searchParams = useSearchParams(); + const router = useRouter(); + const workspace = params.workspace as string; + const projectId = params.projectId as string; + + const activeId = (searchParams.get("section") ?? "emails") as SectionId; + const active = SECTIONS.find(s => s.id === activeId) ?? SECTIONS[0]; + + const setSection = (id: string) => + router.push(`/${workspace}/project/${projectId}/assist?section=${id}`, { scroll: false }); + + return ( +
+ + {/* Left nav */} +
+
Assist
+ {SECTIONS.map(s => { + const isActive = activeId === s.id; + return ( + + ); + })} +
+ + {/* Content */} +
+
+
+
{active.title}
+
{active.desc}
+
+ +
+ {active.items.map(item => ( +
+ {item} + Soon +
+ ))} +
+ +
+
+
{active.title} is coming to VIBN
+
We're building this section next. Shape it by telling us what you need.
+
+ +
+
+
+
+ ); +} + +export default function AssistPage() { + return ( + Loading…}> + + + ); +} diff --git a/app/[workspace]/project/[projectId]/build/page.tsx b/app/[workspace]/project/[projectId]/build/page.tsx index 3e05f7d..9b672ca 100644 --- a/app/[workspace]/project/[projectId]/build/page.tsx +++ b/app/[workspace]/project/[projectId]/build/page.tsx @@ -1,175 +1,220 @@ "use client"; -import { useEffect, useState, useCallback, Suspense } from "react"; -import { useParams, useSearchParams } from "next/navigation"; +import { Suspense, useState, useEffect, useCallback } from "react"; +import { useParams, useSearchParams, useRouter } from "next/navigation"; import { useSession } from "next-auth/react"; +import Link from "next/link"; // ── Types ───────────────────────────────────────────────────────────────────── -interface FileItem { - name: string; - path: string; - type: "file" | "dir" | "symlink"; - size?: number; -} - +interface AppEntry { name: string; path: string; } +interface SurfaceEntry { id: string; label: string; lockedTheme?: string; } +interface FileItem { name: string; path: string; type: "file" | "dir" | "symlink"; } interface TreeNode { - name: string; - path: string; - type: "file" | "dir"; - children?: TreeNode[]; - expanded?: boolean; - loaded?: boolean; + name: string; path: string; type: "file" | "dir"; + children?: TreeNode[]; expanded?: boolean; loaded?: boolean; } -// ── Language detection ──────────────────────────────────────────────────────── +// ── Constants ───────────────────────────────────────────────────────────────── + +const INFRA_ITEMS = [ + { id: "builds", label: "Builds", icon: "⬡" }, + { id: "databases", label: "Databases", icon: "◫" }, + { id: "services", label: "Services", icon: "◎" }, + { id: "environment", label: "Environment", icon: "≡" }, + { id: "domains", label: "Domains", icon: "◬" }, + { id: "logs", label: "Logs", icon: "≈" }, +]; + +const SURFACE_LABELS: Record = { + webapp: "Web App", marketing: "Marketing Site", admin: "Admin Panel", +}; +const SURFACE_ICONS: Record = { + webapp: "◈", marketing: "◌", admin: "◫", +}; + +// ── Language / syntax helpers ───────────────────────────────────────────────── function langFromName(name: string): string { const ext = name.split(".").pop()?.toLowerCase() ?? ""; const map: Record = { ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript", - json: "json", md: "markdown", mdx: "markdown", - css: "css", scss: "css", html: "html", + json: "json", md: "markdown", css: "css", scss: "css", html: "html", py: "python", sh: "shell", yaml: "yaml", yml: "yaml", toml: "toml", prisma: "prisma", sql: "sql", - env: "dotenv", gitignore: "shell", dockerfile: "dockerfile", }; return map[ext] ?? "text"; } -// ── Simple token highlighter ────────────────────────────────────────────────── - function highlightCode(code: string, lang: string): React.ReactNode[] { return code.split("\n").map((line, i) => { - if (lang === "text" || lang === "dotenv" || lang === "dockerfile") { - return
{line || "\u00a0"}
; - } const commentPrefixes = ["//", "#", "--"]; if (commentPrefixes.some(p => line.trimStart().startsWith(p))) { return
{line}
; } - const kwRe = /\b(import|export|from|const|let|var|function|return|if|else|async|await|type|interface|class|extends|implements|new|default|null|undefined|true|false|void|string|number|boolean|object|Promise|React)\b/g; + const kwRe = /\b(import|export|from|const|let|var|function|return|if|else|async|await|type|interface|class|extends|new|default|null|undefined|true|false|void|string|number|boolean|React)\b/g; const parts = line.split(kwRe); - const tokens = parts.map((part, j) => { - if (!part) return null; - if (/^(import|export|from|const|let|var|function|return|if|else|async|await|type|interface|class|extends|implements|new|default|null|undefined|true|false|void|string|number|boolean|object|Promise|React)$/.test(part)) { - return {part}; - } - if (/^(['"`]).*\1$/.test(part.trim())) { - return {part}; - } - return {part}; + const tokens = parts.map((p, j) => { + if (!p) return null; + if (/^(import|export|from|const|let|var|function|return|if|else|async|await|type|interface|class|extends|new|default|null|undefined|true|false|void|string|number|boolean|React)$/.test(p)) + return {p}; + return {p}; }); return
{tokens.length ? tokens : "\u00a0"}
; }); } -// ── Tree row ────────────────────────────────────────────────────────────────── +// ── File tree row ───────────────────────────────────────────────────────────── -function TreeRow({ - node, depth, selectedPath, onSelect, onToggle, -}: { - node: TreeNode; - depth: number; - selectedPath: string | null; - onSelect: (path: string) => void; - onToggle: (path: string) => void; +function TreeRow({ node, depth, selectedPath, onSelect, onToggle }: { + node: TreeNode; depth: number; selectedPath: string | null; + onSelect: (p: string) => void; onToggle: (p: string) => void; }) { - const isSelected = selectedPath === node.path; + const active = selectedPath === node.path; const isDir = node.type === "dir"; const ext = node.name.split(".").pop()?.toLowerCase() ?? ""; - const fileColor = - ext === "tsx" || ext === "ts" ? "#3178c6" - : ext === "jsx" || ext === "js" ? "#f0db4f" - : ext === "css" || ext === "scss" ? "#e879f9" - : ext === "json" ? "#a09a90" - : ext === "md" || ext === "mdx" ? "#6b6560" - : "#b5b0a6"; + const fileColor = ext === "tsx" || ext === "ts" ? "#3178c6" : ext === "jsx" || ext === "js" ? "#f0db4f" + : ext === "css" || ext === "scss" ? "#e879f9" : "#b5b0a6"; return ( <> - - {isDir && node.expanded && node.children?.map(child => ( - - ))} + {isDir && node.expanded && node.children?.map(c => + + )} ); } -// ── Empty state ─────────────────────────────────────────────────────────────── +// ── Left nav shared styles ──────────────────────────────────────────────────── -function EmptyState() { +const NAV_GROUP_LABEL: React.CSSProperties = { + fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6", + letterSpacing: "0.09em", textTransform: "uppercase", + padding: "12px 12px 5px", fontFamily: "Outfit, sans-serif", +}; + +function NavItem({ label, active, onClick, indent = false }: { label: string; active: boolean; onClick: () => void; indent?: boolean }) { return ( -
-
-
-
- Select an app to browse -
-
- Choose one of your apps from the Build section in the left sidebar to explore its files. -
+ + ); +} + +// ── Placeholder panel ───────────────────────────────────────────────────────── + +function Placeholder({ icon, title, desc }: { icon: string; title: string; desc: string }) { + return ( +
+
{icon}
+
+
{title}
+
{desc}
+
+
Coming soon
+
+ ); +} + +// ── Infra content ───────────────────────────────────────────────────────────── + +function InfraContent({ tab, projectId, workspace }: { tab: string; projectId: string; workspace: string }) { + const base = `/${workspace}/project/${projectId}/infrastructure`; + const descriptions: Record = { + databases: { icon: "◫", title: "Databases", desc: "PostgreSQL, Redis, and other databases — provisioned and managed with connection strings auto-injected." }, + services: { icon: "◎", title: "Services", desc: "Background workers, queues, email delivery, file storage, and third-party integrations." }, + environment: { icon: "≡", title: "Environment", desc: "Environment variables and secrets, encrypted at rest and auto-injected into your containers." }, + domains: { icon: "◬", title: "Domains", desc: "Custom domains and SSL certificates for all your deployed services." }, + logs: { icon: "≈", title: "Logs", desc: "Runtime logs, request traces, and error reports streaming from deployed services." }, + builds: { icon: "⬡", title: "Builds", desc: "Deployment history, build logs, and rollback controls for all your apps." }, + }; + const d = descriptions[tab]; + return ( +
+
+
{tab}
+ Open full view → +
+ {d && } +
+ ); +} + +// ── Layouts content ─────────────────────────────────────────────────────────── + +function LayoutsContent({ surfaces, projectId, workspace, activeSurfaceId, onSelectSurface }: { + surfaces: SurfaceEntry[]; projectId: string; workspace: string; + activeSurfaceId: string | null; onSelectSurface: (id: string) => void; +}) { + if (surfaces.length === 0) { + return ( + + ); + } + const active = surfaces.find(s => s.id === activeSurfaceId) ?? surfaces[0]; + return ( +
+
+
Layouts
+ Edit in Design → +
+
+ {surfaces.map(s => ( +
onSelectSurface(s.id)} style={{ + background: active?.id === s.id ? "#fff" : "#faf8f5", + border: `1px solid ${active?.id === s.id ? "#1a1a1a" : "#e8e4dc"}`, + borderRadius: 10, padding: "16px 20px", cursor: "pointer", + minWidth: 180, flex: "1 1 180px", maxWidth: 240, + transition: "border-color 0.1s", + }}> +
+ {SURFACE_LABELS[s.id] ?? s.id} +
+ {s.lockedTheme ? ( +
Theme: {s.lockedTheme}
+ ) : ( +
Not configured
+ )} +
+ ))} +
+
+ Click a surface to select it, then open the Design editor to configure themes, fonts, and components.
); } -// ── Inner page (needs useSearchParams) ─────────────────────────────────────── - -function BuildPageInner() { - const params = useParams(); - const searchParams = useSearchParams(); - const projectId = params.projectId as string; - const { status: authStatus } = useSession(); - - // Which app the user clicked (from sidebar link) - const appName = searchParams.get("app") ?? ""; - const rootPath = searchParams.get("root") ?? ""; +// ── Code content (file browser) ─────────────────────────────────────────────── +function CodeContent({ projectId, appName, rootPath }: { projectId: string; appName: string; rootPath: string }) { + const { status } = useSession(); const [tree, setTree] = useState([]); const [treeLoading, setTreeLoading] = useState(false); - const [treeError, setTreeError] = useState(null); const [selectedPath, setSelectedPath] = useState(null); const [fileContent, setFileContent] = useState(null); const [fileLoading, setFileLoading] = useState(false); @@ -178,219 +223,93 @@ function BuildPageInner() { const fetchDir = useCallback(async (path: string): Promise => { const res = await fetch(`/api/projects/${projectId}/file?path=${encodeURIComponent(path)}`); const data = await res.json(); - if (!res.ok) throw new Error(data.error ?? "Failed to load"); + if (!res.ok) throw new Error(data.error); const items: FileItem[] = data.items ?? []; - return items - .filter(item => item.type !== "symlink") - .sort((a, b) => { - if (a.type === "dir" && b.type !== "dir") return -1; - if (a.type !== "dir" && b.type === "dir") return 1; - return a.name.localeCompare(b.name); - }) - .map(item => ({ - name: item.name, - path: item.path, - type: item.type === "dir" ? "dir" : "file", - expanded: false, - loaded: item.type !== "dir", - children: item.type === "dir" ? [] : undefined, - })); + return items.filter(i => i.type !== "symlink") + .sort((a, b) => a.type === "dir" && b.type !== "dir" ? -1 : a.type !== "dir" && b.type === "dir" ? 1 : a.name.localeCompare(b.name)) + .map(i => ({ name: i.name, path: i.path, type: i.type === "dir" ? "dir" : "file", expanded: false, loaded: i.type !== "dir", children: i.type === "dir" ? [] : undefined })); }, [projectId]); - // Load the app's root dir whenever app changes useEffect(() => { - if (!rootPath || authStatus !== "authenticated") return; - setTree([]); - setSelectedPath(null); - setFileContent(null); - setTreeError(null); - setTreeLoading(true); - fetchDir(rootPath) - .then(nodes => { setTree(nodes); setTreeLoading(false); }) - .catch(e => { setTreeError(e.message); setTreeLoading(false); }); - }, [rootPath, authStatus, fetchDir]); + if (!rootPath || status !== "authenticated") return; + setTree([]); setSelectedPath(null); setFileContent(null); setTreeLoading(true); + fetchDir(rootPath).then(nodes => { setTree(nodes); setTreeLoading(false); }).catch(() => setTreeLoading(false)); + }, [rootPath, status, fetchDir]); - // Toggle dir expand/collapse with lazy-load const handleToggle = useCallback(async (path: string) => { setTree(prev => { - const toggle = (nodes: TreeNode[]): TreeNode[] => - nodes.map(n => { - if (n.path === path) return { ...n, expanded: !n.expanded }; - if (n.children) return { ...n, children: toggle(n.children) }; - return n; - }); + const toggle = (nodes: TreeNode[]): TreeNode[] => nodes.map(n => n.path === path ? { ...n, expanded: !n.expanded } : n.children ? { ...n, children: toggle(n.children) } : n); return toggle(prev); }); - - const findNode = (nodes: TreeNode[], p: string): TreeNode | null => { - for (const n of nodes) { - if (n.path === p) return n; - if (n.children) { const f = findNode(n.children, p); if (f) return f; } - } - return null; - }; - + const findNode = (nodes: TreeNode[], p: string): TreeNode | null => { for (const n of nodes) { if (n.path === p) return n; if (n.children) { const f = findNode(n.children, p); if (f) return f; } } return null; }; const node = findNode(tree, path); if (node && !node.loaded) { - try { - const children = await fetchDir(path); - setTree(prev => { - const update = (nodes: TreeNode[]): TreeNode[] => - nodes.map(n => { - if (n.path === path) return { ...n, children, loaded: true }; - if (n.children) return { ...n, children: update(n.children) }; - return n; - }); - return update(prev); - }); - } catch { /* silently fail */ } + const children = await fetchDir(path).catch(() => []); + setTree(prev => { + const update = (nodes: TreeNode[]): TreeNode[] => nodes.map(n => n.path === path ? { ...n, children, loaded: true } : n.children ? { ...n, children: update(n.children) } : n); + return update(prev); + }); } }, [tree, fetchDir]); - // Select a file and load its content const handleSelectFile = useCallback(async (path: string) => { - setSelectedPath(path); - setFileContent(null); - setFileName(path.split("/").pop() ?? null); - setFileLoading(true); + setSelectedPath(path); setFileContent(null); setFileName(path.split("/").pop() ?? null); setFileLoading(true); try { const res = await fetch(`/api/projects/${projectId}/file?path=${encodeURIComponent(path)}`); const data = await res.json(); setFileContent(data.content ?? ""); - } catch { - setFileContent("// Failed to load file content"); - } finally { - setFileLoading(false); - } + } catch { setFileContent("// Failed to load"); } + finally { setFileLoading(false); } }, [projectId]); const lang = fileName ? langFromName(fileName) : "text"; const lines = (fileContent ?? "").split("\n"); - if (!appName || !rootPath) { - return ; + if (!appName) { + return ( +
+
+
+
Select an app
+
Choose an app from the left to browse its source files.
+
+
+ ); } return ( -
- - {/* ── File tree ── */} -
- {/* App name header */} -
- - - {appName} - +
+ {/* File tree */} +
+
+ + {appName}
- - {/* Tree */} -
- {treeLoading && ( -
Loading…
- )} - {treeError && ( -
{treeError}
- )} - {!treeLoading && !treeError && tree.length === 0 && ( -
Empty folder.
- )} - {tree.map(node => ( - - ))} +
+ {treeLoading &&
Loading…
} + {!treeLoading && tree.length === 0 &&
Empty.
} + {tree.map(n => )}
- - {/* ── Code preview ── */} -
- - {/* Breadcrumb bar */} -
+ {/* Code viewer */} +
+
{selectedPath ? ( - - {/* Show path relative to rootPath */} - {(() => { - const rel = selectedPath.startsWith(rootPath + "/") - ? selectedPath.slice(rootPath.length + 1) - : selectedPath; - return rel.split("/").map((seg, i, arr) => ( - - {i > 0 && /} - {seg} - - )); - })()} + + {(() => { const rel = selectedPath.startsWith(rootPath + "/") ? selectedPath.slice(rootPath.length + 1) : selectedPath; return rel.split("/").map((s, i, a) => {i > 0 && /}{s}); })()} - ) : ( - - Select a file to view - - )} - {fileName && ( - - {lang} - - )} + ) : Select a file} + {fileName && {lang}}
- - {/* Code area */}
- {!selectedPath && !fileLoading && ( -
- Select a file from the tree -
- )} - {fileLoading && ( -
- Loading… -
- )} + {!selectedPath && !fileLoading &&
Select a file to view
} + {fileLoading &&
Loading…
} {!fileLoading && fileContent !== null && ( -
- {/* Line numbers */} -
- {lines.map((_, i) => ( -
- {i + 1} -
- ))} +
+
+ {lines.map((_, i) =>
{i + 1}
)}
- {/* Code */} -
+
{highlightCode(fileContent, lang)}
@@ -401,12 +320,100 @@ function BuildPageInner() { ); } -// ── Page export (Suspense wraps useSearchParams) ────────────────────────────── +// ── Main Build hub ──────────────────────────────────────────────────────────── + +function BuildHubInner() { + const params = useParams(); + const searchParams = useSearchParams(); + const router = useRouter(); + const projectId = params.projectId as string; + const workspace = params.workspace as string; + + const section = searchParams.get("section") ?? "code"; + const activeApp = searchParams.get("app") ?? ""; + const activeRoot = searchParams.get("root") ?? ""; + const activeInfra = searchParams.get("tab") ?? "builds"; + const activeSurfaceParam = searchParams.get("surface") ?? ""; + + const [apps, setApps] = useState([]); + const [surfaces, setSurfaces] = useState([]); + const [activeSurfaceId, setActiveSurfaceId] = useState(activeSurfaceParam); + + useEffect(() => { + fetch(`/api/projects/${projectId}/apps`).then(r => r.json()).then(d => setApps(d.apps ?? [])).catch(() => {}); + fetch(`/api/projects/${projectId}/design-surfaces`).then(r => r.json()).then(d => { + const ids: string[] = d.surfaces ?? []; + const themes: Record = d.surfaceThemes ?? {}; + setSurfaces(ids.map(id => ({ id, label: SURFACE_LABELS[id] ?? id, lockedTheme: themes[id] }))); + if (!activeSurfaceId && ids.length > 0) setActiveSurfaceId(ids[0]); + }).catch(() => {}); + }, [projectId]); + + const navigate = (params: Record) => { + const sp = new URLSearchParams({ section, ...params }); + router.push(`/${workspace}/project/${projectId}/build?${sp.toString()}`, { scroll: false }); + }; + + const setSection = (s: string) => router.push(`/${workspace}/project/${projectId}/build?section=${s}`, { scroll: false }); + + return ( +
+ + {/* ── Left nav ── */} +
+ + {/* Code group */} +
Code
+ {apps.length > 0 ? apps.map(app => ( + navigate({ section: "code", app: app.name, root: app.path })} + /> + )) : ( + setSection("code")} /> + )} + + {/* Layouts group */} +
Layouts
+ {surfaces.length > 0 ? surfaces.map(s => ( + { setActiveSurfaceId(s.id); navigate({ section: "layouts", surface: s.id }); }} + /> + )) : ( + setSection("layouts")} /> + )} + + {/* Infrastructure group */} +
Infrastructure
+ {INFRA_ITEMS.map(item => ( + navigate({ section: "infrastructure", tab: item.id })} + /> + ))} +
+ + {/* ── Content ── */} +
+ {section === "code" && ( + + )} + {section === "layouts" && ( + { setActiveSurfaceId(id); navigate({ section: "layouts", surface: id }); }} /> + )} + {section === "infrastructure" && ( + + )} +
+
+ ); +} export default function BuildPage() { return ( Loading…
}> - + ); } diff --git a/app/[workspace]/project/[projectId]/growth/page.tsx b/app/[workspace]/project/[projectId]/growth/page.tsx new file mode 100644 index 0000000..6f80c74 --- /dev/null +++ b/app/[workspace]/project/[projectId]/growth/page.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { Suspense } from "react"; +import { useParams, useSearchParams, useRouter } from "next/navigation"; + +const SECTIONS = [ + { + id: "marketing-site", + label: "Marketing Site", + icon: "◌", + title: "Marketing Site", + desc: "Your public-facing website — hero, features, pricing, blog, and landing pages. Connected to your design surface and deployed via your infrastructure.", + items: ["Hero & Landing", "Features", "Pricing Page", "Blog", "Case Studies", "About"], + }, + { + id: "communications", + label: "Communications", + icon: "◈", + title: "Communications", + desc: "Outbound messaging — product announcements, newsletters, launch emails, and drip campaigns sent to your audience.", + items: ["Announcements", "Newsletter", "Launch Sequence", "Drip Campaigns"], + }, + { + id: "channels", + label: "Channels", + icon: "↗", + title: "Distribution Channels", + desc: "Where your product gets discovered — SEO, social, Product Hunt, app stores, partnerships, and paid acquisition.", + items: ["SEO & Search", "Social Media", "Product Hunt", "App Stores", "Partnerships", "Paid Ads"], + }, + { + id: "pages", + label: "Pages", + icon: "▭", + title: "Pages", + desc: "Individual landing pages for campaigns, experiments, and specific audience segments. Build, publish, and A/B test.", + items: ["Campaign Pages", "A/B Tests", "Event Pages", "Partner Pages", "Waitlist"], + }, +] as const; + +type SectionId = typeof SECTIONS[number]["id"]; + +const NAV_GROUP: React.CSSProperties = { + fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6", + letterSpacing: "0.09em", textTransform: "uppercase", + padding: "14px 12px 6px", fontFamily: "Outfit, sans-serif", +}; + +function GrowthInner() { + const params = useParams(); + const searchParams = useSearchParams(); + const router = useRouter(); + const workspace = params.workspace as string; + const projectId = params.projectId as string; + + const activeId = (searchParams.get("section") ?? "marketing-site") as SectionId; + const active = SECTIONS.find(s => s.id === activeId) ?? SECTIONS[0]; + + const setSection = (id: string) => + router.push(`/${workspace}/project/${projectId}/growth?section=${id}`, { scroll: false }); + + return ( +
+ + {/* Left nav */} +
+
Growth
+ {SECTIONS.map(s => { + const isActive = activeId === s.id; + return ( + + ); + })} +
+ + {/* Content */} +
+
+
+
{active.title}
+
{active.desc}
+
+ + {/* Feature items */} +
+ {active.items.map(item => ( +
+ {item} + Soon +
+ ))} +
+ + {/* CTA */} +
+
+
+ {active.title} is coming to VIBN +
+
+ We're building this section next. Shape it by telling us what you need. +
+
+ +
+
+
+
+ ); +} + +export default function GrowthPage() { + return ( + Loading…
}> + + + ); +} diff --git a/components/layout/project-shell.tsx b/components/layout/project-shell.tsx index 8aa2a88..272617f 100644 --- a/components/layout/project-shell.tsx +++ b/components/layout/project-shell.tsx @@ -23,14 +23,12 @@ interface ProjectShellProps { } const ALL_TABS = [ - { id: "overview", label: "Atlas", path: "overview" }, - { id: "prd", label: "PRD", path: "prd" }, - { id: "design", label: "Design", path: "design" }, - { id: "build", label: "Build", path: "build" }, - { id: "deployment", label: "Launch", path: "deployment" }, - { id: "grow", label: "Grow", path: "grow" }, - { id: "insights", label: "Insights", path: "insights" }, - { id: "settings", label: "Settings", path: "settings" }, + { id: "overview", label: "Atlas", path: "overview" }, + { id: "prd", label: "PRD", path: "prd" }, + { id: "build", label: "Build", path: "build" }, + { id: "growth", label: "Growth", path: "growth" }, + { id: "assist", label: "Assist", path: "assist" }, + { id: "analytics", label: "Analytics", path: "analytics" }, ]; function getTabsForMode( @@ -41,7 +39,7 @@ function getTabsForMode( return ALL_TABS.filter(t => t.id !== "prd"); case "migration": return ALL_TABS - .filter(t => !["prd", "grow", "insights"].includes(t.id)) + .filter(t => t.id !== "prd") .map(t => t.id === "overview" ? { ...t, label: "Migration Plan" } : t); default: return ALL_TABS;