feat(ai-access): per-workspace Gitea bot + tenant-safe Coolify proxy + MCP
Ship Phases 1–3 of the multi-tenant AI access plan so an AI agent can
act on a Vibn workspace with one bearer token and zero admin reach.
Phase 1 — Gitea bot per workspace
- Add gitea_bot_username / gitea_bot_user_id / gitea_bot_token_encrypted
columns to vibn_workspaces (migrate route).
- New lib/auth/secret-box.ts (AES-256-GCM, VIBN_SECRETS_KEY) for PAT at rest.
- Extend lib/gitea.ts with createUser, createAccessTokenFor (Sudo PAT),
createOrgTeam, addOrgTeamMember, ensureOrgTeamMembership.
- ensureWorkspaceProvisioned now mints a vibn-bot-<slug> user, adds it to
a Writers team (write perms only) on the workspace's org, and stores
its PAT encrypted.
- GET /api/workspaces/[slug]/gitea-credentials returns a workspace-scoped
bot PAT + clone URL template; session or vibn_sk_ bearer auth.
Phase 2 — Tenant-safe Coolify proxy + real MCP
- lib/coolify.ts: projectUuidOf, listApplicationsInProject,
getApplicationInProject, TenantError, env CRUD, deployments list.
- Workspace-scoped REST endpoints (all filtered by coolify_project_uuid):
GET/POST /api/workspaces/[slug]/apps/[uuid](/deploy|/envs|/deployments),
GET /api/workspaces/[slug]/deployments/[deploymentUuid]/logs.
- Full rewrite of /api/mcp off legacy Firebase onto Postgres vibn_sk_
keys, exposing workspace.describe, gitea.credentials, projects.*,
apps.* (list/get/deploy/deployments, envs.list/upsert/delete).
Phase 3 — Settings UI AI bundle
- GET /api/workspaces/[slug]/bootstrap.sh: curl|sh installer that writes
.cursor/rules, .cursor/mcp.json and appends VIBN_* to .env.local.
Embeds the caller's vibn_sk_ token when invoked with bearer auth.
- WorkspaceKeysPanel: single AiAccessBundleCard with system-prompt block,
one-line bootstrap, Reveal-bot-PAT button, collapsible manual-setup
fallback. Minted-key modal also shows the bootstrap one-liner.
Ops prerequisites:
- Set VIBN_SECRETS_KEY (>=16 chars) on the frontend.
- Run /api/admin/migrate to add the three bot columns.
- GITEA_API_TOKEN must be a site-admin token (needed for admin/users
+ Sudo PAT mint); otherwise provision_status lands on 'partial'.
Made-with: Cursor
This commit is contained in:
@@ -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: "var(--font-inter), ui-sans-serif, 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 (
|
||||
<div style={{ display: "flex", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", overflow: "hidden" }}>
|
||||
|
||||
{/* Left nav */}
|
||||
<div style={{ width: 200, flexShrink: 0, borderRight: "1px solid #e8e4dc", background: "#faf8f5", display: "flex", flexDirection: "column", overflow: "auto" }}>
|
||||
<div style={NAV_GROUP}>Analytics</div>
|
||||
{SECTIONS.map(s => {
|
||||
const isActive = activeId === s.id;
|
||||
return (
|
||||
<button key={s.id} onClick={() => setSection(s.id)} style={{
|
||||
display: "flex", alignItems: "center", gap: 8, width: "100%", textAlign: "left",
|
||||
background: isActive ? "#f0ece4" : "transparent", border: "none", cursor: "pointer",
|
||||
padding: "6px 12px", borderRadius: 5,
|
||||
fontSize: "0.78rem", fontWeight: isActive ? 600 : 440,
|
||||
color: isActive ? "#1a1a1a" : "#5a5550",
|
||||
}}
|
||||
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
|
||||
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||
>
|
||||
<span style={{ fontSize: "0.65rem", opacity: 0.55, width: 14, textAlign: "center" }}>{s.icon}</span>
|
||||
{s.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, overflow: "auto", display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ padding: "28px 32px", maxWidth: 800 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ fontSize: "1.1rem", fontWeight: 700, color: "#1a1a1a", marginBottom: 6 }}>{active.title}</div>
|
||||
<div style={{ fontSize: "0.82rem", color: "#6b6560", lineHeight: 1.65, maxWidth: 520 }}>{active.desc}</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", gap: 12, marginBottom: 32 }}>
|
||||
{active.items.map(item => (
|
||||
<div key={item} style={{
|
||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 9,
|
||||
padding: "14px 16px", display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
}}>
|
||||
<span style={{ fontSize: "0.8rem", fontWeight: 500, color: "#1a1a1a" }}>{item}</span>
|
||||
<span style={{ fontSize: "0.65rem", color: "#c5c0b8", background: "#f6f4f0", padding: "2px 7px", borderRadius: 4 }}>Soon</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: "linear-gradient(135deg, #1a1a1a 0%, #2d2820 100%)",
|
||||
borderRadius: 12, padding: "24px 28px",
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between", gap: 20,
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: "0.85rem", fontWeight: 600, color: "#fff", marginBottom: 4 }}>{active.title} is coming to VIBN</div>
|
||||
<div style={{ fontSize: "0.75rem", color: "#8a8478", lineHeight: 1.5 }}>We're building this section next. Shape it by telling us what you need.</div>
|
||||
</div>
|
||||
<button style={{ background: "#d4a04a", color: "#fff", border: "none", borderRadius: 8, padding: "9px 20px", fontSize: "0.78rem", fontWeight: 600, cursor: "pointer", whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||
Give feedback
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
return (
|
||||
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", fontSize: "0.85rem" }}>Loading…</div>}>
|
||||
<AnalyticsInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
133
app/[workspace]/project/[projectId]/(workspace)/assist/page.tsx
Normal file
133
app/[workspace]/project/[projectId]/(workspace)/assist/page.tsx
Normal file
@@ -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: "var(--font-inter), ui-sans-serif, 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 (
|
||||
<div style={{ display: "flex", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", overflow: "hidden" }}>
|
||||
|
||||
{/* Left nav */}
|
||||
<div style={{ width: 200, flexShrink: 0, borderRight: "1px solid #e8e4dc", background: "#faf8f5", display: "flex", flexDirection: "column", overflow: "auto" }}>
|
||||
<div style={NAV_GROUP}>Assist</div>
|
||||
{SECTIONS.map(s => {
|
||||
const isActive = activeId === s.id;
|
||||
return (
|
||||
<button key={s.id} onClick={() => setSection(s.id)} style={{
|
||||
display: "flex", alignItems: "center", gap: 8, width: "100%", textAlign: "left",
|
||||
background: isActive ? "#f0ece4" : "transparent", border: "none", cursor: "pointer",
|
||||
padding: "6px 12px", borderRadius: 5,
|
||||
fontSize: "0.78rem", fontWeight: isActive ? 600 : 440,
|
||||
color: isActive ? "#1a1a1a" : "#5a5550",
|
||||
}}
|
||||
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
|
||||
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||
>
|
||||
<span style={{ fontSize: "0.65rem", opacity: 0.55, width: 14, textAlign: "center" }}>{s.icon}</span>
|
||||
{s.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, overflow: "auto", display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ padding: "28px 32px", maxWidth: 800 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ fontSize: "1.1rem", fontWeight: 700, color: "#1a1a1a", marginBottom: 6 }}>{active.title}</div>
|
||||
<div style={{ fontSize: "0.82rem", color: "#6b6560", lineHeight: 1.65, maxWidth: 520 }}>{active.desc}</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", gap: 12, marginBottom: 32 }}>
|
||||
{active.items.map(item => (
|
||||
<div key={item} style={{
|
||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 9,
|
||||
padding: "14px 16px", display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
}}>
|
||||
<span style={{ fontSize: "0.8rem", fontWeight: 500, color: "#1a1a1a" }}>{item}</span>
|
||||
<span style={{ fontSize: "0.65rem", color: "#c5c0b8", background: "#f6f4f0", padding: "2px 7px", borderRadius: 4 }}>Soon</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: "linear-gradient(135deg, #1a1a1a 0%, #2d2820 100%)",
|
||||
borderRadius: 12, padding: "24px 28px",
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between", gap: 20,
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: "0.85rem", fontWeight: 600, color: "#fff", marginBottom: 4 }}>{active.title} is coming to VIBN</div>
|
||||
<div style={{ fontSize: "0.75rem", color: "#8a8478", lineHeight: 1.5 }}>We're building this section next. Shape it by telling us what you need.</div>
|
||||
</div>
|
||||
<button style={{ background: "#d4a04a", color: "#fff", border: "none", borderRadius: 8, padding: "9px 20px", fontSize: "0.78rem", fontWeight: 600, cursor: "pointer", whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||
Give feedback
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AssistPage() {
|
||||
return (
|
||||
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", fontSize: "0.85rem" }}>Loading…</div>}>
|
||||
<AssistInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
1499
app/[workspace]/project/[projectId]/(workspace)/build/page.tsx
Normal file
1499
app/[workspace]/project/[projectId]/(workspace)/build/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
productName: string;
|
||||
status?: string;
|
||||
giteaRepoUrl?: string;
|
||||
giteaRepo?: string;
|
||||
theiaWorkspaceUrl?: string;
|
||||
coolifyDeployUrl?: string;
|
||||
customDomain?: string;
|
||||
prd?: string;
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{
|
||||
fontSize: "0.6rem", fontWeight: 600, color: "#a09a90",
|
||||
letterSpacing: "0.1em", textTransform: "uppercase", marginBottom: 12,
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoCard({ children, style = {} }: { children: React.ReactNode; style?: React.CSSProperties }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
|
||||
boxShadow: "0 1px 2px #1a1a1a05", marginBottom: 12, ...style,
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DeploymentPage() {
|
||||
const params = useParams();
|
||||
const projectId = params.projectId as string;
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [customDomainInput, setCustomDomainInput] = useState("");
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/projects/${projectId}`)
|
||||
.then((r) => r.json())
|
||||
.then((d) => setProject(d.project))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [projectId]);
|
||||
|
||||
const handleConnectDomain = async () => {
|
||||
if (!customDomainInput.trim()) return;
|
||||
setConnecting(true);
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
toast.info("Domain connection coming soon — we'll hook this to Coolify.");
|
||||
setConnecting(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#a09a90" }}>
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasDeploy = Boolean(project?.coolifyDeployUrl || project?.theiaWorkspaceUrl);
|
||||
const hasRepo = Boolean(project?.giteaRepoUrl);
|
||||
const hasPRD = Boolean(project?.prd);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="vibn-enter"
|
||||
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
|
||||
>
|
||||
<div style={{ maxWidth: 560 }}>
|
||||
<h3 style={{
|
||||
fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem",
|
||||
fontWeight: 400, color: "#1a1a1a", marginBottom: 4,
|
||||
}}>
|
||||
Deployment
|
||||
</h3>
|
||||
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 24 }}>
|
||||
Links, environments, and hosting for {project?.productName ?? "this project"}
|
||||
</p>
|
||||
|
||||
{/* Project URLs */}
|
||||
<InfoCard style={{ padding: "22px" }}>
|
||||
<SectionLabel>Project URLs</SectionLabel>
|
||||
{hasDeploy ? (
|
||||
<>
|
||||
{project?.coolifyDeployUrl && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 0", borderBottom: "1px solid #f0ece4" }}>
|
||||
<div style={{ width: 28, height: 28, borderRadius: 6, background: "#f6f4f0", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.7rem", color: "#8a8478" }}>▲</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: "0.68rem", color: "#b5b0a6", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.04em" }}>Staging</div>
|
||||
<div style={{ fontSize: "0.84rem", fontFamily: "IBM Plex Mono, monospace", color: "#3d5afe", fontWeight: 500 }}>{project.coolifyDeployUrl}</div>
|
||||
</div>
|
||||
<a href={project.coolifyDeployUrl} target="_blank" rel="noopener noreferrer"
|
||||
style={{ padding: "5px 12px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.7rem", fontWeight: 600, textDecoration: "none", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
Open ↗
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{project?.customDomain && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 0", borderBottom: "1px solid #f0ece4" }}>
|
||||
<div style={{ width: 28, height: 28, borderRadius: 6, background: "#2e7d3210", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.7rem", color: "#2e7d32" }}>●</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: "0.68rem", color: "#b5b0a6", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.04em" }}>Production</div>
|
||||
<div style={{ fontSize: "0.84rem", fontFamily: "IBM Plex Mono, monospace", color: "#2e7d32", fontWeight: 500 }}>{project.customDomain}</div>
|
||||
</div>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", padding: "3px 9px", borderRadius: 4, fontSize: "0.68rem", fontWeight: 600, color: "#2e7d32", background: "#2e7d3210" }}>SSL Active</span>
|
||||
<a href={`https://${project.customDomain}`} target="_blank" rel="noopener noreferrer"
|
||||
style={{ padding: "5px 12px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.7rem", fontWeight: 600, textDecoration: "none", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
Open ↗
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{project?.giteaRepoUrl && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 0" }}>
|
||||
<div style={{ width: 28, height: 28, borderRadius: 6, background: "#f6f4f0", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.7rem", color: "#8a8478" }}>⚙</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: "0.68rem", color: "#b5b0a6", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.04em" }}>Build repo</div>
|
||||
<div style={{ fontSize: "0.84rem", fontFamily: "IBM Plex Mono, monospace", color: "#6b6560", fontWeight: 500 }}>{project.giteaRepo}</div>
|
||||
</div>
|
||||
<a href={project.giteaRepoUrl} target="_blank" rel="noopener noreferrer"
|
||||
style={{ padding: "5px 12px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.7rem", fontWeight: 600, textDecoration: "none", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
View ↗
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div style={{ padding: "18px 0", textAlign: "center" }}>
|
||||
<p style={{ fontSize: "0.82rem", color: "#a09a90", marginBottom: 12 }}>
|
||||
{!hasPRD
|
||||
? "Complete your PRD with Vibn first, then build and deploy."
|
||||
: !hasRepo
|
||||
? "No repository yet — the Architect agent will scaffold one from your PRD."
|
||||
: "No deployment yet — kick off a build to get a live URL."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</InfoCard>
|
||||
|
||||
{/* Custom domain */}
|
||||
{hasDeploy && !project?.customDomain && (
|
||||
<InfoCard style={{ padding: "22px" }}>
|
||||
<SectionLabel>Custom Domain</SectionLabel>
|
||||
<p style={{ fontSize: "0.82rem", color: "#6b6560", lineHeight: 1.6, marginBottom: 14 }}>
|
||||
Point your own domain to this project. SSL certificates are handled automatically.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<input
|
||||
placeholder="app.yourdomain.com"
|
||||
value={customDomainInput}
|
||||
onChange={(e) => setCustomDomainInput(e.target.value)}
|
||||
style={{ flex: 1, padding: "9px 13px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.84rem", fontFamily: "IBM Plex Mono, monospace", color: "#1a1a1a" }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleConnectDomain}
|
||||
disabled={connecting}
|
||||
style={{ padding: "9px 18px", borderRadius: 7, border: "none", background: "#1a1a1a", color: "#fff", fontSize: "0.78rem", fontWeight: 600, cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", opacity: connecting ? 0.6 : 1 }}
|
||||
>
|
||||
{connecting ? "Connecting…" : "Connect"}
|
||||
</button>
|
||||
</div>
|
||||
</InfoCard>
|
||||
)}
|
||||
|
||||
{/* Environment variables */}
|
||||
<InfoCard style={{ padding: "22px" }}>
|
||||
<SectionLabel>Environment Variables</SectionLabel>
|
||||
{hasDeploy ? (
|
||||
<p style={{ fontSize: "0.82rem", color: "#a09a90", padding: "10px 0" }}>
|
||||
Manage environment variables in Coolify for your deployed services.
|
||||
{project?.coolifyDeployUrl && (
|
||||
<> <a href="http://34.19.250.135:8000" target="_blank" rel="noopener noreferrer" style={{ color: "#3d5afe", textDecoration: "none" }}>Open Coolify ↗</a></>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ fontSize: "0.82rem", color: "#a09a90", padding: "10px 0" }}>Available after first build completes.</p>
|
||||
)}
|
||||
</InfoCard>
|
||||
|
||||
{/* Deploy history */}
|
||||
<InfoCard style={{ padding: "22px" }}>
|
||||
<SectionLabel>Deploy History</SectionLabel>
|
||||
<p style={{ fontSize: "0.82rem", color: "#a09a90", padding: "10px 0" }}>
|
||||
{project?.status === "live"
|
||||
? "Deploy history will appear here."
|
||||
: "No deploys yet."}
|
||||
</p>
|
||||
</InfoCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1154
app/[workspace]/project/[projectId]/(workspace)/design/page.tsx
Normal file
1154
app/[workspace]/project/[projectId]/(workspace)/design/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
export default function GrowPage() {
|
||||
const items = [
|
||||
{ icon: "📣", title: "Marketing copy", desc: "AI-generated landing page, emails, and social posts tailored to your product." },
|
||||
{ icon: "🎯", title: "Launch channels", desc: "Recommended channels based on your target audience and business model." },
|
||||
{ icon: "👥", title: "User acquisition", desc: "Onboarding flows, referral mechanics, and early adopter campaigns." },
|
||||
{ icon: "💬", title: "Community", desc: "Discord, Slack, or forum setup recommendations for your user base." },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="vibn-enter"
|
||||
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
|
||||
>
|
||||
<div style={{ maxWidth: 560 }}>
|
||||
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
|
||||
Grow
|
||||
</h3>
|
||||
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 28 }}>
|
||||
Marketing, launch strategy, and user acquisition — coming once your product is live.
|
||||
</p>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{items.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="vibn-enter"
|
||||
style={{
|
||||
display: "flex", alignItems: "flex-start", gap: 16,
|
||||
padding: "18px 20px", background: "#fff",
|
||||
border: "1px solid #e8e4dc", borderRadius: 10,
|
||||
boxShadow: "0 1px 2px #1a1a1a05",
|
||||
animationDelay: `${i * 0.06}s`,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: "1.2rem", flexShrink: 0, marginTop: 2 }}>{item.icon}</div>
|
||||
<div>
|
||||
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 4 }}>{item.title}</div>
|
||||
<div style={{ fontSize: "0.8rem", color: "#6b6560", lineHeight: 1.55 }}>{item.desc}</div>
|
||||
</div>
|
||||
<span style={{
|
||||
marginLeft: "auto", flexShrink: 0,
|
||||
display: "inline-flex", alignItems: "center",
|
||||
padding: "3px 9px", borderRadius: 4,
|
||||
fontSize: "0.68rem", fontWeight: 600,
|
||||
color: "#9a7b3a", background: "#d4a04a12",
|
||||
}}>
|
||||
Soon
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
app/[workspace]/project/[projectId]/(workspace)/growth/page.tsx
Normal file
144
app/[workspace]/project/[projectId]/(workspace)/growth/page.tsx
Normal file
@@ -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: "var(--font-inter), ui-sans-serif, 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 (
|
||||
<div style={{ display: "flex", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", overflow: "hidden" }}>
|
||||
|
||||
{/* Left nav */}
|
||||
<div style={{ width: 200, flexShrink: 0, borderRight: "1px solid #e8e4dc", background: "#faf8f5", display: "flex", flexDirection: "column", overflow: "auto" }}>
|
||||
<div style={NAV_GROUP}>Growth</div>
|
||||
{SECTIONS.map(s => {
|
||||
const isActive = activeId === s.id;
|
||||
return (
|
||||
<button key={s.id} onClick={() => setSection(s.id)} style={{
|
||||
display: "flex", alignItems: "center", gap: 8, width: "100%", textAlign: "left",
|
||||
background: isActive ? "#f0ece4" : "transparent", border: "none", cursor: "pointer",
|
||||
padding: "6px 12px", borderRadius: 5,
|
||||
fontSize: "0.78rem", fontWeight: isActive ? 600 : 440,
|
||||
color: isActive ? "#1a1a1a" : "#5a5550",
|
||||
}}
|
||||
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
|
||||
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||
>
|
||||
<span style={{ fontSize: "0.65rem", opacity: 0.55, width: 14, textAlign: "center" }}>{s.icon}</span>
|
||||
{s.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, overflow: "auto", display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ padding: "28px 32px", maxWidth: 800 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ fontSize: "1.1rem", fontWeight: 700, color: "#1a1a1a", marginBottom: 6 }}>{active.title}</div>
|
||||
<div style={{ fontSize: "0.82rem", color: "#6b6560", lineHeight: 1.65, maxWidth: 520 }}>{active.desc}</div>
|
||||
</div>
|
||||
|
||||
{/* Feature items */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", gap: 12, marginBottom: 32 }}>
|
||||
{active.items.map(item => (
|
||||
<div key={item} style={{
|
||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 9,
|
||||
padding: "14px 16px", display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
}}>
|
||||
<span style={{ fontSize: "0.8rem", fontWeight: 500, color: "#1a1a1a" }}>{item}</span>
|
||||
<span style={{ fontSize: "0.65rem", color: "#c5c0b8", background: "#f6f4f0", padding: "2px 7px", borderRadius: 4 }}>Soon</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div style={{
|
||||
background: "linear-gradient(135deg, #1a1a1a 0%, #2d2820 100%)",
|
||||
borderRadius: 12, padding: "24px 28px",
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
gap: 20,
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: "0.85rem", fontWeight: 600, color: "#fff", marginBottom: 4 }}>
|
||||
{active.title} is coming to VIBN
|
||||
</div>
|
||||
<div style={{ fontSize: "0.75rem", color: "#8a8478", lineHeight: 1.5 }}>
|
||||
We're building this section next. Shape it by telling us what you need.
|
||||
</div>
|
||||
</div>
|
||||
<button style={{
|
||||
background: "#d4a04a", color: "#fff", border: "none", borderRadius: 8,
|
||||
padding: "9px 20px", fontSize: "0.78rem", fontWeight: 600,
|
||||
cursor: "pointer", whiteSpace: "nowrap", flexShrink: 0,
|
||||
}}>
|
||||
Give feedback
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GrowthPage() {
|
||||
return (
|
||||
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", fontSize: "0.85rem" }}>Loading…</div>}>
|
||||
<GrowthInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useState, useEffect } from "react";
|
||||
import { useParams, useSearchParams, useRouter } from "next/navigation";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface InfraApp {
|
||||
name: string;
|
||||
domain?: string | null;
|
||||
coolifyServiceUuid?: string | null;
|
||||
}
|
||||
|
||||
interface ProjectData {
|
||||
giteaRepo?: string;
|
||||
giteaRepoUrl?: string;
|
||||
apps?: InfraApp[];
|
||||
}
|
||||
|
||||
// ── Tab definitions ───────────────────────────────────────────────────────────
|
||||
|
||||
const TABS = [
|
||||
{ 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: "≈" },
|
||||
] as const;
|
||||
|
||||
type TabId = typeof TABS[number]["id"];
|
||||
|
||||
// ── Shared empty state ────────────────────────────────────────────────────────
|
||||
|
||||
function ComingSoonPanel({ icon, title, description }: { icon: string; title: string; description: string }) {
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, display: "flex", flexDirection: "column",
|
||||
alignItems: "center", justifyContent: "center",
|
||||
padding: 60, textAlign: "center", gap: 16,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 56, height: 56, borderRadius: 14, background: "#f0ece4",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: "1.5rem", color: "#b5b0a6",
|
||||
}}>
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: "1rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 6 }}>{title}</div>
|
||||
<div style={{ fontSize: "0.82rem", color: "#a09a90", maxWidth: 340, lineHeight: 1.6 }}>{description}</div>
|
||||
</div>
|
||||
<div style={{
|
||||
marginTop: 8, padding: "8px 18px",
|
||||
background: "#1a1a1a", color: "#fff",
|
||||
borderRadius: 7, fontSize: "0.78rem", fontWeight: 500,
|
||||
opacity: 0.4, cursor: "default",
|
||||
}}>
|
||||
Coming soon
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Builds tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
function BuildsTab({ project }: { project: ProjectData | null }) {
|
||||
const apps = project?.apps ?? [];
|
||||
if (apps.length === 0) {
|
||||
return (
|
||||
<ComingSoonPanel
|
||||
icon="⬡"
|
||||
title="No deployments yet"
|
||||
description="Once your apps are deployed via Coolify, build history and deployment logs will appear here."
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 720 }}>
|
||||
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
|
||||
Deployed Apps
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{apps.map(app => (
|
||||
<div key={app.name} style={{
|
||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
|
||||
padding: "14px 18px", display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<span style={{ fontSize: "0.85rem", color: "#a09a90" }}>⬡</span>
|
||||
<div>
|
||||
<div style={{ fontSize: "0.82rem", fontWeight: 600, color: "#1a1a1a" }}>{app.name}</div>
|
||||
{app.domain && (
|
||||
<div style={{ fontSize: "0.72rem", color: "#a09a90", marginTop: 2 }}>{app.domain}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#2e7d32", display: "inline-block" }} />
|
||||
<span style={{ fontSize: "0.73rem", color: "#6b6560" }}>Running</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Databases tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
function DatabasesTab() {
|
||||
return (
|
||||
<ComingSoonPanel
|
||||
icon="◫"
|
||||
title="Databases"
|
||||
description="Provision and manage PostgreSQL, Redis, and other databases for your project. Connection strings and credentials will be auto-injected into your environment."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Services tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function ServicesTab() {
|
||||
return (
|
||||
<ComingSoonPanel
|
||||
icon="◎"
|
||||
title="Services"
|
||||
description="Background workers, email delivery, queues, file storage, and third-party integrations will be configured and monitored here."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Environment tab ───────────────────────────────────────────────────────────
|
||||
|
||||
function EnvironmentTab({ project }: { project: ProjectData | null }) {
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 720 }}>
|
||||
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
|
||||
Environment Variables & Secrets
|
||||
</div>
|
||||
<div style={{
|
||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
|
||||
overflow: "hidden", marginBottom: 20,
|
||||
}}>
|
||||
{/* Header row */}
|
||||
<div style={{
|
||||
display: "grid", gridTemplateColumns: "1fr 1fr auto",
|
||||
padding: "10px 18px", background: "#faf8f5",
|
||||
borderBottom: "1px solid #e8e4dc",
|
||||
fontSize: "0.68rem", fontWeight: 700, color: "#a09a90",
|
||||
letterSpacing: "0.06em", textTransform: "uppercase",
|
||||
}}>
|
||||
<span>Key</span><span>Value</span><span />
|
||||
</div>
|
||||
{/* Placeholder rows */}
|
||||
{["DATABASE_URL", "NEXTAUTH_SECRET", "GITEA_API_TOKEN"].map(k => (
|
||||
<div key={k} style={{
|
||||
display: "grid", gridTemplateColumns: "1fr 1fr auto",
|
||||
padding: "11px 18px", borderBottom: "1px solid #f0ece4",
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#1a1a1a" }}>{k}</span>
|
||||
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#b5b0a6", letterSpacing: 2 }}>••••••••</span>
|
||||
<button style={{ background: "none", border: "none", cursor: "pointer", color: "#a09a90", fontSize: "0.72rem", padding: "2px 6px" }}>Edit</button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ padding: "11px 18px", borderTop: "1px solid #f0ece4" }}>
|
||||
<button style={{
|
||||
background: "none", border: "1px dashed #d4cfc8", borderRadius: 6,
|
||||
padding: "6px 14px", fontSize: "0.75rem", color: "#a09a90",
|
||||
cursor: "pointer", width: "100%",
|
||||
}}>
|
||||
+ Add variable
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: "0.75rem", color: "#b5b0a6", lineHeight: 1.6 }}>
|
||||
Variables are encrypted at rest and auto-injected into deployed containers. Secrets are never exposed in logs.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Domains tab ───────────────────────────────────────────────────────────────
|
||||
|
||||
function DomainsTab({ project }: { project: ProjectData | null }) {
|
||||
const apps = (project?.apps ?? []).filter(a => a.domain);
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 720 }}>
|
||||
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
|
||||
Domains & SSL
|
||||
</div>
|
||||
{apps.length > 0 ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10, marginBottom: 20 }}>
|
||||
{apps.map(app => (
|
||||
<div key={app.name} style={{
|
||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
|
||||
padding: "14px 18px", display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.8rem", color: "#1a1a1a", fontWeight: 500 }}>
|
||||
{app.domain}
|
||||
</div>
|
||||
<div style={{ fontSize: "0.7rem", color: "#a09a90", marginTop: 3 }}>{app.name}</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#2e7d32", display: "inline-block" }} />
|
||||
<span style={{ fontSize: "0.73rem", color: "#6b6560" }}>SSL active</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
background: "#fff", border: "1px dashed #d4cfc8", borderRadius: 10,
|
||||
padding: "32px 24px", textAlign: "center", marginBottom: 20,
|
||||
}}>
|
||||
<div style={{ fontSize: "0.82rem", color: "#a09a90" }}>No custom domains configured</div>
|
||||
<div style={{ fontSize: "0.73rem", color: "#b5b0a6", marginTop: 6 }}>Deploy an app first, then point a domain here.</div>
|
||||
</div>
|
||||
)}
|
||||
<button style={{
|
||||
background: "#1a1a1a", color: "#fff", border: "none",
|
||||
borderRadius: 8, padding: "9px 20px",
|
||||
fontSize: "0.78rem", fontWeight: 500, cursor: "pointer",
|
||||
opacity: 0.5,
|
||||
}}>
|
||||
+ Add domain
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Logs tab ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function LogsTab({ project }: { project: ProjectData | null }) {
|
||||
const apps = project?.apps ?? [];
|
||||
if (apps.length === 0) {
|
||||
return (
|
||||
<ComingSoonPanel
|
||||
icon="≈"
|
||||
title="No logs yet"
|
||||
description="Runtime logs, request traces, and error reports from your deployed services will stream here."
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 900 }}>
|
||||
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 16 }}>
|
||||
Runtime Logs
|
||||
</div>
|
||||
<div style={{
|
||||
background: "#1e1e1e", borderRadius: 10, padding: "20px 24px",
|
||||
fontFamily: "IBM Plex Mono, monospace", fontSize: "0.73rem", color: "#d4d4d4",
|
||||
lineHeight: 1.6, minHeight: 200,
|
||||
}}>
|
||||
<div style={{ color: "#6a9955" }}>{"# Logs will stream here once connected to Coolify"}</div>
|
||||
<div style={{ color: "#569cd6", marginTop: 8 }}>{"→ Select a service to tail its log output"}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Inner page ────────────────────────────────────────────────────────────────
|
||||
|
||||
function InfrastructurePageInner() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const projectId = params.projectId as string;
|
||||
const workspace = params.workspace as string;
|
||||
|
||||
const activeTab = (searchParams.get("tab") ?? "builds") as TabId;
|
||||
const [project, setProject] = useState<ProjectData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/projects/${projectId}/apps`)
|
||||
.then(r => r.json())
|
||||
.then(d => setProject({ apps: d.apps ?? [], giteaRepo: d.giteaRepo, giteaRepoUrl: d.giteaRepoUrl }))
|
||||
.catch(() => {});
|
||||
}, [projectId]);
|
||||
|
||||
const setTab = (id: TabId) => {
|
||||
router.push(`/${workspace}/project/${projectId}/infrastructure?tab=${id}`, { scroll: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", overflow: "hidden" }}>
|
||||
|
||||
{/* ── Left sub-nav ── */}
|
||||
<div style={{
|
||||
width: 190, flexShrink: 0,
|
||||
borderRight: "1px solid #e8e4dc",
|
||||
background: "#faf8f5",
|
||||
display: "flex", flexDirection: "column",
|
||||
padding: "16px 8px",
|
||||
gap: 2,
|
||||
overflow: "auto",
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6",
|
||||
letterSpacing: "0.1em", textTransform: "uppercase",
|
||||
padding: "0 8px 10px",
|
||||
}}>
|
||||
Infrastructure
|
||||
</div>
|
||||
{TABS.map(tab => {
|
||||
const active = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setTab(tab.id)}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 9,
|
||||
padding: "7px 10px", borderRadius: 6,
|
||||
background: active ? "#f0ece4" : "transparent",
|
||||
border: "none", cursor: "pointer", width: "100%", textAlign: "left",
|
||||
color: active ? "#1a1a1a" : "#6b6560",
|
||||
fontSize: "0.8rem", fontWeight: active ? 600 : 450,
|
||||
transition: "background 0.1s",
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
|
||||
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||
>
|
||||
<span style={{ fontSize: "0.75rem", opacity: 0.65, width: 16, textAlign: "center" }}>{tab.icon}</span>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Content ── */}
|
||||
<div style={{ flex: 1, overflow: "auto", display: "flex", flexDirection: "column" }}>
|
||||
{activeTab === "builds" && <BuildsTab project={project} />}
|
||||
{activeTab === "databases" && <DatabasesTab />}
|
||||
{activeTab === "services" && <ServicesTab />}
|
||||
{activeTab === "environment" && <EnvironmentTab project={project} />}
|
||||
{activeTab === "domains" && <DomainsTab project={project} />}
|
||||
{activeTab === "logs" && <LogsTab project={project} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Export ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function InfrastructurePage() {
|
||||
return (
|
||||
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", fontSize: "0.85rem" }}>Loading…</div>}>
|
||||
<InfrastructurePageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
export default function InsightsPage() {
|
||||
const items = [
|
||||
{ icon: "📊", title: "Usage analytics", desc: "Page views, active users, retention curves, and funnel analysis." },
|
||||
{ icon: "⚡", title: "Performance", desc: "Load times, error rates, and infrastructure health at a glance." },
|
||||
{ icon: "💰", title: "Revenue", desc: "MRR, churn, LTV, and subscription metrics wired from your billing provider." },
|
||||
{ icon: "🔔", title: "Alerts", desc: "Get notified when key metrics drop or anomalies are detected." },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="vibn-enter"
|
||||
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
|
||||
>
|
||||
<div style={{ maxWidth: 560 }}>
|
||||
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
|
||||
Insights
|
||||
</h3>
|
||||
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 28 }}>
|
||||
Analytics, performance, and revenue — available once your product is deployed.
|
||||
</p>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{items.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="vibn-enter"
|
||||
style={{
|
||||
display: "flex", alignItems: "flex-start", gap: 16,
|
||||
padding: "18px 20px", background: "#fff",
|
||||
border: "1px solid #e8e4dc", borderRadius: 10,
|
||||
boxShadow: "0 1px 2px #1a1a1a05",
|
||||
animationDelay: `${i * 0.06}s`,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: "1.2rem", flexShrink: 0, marginTop: 2 }}>{item.icon}</div>
|
||||
<div>
|
||||
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 4 }}>{item.title}</div>
|
||||
<div style={{ fontSize: "0.8rem", color: "#6b6560", lineHeight: 1.55 }}>{item.desc}</div>
|
||||
</div>
|
||||
<span style={{
|
||||
marginLeft: "auto", flexShrink: 0,
|
||||
display: "inline-flex", alignItems: "center",
|
||||
padding: "3px 9px", borderRadius: 4,
|
||||
fontSize: "0.68rem", fontWeight: 600,
|
||||
color: "#9a7b3a", background: "#d4a04a12",
|
||||
}}>
|
||||
Soon
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { FreshIdeaMain } from "@/components/project-main/FreshIdeaMain";
|
||||
import { ChatImportMain } from "@/components/project-main/ChatImportMain";
|
||||
import { CodeImportMain } from "@/components/project-main/CodeImportMain";
|
||||
import { MigrateMain } from "@/components/project-main/MigrateMain";
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
productName: string;
|
||||
name?: string;
|
||||
stage?: "discovery" | "architecture" | "building" | "active";
|
||||
creationMode?: "fresh" | "chat-import" | "code-import" | "migration";
|
||||
creationStage?: string;
|
||||
sourceData?: {
|
||||
chatText?: string;
|
||||
repoUrl?: string;
|
||||
liveUrl?: string;
|
||||
hosting?: string;
|
||||
description?: string;
|
||||
};
|
||||
analysisResult?: Record<string, unknown>;
|
||||
migrationPlan?: string;
|
||||
}
|
||||
|
||||
export default function ProjectOverviewPage() {
|
||||
const params = useParams();
|
||||
const projectId = params.projectId as string;
|
||||
const { status: authStatus } = useSession();
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (authStatus !== "authenticated") {
|
||||
if (authStatus === "unauthenticated") setLoading(false);
|
||||
return;
|
||||
}
|
||||
fetch(`/api/projects/${projectId}`)
|
||||
.then(r => r.json())
|
||||
.then(d => setProject(d.project))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [authStatus, projectId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
<Loader2 style={{ width: 24, height: 24, color: "#a09a90" }} className="animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#a09a90", fontSize: "0.88rem" }}>
|
||||
Project not found.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const projectName = project.productName || project.name || "Untitled";
|
||||
const mode = project.creationMode ?? "fresh";
|
||||
|
||||
if (mode === "chat-import") {
|
||||
return (
|
||||
<ChatImportMain
|
||||
projectId={projectId}
|
||||
projectName={projectName}
|
||||
sourceData={project.sourceData}
|
||||
analysisResult={project.analysisResult as Parameters<typeof ChatImportMain>[0]["analysisResult"]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "code-import") {
|
||||
return (
|
||||
<CodeImportMain
|
||||
projectId={projectId}
|
||||
projectName={projectName}
|
||||
sourceData={project.sourceData}
|
||||
analysisResult={project.analysisResult}
|
||||
creationStage={project.creationStage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "migration") {
|
||||
return (
|
||||
<MigrateMain
|
||||
projectId={projectId}
|
||||
projectName={projectName}
|
||||
sourceData={project.sourceData}
|
||||
analysisResult={project.analysisResult}
|
||||
migrationPlan={project.migrationPlan}
|
||||
creationStage={project.creationStage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: "fresh" — wraps AtlasChat with decision banner
|
||||
return (
|
||||
<FreshIdeaMain
|
||||
projectId={projectId}
|
||||
projectName={projectName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
459
app/[workspace]/project/[projectId]/(workspace)/prd/page.tsx
Normal file
459
app/[workspace]/project/[projectId]/(workspace)/prd/page.tsx
Normal file
@@ -0,0 +1,459 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
||||
// Maps each PRD section to the discovery phase that populates it
|
||||
const PRD_SECTIONS = [
|
||||
{ id: "executive_summary", label: "Executive Summary", phaseId: "big_picture" },
|
||||
{ id: "problem_statement", label: "Problem Statement", phaseId: "big_picture" },
|
||||
{ id: "vision_metrics", label: "Vision & Success Metrics", phaseId: "big_picture" },
|
||||
{ id: "users_personas", label: "Users & Personas", phaseId: "users_personas" },
|
||||
{ id: "user_flows", label: "User Flows", phaseId: "users_personas" },
|
||||
{ id: "feature_requirements", label: "Feature Requirements", phaseId: "features_scope" },
|
||||
{ id: "screen_specs", label: "Screen Specs", phaseId: "screens_data" },
|
||||
{ id: "business_model", label: "Business Model", phaseId: "business_model" },
|
||||
{ id: "integrations", label: "Integrations & Dependencies", phaseId: "features_scope" },
|
||||
{ id: "non_functional", label: "Non-Functional Reqs", phaseId: "features_scope" },
|
||||
{ id: "risks", label: "Risks & Mitigations", phaseId: "risks_questions" },
|
||||
{ id: "open_questions", label: "Open Questions", phaseId: "risks_questions" },
|
||||
];
|
||||
|
||||
interface SavedPhase {
|
||||
phase: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
data: Record<string, unknown>;
|
||||
saved_at: string;
|
||||
}
|
||||
|
||||
function formatValue(v: unknown): string {
|
||||
if (v === null || v === undefined) return "—";
|
||||
if (Array.isArray(v)) return v.map(item => typeof item === "object" ? JSON.stringify(item) : String(item)).join(", ");
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function PhaseDataCard({ phase }: { phase: SavedPhase }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const entries = Object.entries(phase.data).filter(([, v]) => v !== null && v !== undefined && v !== "");
|
||||
return (
|
||||
<div style={{
|
||||
marginTop: 10, background: "#f6f4f0", borderRadius: 8,
|
||||
border: "1px solid #e8e4dc", overflow: "hidden",
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
style={{
|
||||
width: "100%", textAlign: "left", padding: "10px 14px",
|
||||
background: "none", border: "none", cursor: "pointer",
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.45 }}>
|
||||
{phase.summary}
|
||||
</span>
|
||||
<span style={{ fontSize: "0.7rem", color: "#a09a90", marginLeft: 8, flexShrink: 0 }}>
|
||||
{expanded ? "▲" : "▼"}
|
||||
</span>
|
||||
</button>
|
||||
{expanded && entries.length > 0 && (
|
||||
<div style={{ padding: "4px 14px 14px", borderTop: "1px solid #e8e4dc" }}>
|
||||
{entries.map(([k, v]) => (
|
||||
<div key={k} style={{ marginTop: 10 }}>
|
||||
<div style={{
|
||||
fontSize: "0.6rem", color: "#b5b0a6", textTransform: "uppercase",
|
||||
letterSpacing: "0.06em", fontWeight: 600, marginBottom: 2,
|
||||
}}>
|
||||
{k.replace(/_/g, " ")}
|
||||
</div>
|
||||
<div style={{ fontSize: "0.78rem", color: "#2a2824", lineHeight: 1.5 }}>
|
||||
{formatValue(v)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ArchApp { name: string; type: string; description: string; tech?: string[]; screens?: string[] }
|
||||
interface ArchInfra { name: string; reason: string }
|
||||
interface ArchPackage { name: string; description: string }
|
||||
interface ArchIntegration { name: string; required?: boolean; notes?: string }
|
||||
interface Architecture {
|
||||
productName?: string;
|
||||
productType?: string;
|
||||
summary?: string;
|
||||
apps?: ArchApp[];
|
||||
packages?: ArchPackage[];
|
||||
infrastructure?: ArchInfra[];
|
||||
integrations?: ArchIntegration[];
|
||||
designSurfaces?: string[];
|
||||
riskNotes?: string[];
|
||||
}
|
||||
|
||||
function ArchitectureView({ arch }: { arch: Architecture }) {
|
||||
const Section = ({ title, children }: { title: string; children: React.ReactNode }) => (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 10 }}>{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
const Card = ({ children }: { children: React.ReactNode }) => (
|
||||
<div style={{ background: "#fff", border: "1px solid #e8e4dc", borderRadius: 9, padding: "14px 16px", marginBottom: 8 }}>{children}</div>
|
||||
);
|
||||
const Tag = ({ label }: { label: string }) => (
|
||||
<span style={{ background: "#f0ece4", borderRadius: 4, padding: "2px 7px", fontSize: "0.68rem", color: "#6b6560", fontFamily: "IBM Plex Mono, monospace", marginRight: 4, display: "inline-block", marginBottom: 3 }}>{label}</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 760 }}>
|
||||
{arch.summary && (
|
||||
<div style={{ background: "#1a1a1a", borderRadius: 10, padding: "18px 22px", marginBottom: 24, color: "#e8e4dc", fontSize: "0.88rem", lineHeight: 1.7 }}>
|
||||
{arch.summary}
|
||||
</div>
|
||||
)}
|
||||
{(arch.apps ?? []).length > 0 && (
|
||||
<Section title="Applications">
|
||||
{arch.apps!.map(a => (
|
||||
<Card key={a.name}>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 8, marginBottom: 4 }}>
|
||||
<span style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a" }}>{a.name}</span>
|
||||
<span style={{ fontSize: "0.72rem", color: "#9a9490" }}>{a.type}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.55, marginBottom: a.tech?.length ? 8 : 0 }}>{a.description}</div>
|
||||
{a.tech?.map(t => <Tag key={t} label={t} />)}
|
||||
{a.screens && a.screens.length > 0 && (
|
||||
<div style={{ marginTop: 6, fontSize: "0.72rem", color: "#a09a90" }}>Screens: {a.screens.join(", ")}</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
{(arch.packages ?? []).length > 0 && (
|
||||
<Section title="Shared Packages">
|
||||
{arch.packages!.map(p => (
|
||||
<Card key={p.name}>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "baseline" }}>
|
||||
<span style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a", fontFamily: "IBM Plex Mono, monospace" }}>{p.name}</span>
|
||||
<span style={{ fontSize: "0.78rem", color: "#4a4640" }}>{p.description}</span>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
{(arch.infrastructure ?? []).length > 0 && (
|
||||
<Section title="Infrastructure">
|
||||
{arch.infrastructure!.map(i => (
|
||||
<Card key={i.name}>
|
||||
<div style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 3 }}>{i.name}</div>
|
||||
<div style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.5 }}>{i.reason}</div>
|
||||
</Card>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
{(arch.integrations ?? []).length > 0 && (
|
||||
<Section title="Integrations">
|
||||
{arch.integrations!.map(i => (
|
||||
<Card key={i.name}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: i.notes ? 4 : 0 }}>
|
||||
<span style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a" }}>{i.name}</span>
|
||||
{i.required && <span style={{ fontSize: "0.62rem", background: "#fef3c7", color: "#92400e", padding: "1px 6px", borderRadius: 4 }}>required</span>}
|
||||
</div>
|
||||
{i.notes && <div style={{ fontSize: "0.78rem", color: "#4a4640" }}>{i.notes}</div>}
|
||||
</Card>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
{(arch.riskNotes ?? []).length > 0 && (
|
||||
<Section title="Architectural Risks">
|
||||
{arch.riskNotes!.map((r, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: 10, alignItems: "flex-start", marginBottom: 8 }}>
|
||||
<span style={{ fontSize: "0.72rem", color: "#d97706", marginTop: 2, flexShrink: 0 }}>⚠</span>
|
||||
<span style={{ fontSize: "0.82rem", color: "#4a4640", lineHeight: 1.5 }}>{r}</span>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PRDPage() {
|
||||
const params = useParams();
|
||||
const projectId = params.projectId as string;
|
||||
const workspace = params.workspace as string;
|
||||
const [prd, setPrd] = useState<string | null>(null);
|
||||
const [architecture, setArchitecture] = useState<Architecture | null>(null);
|
||||
const [savedPhases, setSavedPhases] = useState<SavedPhase[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<"prd" | "architecture">("prd");
|
||||
const [archGenerating, setArchGenerating] = useState(false);
|
||||
const [archError, setArchError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch(`/api/projects/${projectId}`).then(r => r.json()).catch(() => ({})),
|
||||
fetch(`/api/projects/${projectId}/save-phase`).then(r => r.json()).catch(() => ({ phases: [] })),
|
||||
]).then(([projectData, phaseData]) => {
|
||||
setPrd(projectData?.project?.prd ?? null);
|
||||
setArchitecture(projectData?.project?.architecture ?? null);
|
||||
setSavedPhases(phaseData?.phases ?? []);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [projectId]);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const handleGenerateArchitecture = async () => {
|
||||
setArchGenerating(true);
|
||||
setArchError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/architecture`, { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? "Generation failed");
|
||||
setArchitecture(data.architecture);
|
||||
setActiveTab("architecture");
|
||||
} catch (e) {
|
||||
setArchError(e instanceof Error ? e.message : "Something went wrong");
|
||||
} finally {
|
||||
setArchGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const phaseMap = new Map(savedPhases.map(p => [p.phase, p]));
|
||||
const savedPhaseIds = new Set(savedPhases.map(p => p.phase));
|
||||
|
||||
const sections = PRD_SECTIONS.map(s => ({
|
||||
...s,
|
||||
savedPhase: s.phaseId ? phaseMap.get(s.phaseId) ?? null : null,
|
||||
isDone: s.phaseId ? savedPhaseIds.has(s.phaseId) : false,
|
||||
}));
|
||||
|
||||
const doneCount = sections.filter(s => s.isDone).length;
|
||||
const totalPct = Math.round((doneCount / sections.length) * 100);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#a09a90" }}>
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: "prd" as const, label: "PRD", available: true },
|
||||
{ id: "architecture" as const, label: "Architecture", available: !!architecture },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: "28px 32px", flex: 1, overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
|
||||
{/* Tab bar — only when at least one doc exists */}
|
||||
{(prd || architecture) && (
|
||||
<div style={{ display: "flex", gap: 4, marginBottom: 24 }}>
|
||||
{tabs.map(t => {
|
||||
const isActive = activeTab === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => t.available && setActiveTab(t.id)}
|
||||
disabled={!t.available}
|
||||
style={{
|
||||
padding: "6px 14px", borderRadius: 8, border: "none", cursor: t.available ? "pointer" : "default",
|
||||
background: isActive ? "#1a1a1a" : "transparent",
|
||||
color: isActive ? "#fff" : t.available ? "#6b6560" : "#c5c0b8",
|
||||
fontSize: "0.8rem", fontWeight: isActive ? 600 : 400,
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
transition: "all 0.1s",
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
{!t.available && <span style={{ marginLeft: 5, fontSize: "0.65rem", opacity: 0.6 }}>—</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Next step banner — PRD done but no architecture yet */}
|
||||
{prd && !architecture && activeTab === "prd" && (
|
||||
<div style={{
|
||||
marginBottom: 24, padding: "18px 22px",
|
||||
background: "#1a1a1a", borderRadius: 10,
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
gap: 16, flexWrap: "wrap",
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#fff", marginBottom: 4 }}>
|
||||
Next: Generate technical architecture
|
||||
</div>
|
||||
<div style={{ fontSize: "0.76rem", color: "#a09a90", lineHeight: 1.5 }}>
|
||||
The AI will read your PRD and recommend the apps, services, and infrastructure your product needs. Takes ~30 seconds.
|
||||
</div>
|
||||
{archError && (
|
||||
<div style={{ fontSize: "0.74rem", color: "#f87171", marginTop: 6 }}>⚠ {archError}</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleGenerateArchitecture}
|
||||
disabled={archGenerating}
|
||||
style={{
|
||||
padding: "10px 20px", borderRadius: 8, border: "none",
|
||||
background: archGenerating ? "#4a4640" : "#fff",
|
||||
color: archGenerating ? "#a09a90" : "#1a1a1a",
|
||||
fontSize: "0.82rem", fontWeight: 700,
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
cursor: archGenerating ? "default" : "pointer",
|
||||
flexShrink: 0, display: "flex", alignItems: "center", gap: 8,
|
||||
transition: "opacity 0.15s",
|
||||
}}
|
||||
>
|
||||
{archGenerating && (
|
||||
<span style={{
|
||||
width: 12, height: 12, borderRadius: "50%",
|
||||
border: "2px solid #60606040", borderTopColor: "#a09a90",
|
||||
animation: "spin 0.7s linear infinite", display: "inline-block",
|
||||
}} />
|
||||
)}
|
||||
{archGenerating ? "Analysing PRD…" : "Generate architecture →"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Architecture tab */}
|
||||
{activeTab === "architecture" && architecture && (
|
||||
<ArchitectureView arch={architecture} />
|
||||
)}
|
||||
|
||||
{/* PRD tab — finalized */}
|
||||
{activeTab === "prd" && prd && (
|
||||
<div style={{ maxWidth: 760 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 20 }}>
|
||||
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", margin: 0 }}>
|
||||
Product Requirements
|
||||
</h3>
|
||||
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.72rem", color: "#6b6560", background: "#f0ece4", padding: "4px 10px", borderRadius: 5 }}>
|
||||
PRD complete
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
background: "#fff", borderRadius: 10, border: "1px solid #e8e4dc",
|
||||
padding: "28px 32px", lineHeight: 1.8,
|
||||
fontSize: "0.88rem", color: "#2a2824",
|
||||
whiteSpace: "pre-wrap", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||
}}>
|
||||
{prd}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PRD tab — section progress (no finalized PRD yet) */}
|
||||
{activeTab === "prd" && !prd && (
|
||||
/* ── Section progress view ── */
|
||||
<div style={{ maxWidth: 680 }}>
|
||||
{/* Progress bar */}
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 16,
|
||||
padding: "16px 20px", background: "#fff",
|
||||
border: "1px solid #e8e4dc", borderRadius: 10,
|
||||
marginBottom: 24, boxShadow: "0 1px 2px #1a1a1a05",
|
||||
}}>
|
||||
<div style={{
|
||||
fontFamily: "IBM Plex Mono, monospace",
|
||||
fontSize: "1.4rem", fontWeight: 500, color: "#1a1a1a", minWidth: 52,
|
||||
}}>
|
||||
{totalPct}%
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ height: 4, borderRadius: 2, background: "#eae6de" }}>
|
||||
<div style={{
|
||||
height: "100%", borderRadius: 2,
|
||||
width: `${totalPct}%`, background: "#1a1a1a",
|
||||
transition: "width 0.6s ease",
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontSize: "0.75rem", color: "#a09a90" }}>
|
||||
{doneCount}/{sections.length} sections
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
{sections.map((s, i) => (
|
||||
<div
|
||||
key={s.id}
|
||||
style={{
|
||||
padding: "14px 18px", marginBottom: 6,
|
||||
background: "#fff", borderRadius: 10,
|
||||
border: `1px solid ${s.isDone ? "#a5d6a740" : "#e8e4dc"}`,
|
||||
animationDelay: `${i * 0.04}s`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
{/* Status icon */}
|
||||
<div style={{
|
||||
width: 24, height: 24, borderRadius: 6, flexShrink: 0,
|
||||
background: s.isDone ? "#2e7d3210" : "#f6f4f0",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: "0.65rem", fontWeight: 700,
|
||||
color: s.isDone ? "#2e7d32" : "#c5c0b8",
|
||||
}}>
|
||||
{s.isDone ? "✓" : "○"}
|
||||
</div>
|
||||
|
||||
<span style={{
|
||||
flex: 1, fontSize: "0.84rem",
|
||||
color: s.isDone ? "#1a1a1a" : "#a09a90",
|
||||
fontWeight: s.isDone ? 500 : 400,
|
||||
}}>
|
||||
{s.label}
|
||||
</span>
|
||||
|
||||
{s.isDone && s.savedPhase && (
|
||||
<span style={{
|
||||
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
|
||||
color: "#2e7d32", background: "#2e7d3210",
|
||||
padding: "2px 7px", borderRadius: 4, fontWeight: 500,
|
||||
}}>
|
||||
saved
|
||||
</span>
|
||||
)}
|
||||
{!s.isDone && !s.phaseId && (
|
||||
<span style={{
|
||||
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
|
||||
color: "#b5b0a6", padding: "2px 7px",
|
||||
}}>
|
||||
generated
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expandable phase data */}
|
||||
{s.isDone && s.savedPhase && (
|
||||
<PhaseDataCard phase={s.savedPhase} />
|
||||
)}
|
||||
|
||||
{/* Pending hint */}
|
||||
{!s.isDone && (
|
||||
<div style={{ marginTop: 6, marginLeft: 36, fontSize: "0.72rem", color: "#c5c0b8" }}>
|
||||
{s.phaseId
|
||||
? `Complete the ${s.savedPhase ? s.savedPhase.title : "discovery"} phase in Vibn`
|
||||
: "Will be generated when PRD is finalized"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{doneCount === 0 && (
|
||||
<p style={{ fontSize: "0.78rem", color: "#b5b0a6", marginTop: 20, textAlign: "center" }}>
|
||||
Continue chatting with Vibn — saved phases will appear here automatically.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
productName: string;
|
||||
productVision?: string;
|
||||
giteaRepo?: string;
|
||||
giteaRepoUrl?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{
|
||||
fontSize: "0.6rem", fontWeight: 600, color: "#a09a90",
|
||||
letterSpacing: "0.1em", textTransform: "uppercase", marginBottom: 12,
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoCard({ children, style = {} }: { children: React.ReactNode; style?: React.CSSProperties }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
|
||||
padding: "22px", marginBottom: 12, boxShadow: "0 1px 2px #1a1a1a05", ...style,
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProjectSettingsPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { data: session } = useSession();
|
||||
const projectId = params.projectId as string;
|
||||
const workspace = params.workspace as string;
|
||||
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const [productName, setProductName] = useState("");
|
||||
const [productVision, setProductVision] = useState("");
|
||||
|
||||
const userInitial = session?.user?.name?.[0]?.toUpperCase() ?? session?.user?.email?.[0]?.toUpperCase() ?? "?";
|
||||
const userName = session?.user?.name ?? session?.user?.email?.split("@")[0] ?? "You";
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/projects/${projectId}`)
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
const p = d.project;
|
||||
setProject(p);
|
||||
setProductName(p?.productName ?? "");
|
||||
setProductVision(p?.productVision ?? "");
|
||||
})
|
||||
.catch(() => toast.error("Failed to load project"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [projectId]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ productName, productVision }),
|
||||
});
|
||||
if (res.ok) {
|
||||
toast.success("Saved");
|
||||
setProject((p) => p ? { ...p, productName, productVision } : p);
|
||||
} else {
|
||||
toast.error("Failed to save");
|
||||
}
|
||||
} catch {
|
||||
toast.error("An error occurred");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirmDelete) { setConfirmDelete(true); return; }
|
||||
setDeleting(true);
|
||||
try {
|
||||
const res = await fetch("/api/projects/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ projectId }),
|
||||
});
|
||||
if (res.ok) {
|
||||
toast.success("Project deleted");
|
||||
router.push(`/${workspace}/projects`);
|
||||
} else {
|
||||
toast.error("Failed to delete project");
|
||||
}
|
||||
} catch {
|
||||
toast.error("An error occurred");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||
<Loader2 style={{ width: 24, height: 24, color: "#a09a90" }} className="animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="vibn-enter"
|
||||
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
|
||||
>
|
||||
<div style={{ maxWidth: 480 }}>
|
||||
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
|
||||
Project Settings
|
||||
</h3>
|
||||
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 24 }}>
|
||||
Configure {project?.productName ?? "this project"}
|
||||
</p>
|
||||
|
||||
{/* General */}
|
||||
<InfoCard>
|
||||
<SectionLabel>General</SectionLabel>
|
||||
<FieldLabel>Project name</FieldLabel>
|
||||
<input
|
||||
value={productName}
|
||||
onChange={(e) => setProductName(e.target.value)}
|
||||
style={{ width: "100%", padding: "9px 13px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.84rem", color: "#1a1a1a", marginBottom: 16, boxSizing: "border-box" }}
|
||||
/>
|
||||
<FieldLabel>Description</FieldLabel>
|
||||
<textarea
|
||||
value={productVision}
|
||||
onChange={(e) => setProductVision(e.target.value)}
|
||||
rows={3}
|
||||
style={{ width: "100%", padding: "9px 13px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#faf8f5", fontSize: "0.84rem", color: "#1a1a1a", resize: "vertical", boxSizing: "border-box" }}
|
||||
/>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: 16 }}>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
style={{ padding: "8px 20px", borderRadius: 7, border: "none", background: "#1a1a1a", color: "#fff", fontSize: "0.78rem", fontWeight: 600, cursor: saving ? "not-allowed" : "pointer", opacity: saving ? 0.7 : 1, display: "flex", alignItems: "center", gap: 6 }}
|
||||
>
|
||||
{saving && <Loader2 style={{ width: 12, height: 12 }} className="animate-spin" />}
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</InfoCard>
|
||||
|
||||
{/* Repo */}
|
||||
{project?.giteaRepoUrl && (
|
||||
<InfoCard>
|
||||
<SectionLabel>Repository</SectionLabel>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: "0.84rem", fontFamily: "IBM Plex Mono, monospace", color: "#1a1a1a", fontWeight: 500 }}>{project.giteaRepo}</div>
|
||||
</div>
|
||||
<a href={project.giteaRepoUrl} target="_blank" rel="noopener noreferrer"
|
||||
style={{ padding: "5px 12px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.7rem", fontWeight: 600, textDecoration: "none" }}>
|
||||
View ↗
|
||||
</a>
|
||||
</div>
|
||||
</InfoCard>
|
||||
)}
|
||||
|
||||
{/* Collaborators */}
|
||||
<InfoCard>
|
||||
<SectionLabel>Collaborators</SectionLabel>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 0" }}>
|
||||
<div style={{ width: 28, height: 28, borderRadius: "50%", background: "#f0ece4", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.68rem", fontWeight: 600, color: "#8a8478" }}>
|
||||
{userInitial}
|
||||
</div>
|
||||
<span style={{ flex: 1, fontSize: "0.82rem", color: "#1a1a1a" }}>{userName}</span>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", padding: "3px 9px", borderRadius: 4, fontSize: "0.68rem", fontWeight: 600, color: "#6b6560", background: "#f0ece4" }}>Owner</span>
|
||||
</div>
|
||||
<button
|
||||
style={{ width: "100%", marginTop: 12, padding: "8px 16px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.75rem", fontWeight: 600, cursor: "pointer" }}
|
||||
onClick={() => toast.info("Team invites coming soon")}
|
||||
>
|
||||
+ Invite to project
|
||||
</button>
|
||||
</InfoCard>
|
||||
|
||||
{/* Export */}
|
||||
<InfoCard>
|
||||
<SectionLabel>Export</SectionLabel>
|
||||
<p style={{ fontSize: "0.82rem", color: "#6b6560", marginBottom: 14, lineHeight: 1.6 }}>
|
||||
Download your PRD or project data for external use.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button
|
||||
style={{ padding: "8px 16px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.75rem", fontWeight: 600, cursor: "pointer" }}
|
||||
onClick={() => toast.info("PDF export coming soon")}
|
||||
>
|
||||
Export PRD as PDF
|
||||
</button>
|
||||
<button
|
||||
style={{ padding: "8px 16px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.75rem", fontWeight: 600, cursor: "pointer" }}
|
||||
onClick={async () => {
|
||||
const res = await fetch(`/api/projects/${projectId}`);
|
||||
const data = await res.json();
|
||||
const blob = new Blob([JSON.stringify(data.project, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url; a.download = `${productName.replace(/\s+/g, "-")}.json`; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
>
|
||||
Export as JSON
|
||||
</button>
|
||||
</div>
|
||||
</InfoCard>
|
||||
|
||||
{/* Danger zone */}
|
||||
<div style={{ background: "#fff", border: "1px solid #f5d5d5", borderRadius: 10, padding: "20px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<div>
|
||||
<div style={{ fontSize: "0.84rem", fontWeight: 500, color: "#d32f2f" }}>Delete project</div>
|
||||
<div style={{ fontSize: "0.75rem", color: "#a09a90" }}>
|
||||
{confirmDelete ? "Click again to confirm — this cannot be undone" : "This action cannot be undone"}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
style={{ padding: "6px 14px", borderRadius: 7, border: "1px solid #f5d5d5", background: confirmDelete ? "#d32f2f" : "#fff", color: confirmDelete ? "#fff" : "#d32f2f", fontSize: "0.72rem", fontWeight: 600, cursor: "pointer", transition: "all 0.15s", display: "flex", alignItems: "center", gap: 6 }}
|
||||
>
|
||||
{deleting && <Loader2 style={{ width: 12, height: 12 }} className="animate-spin" />}
|
||||
{confirmDelete ? "Confirm Delete" : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user