feat(frontend): email+password auth, /signin + /signup pages, marketing consolidation, onboarding workspace naming + full data persistence

This commit is contained in:
2026-06-06 20:28:38 -07:00
parent 201171922d
commit c1d37184eb
49 changed files with 2753 additions and 6503 deletions

692
_onboarding/page.tsx Normal file
View File

@@ -0,0 +1,692 @@
"use client";
import React, { useState, useEffect, useMemo, Fragment } from "react";
import "./onboarding.css";
import { ForkScreen } from "./onboarding-fork";
import { EntrepreneurPath } from "./onboarding-entrepreneur";
import { OwnerPath } from "./onboarding-owner";
import { ConsultantPath } from "./onboarding-consultant";
import { BuildScreen } from "./onboarding-build";
import { ReadyScreen } from "./onboarding-build"; // Assuming ReadyScreen is exported from build
import { AgencyOnboarding } from "./onboarding-agency";
import { type AgencyOnboardingResult } from "./onboarding-agency-types";
import { WizardTop, WizardBody, WizardQ, Field } from "./onboarding-primitives";
// Root onboarding app — owns the route state and the answers dict.
// Routes: fork → <path> → build → ready. A floating debug navigator (toggle
// in the lower-right) lets reviewers jump between any screen without
// filling out the form.
export default function OnboardingApp() {
const initialName = React.useMemo(() => {
try {
return typeof window !== "undefined"
? localStorage.getItem("vibn:firstName") || ""
: "";
} catch {
return "";
}
}, []);
const [stage, setStage] = React.useState("door"); // door | agency | fork | path | choice | build | ready
const [path, setPath] = React.useState(null); // entrepreneur | owner | consultant
const [forkChoice, setForkChoice] = React.useState(null);
const [step, setStep] = React.useState(0);
const [data, setData] = React.useState<Record<string, unknown>>({});
const [createdSlug, setCreatedSlug] = React.useState<string | null>(null);
const [saving, setSaving] = React.useState(false);
const [debugOpen, setDebugOpen] = React.useState(false);
const update = (patch: Record<string, unknown>) =>
setData((d) => ({ ...d, ...patch }));
// ── GTM Onboarding database saving endpoints ────────────────────────────────
const saveOnboarding = async (
payload: Record<string, unknown>,
): Promise<string | null> => {
setSaving(true);
try {
const res = await fetch("/api/onboarding", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (res.ok) {
const bodyData = await res.json();
setCreatedSlug(bodyData.slug);
setSaving(false);
return bodyData.slug;
}
} catch (err) {
console.error("Failed to save onboarding selections:", err);
}
setSaving(false);
return null;
};
const finishAgency = async (result: AgencyOnboardingResult) => {
const slug = await saveOnboarding({
isAgency: true,
profile: result.profile,
expertise: result.expertise,
tools: result.tools,
});
if (slug && typeof window !== "undefined") {
window.location.href = "/" + slug;
}
};
const finishNaming = async (workspaceName: string) => {
const slug = await saveOnboarding({ isAgency: false, data, workspaceName });
if (slug && typeof window !== "undefined") {
window.location.href = "/" + slug;
}
};
// ── transitions ──────────────────────────────────────────────────────
const confirmFork = () => {
if (!forkChoice) return;
setPath(forkChoice);
setStep(0);
setStage("path");
};
const backToFork = () => {
setStage("fork");
setStep(0);
};
const completePath = () => setStage("choice");
const openWorkspace = () => {
if (createdSlug && typeof window !== "undefined") {
window.location.href = "/" + createdSlug; // Route directly to their live chat workspace!
} else {
setStage("ready");
}
};
const close = () => {
if (typeof window !== "undefined") window.location.href = "/";
};
const openChat = () => {
if (createdSlug && typeof window !== "undefined") {
window.location.href = "/" + createdSlug;
} else if (typeof window !== "undefined") {
window.location.href = "/";
}
};
const openAgency = () => setStage("agency");
const openSelf = () => {
setStage("fork");
setStep(0);
};
// ⌘↵ advances on whatever the current primary action is
React.useEffect(() => {
const handler = (e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
const btn = document.querySelector(
".btn-primary:not([disabled])",
) as HTMLElement;
if (btn) btn.click();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
// ── render ───────────────────────────────────────────────────────────
let body;
if (stage === "door") {
body = (
<DoorScreen onAgency={openAgency} onSelf={openSelf} onClose={close} />
);
} else if (stage === "agency") {
body = (
<AgencyOnboarding
onComplete={finishAgency}
onExit={close}
onBack={() => setStage("door")}
/>
);
} else if (stage === "fork") {
body = (
<ForkScreen
name={initialName}
value={forkChoice}
onChange={setForkChoice}
onClose={close}
onNext={confirmFork}
/>
);
} else if (stage === "path") {
const props = {
data,
onUpdate: update,
onBack: backToFork,
onClose: close,
onComplete: completePath,
onJumpToStep: setStep,
step,
};
if (path === "entrepreneur") body = <EntrepreneurPath {...props} />;
else if (path === "owner") body = <OwnerPath {...props} />;
else body = <ConsultantPath {...props} />;
} else if (stage === "choice") {
body = (
<NameWorkspaceScreen
defaultName={String(data.bizName ?? data.idea ?? "")}
onSubmit={finishNaming}
onClose={close}
resolving={saving}
/>
);
} else if (stage === "build") {
body = (
<BuildScreen
path={path}
data={data}
onBack={() => setStage("path")}
onClose={close}
onOpen={openWorkspace}
/>
);
} else {
body = (
<ReadyScreen
path={path}
data={data}
onClose={close}
onOpenChat={openChat}
/>
);
}
return (
<div className="app">
{body}
<DebugNav
open={debugOpen}
setOpen={setDebugOpen}
stage={stage}
path={path}
step={step}
onJump={(s, p, idx) => {
if (s === "fork") setStage("fork");
else if (s === "build") {
setPath(p);
setStage("build");
} else if (s === "ready") {
setPath(p);
setStage("ready");
} else {
setPath(p);
setStep(idx);
setStage("path");
}
}}
/>
</div>
);
}
// ── Debug navigator ──────────────────────────────────────────────────────
function DebugNav({ open, setOpen, stage, path, step, onJump }) {
const groups = [
{
title: "Start",
rows: [
{
label: "01 · Fork",
active: stage === "fork",
go: () => onJump("fork"),
},
],
},
{
title: "Entrepreneur",
rows: [
{
label: "02 · Idea",
active: stage === "path" && path === "entrepreneur" && step === 0,
go: () => onJump("path", "entrepreneur", 0),
},
{
label: "03 · Audience",
active: stage === "path" && path === "entrepreneur" && step === 1,
go: () => onJump("path", "entrepreneur", 1),
},
{
label: "04 · Goal",
active: stage === "path" && path === "entrepreneur" && step === 2,
go: () => onJump("path", "entrepreneur", 2),
},
{
label: "05 · Vibe",
active: stage === "path" && path === "entrepreneur" && step === 3,
go: () => onJump("path", "entrepreneur", 3),
},
],
},
{
title: "Owner",
rows: [
{
label: "02 · Business",
active: stage === "path" && path === "owner" && step === 0,
go: () => onJump("path", "owner", 0),
},
{
label: "03 · Stack",
active: stage === "path" && path === "owner" && step === 1,
go: () => onJump("path", "owner", 1),
},
{
label: "04 · First fix",
active: stage === "path" && path === "owner" && step === 2,
go: () => onJump("path", "owner", 2),
},
{
label: "05 · Scale",
active: stage === "path" && path === "owner" && step === 3,
go: () => onJump("path", "owner", 3),
},
],
},
{
title: "Consultant",
rows: [
{
label: "02 · Client",
active: stage === "path" && path === "consultant" && step === 0,
go: () => onJump("path", "consultant", 0),
},
{
label: "03 · Brief",
active: stage === "path" && path === "consultant" && step === 1,
go: () => onJump("path", "consultant", 1),
},
{
label: "04 · Scope",
active: stage === "path" && path === "consultant" && step === 2,
go: () => onJump("path", "consultant", 2),
},
{
label: "05 · Handoff",
active: stage === "path" && path === "consultant" && step === 3,
go: () => onJump("path", "consultant", 3),
},
],
},
{
title: "Finish",
rows: [
{
label: "Build · entrepreneur",
active: stage === "build" && path === "entrepreneur",
go: () => onJump("build", "entrepreneur"),
},
{
label: "Build · owner",
active: stage === "build" && path === "owner",
go: () => onJump("build", "owner"),
},
{
label: "Build · consultant",
active: stage === "build" && path === "consultant",
go: () => onJump("build", "consultant"),
},
{
label: "Ready",
active: stage === "ready",
go: () => onJump("ready", path || "entrepreneur"),
},
],
},
];
return (
<div className="debug">
{open && (
<div className="debug-panel">
{groups.map((g) => (
<React.Fragment key={g.title}>
<div
style={{
fontFamily: "var(--font-mono)",
fontSize: 9.5,
color: "var(--fg-faint)",
letterSpacing: "0.14em",
textTransform: "uppercase",
padding: "8px 8px 4px",
}}
>
{g.title}
</div>
{g.rows.map((r) => (
<button
key={r.label}
type="button"
className={"debug-row" + (r.active ? " active" : "")}
onClick={r.go}
>
{r.active && <b> </b>}
{r.label}
</button>
))}
</React.Fragment>
))}
<button
type="button"
className="debug-row"
onClick={() => setOpen(false)}
style={{
marginTop: 8,
justifyContent: "center",
color: "var(--fg-mute)",
}}
>
Close
</button>
</div>
)}
<button
type="button"
className="debug-toggle"
onClick={() => setOpen((o) => !o)}
title="Designer navigator"
>
<span style={{ color: "var(--accent)", marginRight: 6 }}></span>
{stage === "path" ? `${path} · step ${step + 1}` : stage}
</button>
</div>
);
}
// ── Name Your Workspace ─────────────────────────────────────────────────────
// Final step on the Self-Builder / Personal path. Confirm (or edit) the
// workspace name — pre-filled from what they already told us — then create the
// workspace and drop them into their dashboard.
function NameWorkspaceScreen({ defaultName, onSubmit, onClose, resolving }) {
const [name, setName] = React.useState(defaultName || "");
const trimmed = name.trim();
const slug = trimmed
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 32);
if (resolving) {
return (
<>
<WizardTop onBack={null} onClose={onClose} stepText="Creating" />
<WizardBody>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 16,
padding: "44px 0",
}}
>
<span className="spinner" style={{ width: 30, height: 30 }}>
<span className="spinner-line" />
</span>
<div style={{ textAlign: "center" }}>
<div
style={{ fontSize: 15, color: "var(--fg)", fontWeight: 500 }}
>
Creating your workspace
</div>
<div
className="mono"
style={{
marginTop: 6,
fontSize: 12.5,
color: "var(--accent)",
}}
>
Provisioning your server, repos & dashboard
</div>
</div>
</div>
</WizardBody>
</>
);
}
return (
<>
<WizardTop
onBack={null}
onClose={onClose}
stepText="Name your workspace"
/>
<WizardBody>
<WizardQ
title="Name your workspace"
sub="This is your home base. You can rename it later."
/>
<Field label="Workspace name">
<input
type="text"
className="wiz-input"
placeholder="My Workspace"
value={name}
autoFocus
maxLength={48}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && trimmed) onSubmit(trimmed);
}}
/>
</Field>
{slug && (
<div
className="mono"
style={{ marginTop: 4, fontSize: 12.5, color: "var(--fg-mute)" }}
>
vibnai.com/<span style={{ color: "var(--accent)" }}>{slug}</span>
</div>
)}
<button
type="button"
className="btn btn-primary btn-wiz"
style={{ marginTop: 22, width: "100%" }}
disabled={!trimmed}
onClick={() => onSubmit(trimmed)}
>
Create my workspace
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 8h10M9 4l4 4-4 4" />
</svg>
</button>
</WizardBody>
</>
);
}
// ── Front door ─────────────────────────────────────────────────────────────
// The very first choice. Motivations are opposite, so the openings diverge:
// consultants set up an agency; self-builders go straight to build.
function DoorCard({
emphasized,
icon,
title,
sub,
onClick,
}: {
emphasized?: boolean;
icon: React.ReactNode;
title: React.ReactNode;
sub: React.ReactNode;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
style={{
display: "flex",
alignItems: "center",
gap: 14,
padding: "16px 18px",
borderRadius: 12,
textAlign: "left",
cursor: "pointer",
color: "var(--fg)",
border: `1px solid ${emphasized ? "var(--accent)" : "var(--hairline)"}`,
background: emphasized ? "var(--accent-soft)" : "var(--bg-1)",
transition: "border-color .15s, background .15s, transform .1s",
}}
>
<span
style={{
width: 38,
height: 38,
flexShrink: 0,
borderRadius: 10,
display: "grid",
placeItems: "center",
background: emphasized ? "var(--accent)" : "var(--bg-2)",
color: emphasized ? "var(--accent-fg)" : "var(--fg-mute)",
}}
>
{icon}
</span>
<span style={{ flex: 1, minWidth: 0 }}>
<span
style={{
display: "block",
fontSize: 15,
fontWeight: 600,
letterSpacing: "-0.01em",
}}
>
{title}
</span>
<span
style={{
display: "block",
marginTop: 3,
fontSize: 13,
color: "var(--fg-mute)",
lineHeight: 1.45,
}}
>
{sub}
</span>
</span>
<svg
width="15"
height="15"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
style={{ color: "var(--fg-faint)" }}
>
<path d="M3 8h10M9 4l4 4-4 4" />
</svg>
</button>
);
}
function DoorScreen({ onAgency, onSelf, onClose }) {
return (
<>
<WizardTop onBack={null} onClose={onClose} stepText="Get started" />
<WizardBody>
<WizardQ
title="What brings you to Vibn?"
sub="This sets up the right workspace for you. You can do both later."
/>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<DoorCard
onClick={onSelf}
title="Personal"
sub={
<>
<span>I want to build my own ideas</span>
<span
style={{
display: "block",
marginTop: 8,
fontSize: 12,
color: "var(--fg-faint)",
lineHeight: 1.4,
}}
>
Go from idea to market, and beyond.
</span>
</>
}
icon={
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="9" cy="9" r="3" />
<path d="M9 2.5v2M9 13.5v2M2.5 9h2M13.5 9h2" />
</svg>
}
/>
<DoorCard
onClick={onAgency}
title="Agency"
sub={
<>
<span>I want to do billable AI work for others</span>
<span
style={{
display: "block",
marginTop: 8,
fontSize: 12,
color: "var(--fg-faint)",
lineHeight: 1.4,
}}
>
VIBN will help you find local businesses that you can build
custom solutions for
</span>
</>
}
icon={
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M2.5 15 9 3l6.5 12" />
<path d="M5.5 12h7" />
</svg>
}
/>
</div>
</WizardBody>
</>
);
}

View File

@@ -1,5 +1,5 @@
import { Nav, Footer } from "@/marketing/new-site";
import "../styles/new-site.css";
import { Nav, Footer } from "../new-site";
import "../../styles/new-site.css";
export const metadata = {
title: "Vibn — Our Mission",

View File

@@ -2354,7 +2354,7 @@ function Closing() {
<div className="closing-cta">
<div className="row">
<a href="/auth?new=1" className="btn btn-primary">
<a href="/signup" className="btn btn-primary">
Sign up <Arrow />
</a>
<a href="#how" className="btn btn-ghost">
@@ -2572,13 +2572,13 @@ export function Nav({ scrolled = false }: { scrolled?: boolean }) {
</div>
<div className="nav-cta">
<a
href="/auth"
href="/signin"
className="btn btn-ghost"
style={{ display: "inline-flex" }}
>
Sign in
</a>
<a href="/auth?new=1" className="btn btn-primary">
<a href="/signup" className="btn btn-primary">
Sign up <Arrow size={12} />
</a>
</div>
@@ -2620,7 +2620,7 @@ function LaunchModal({ prompt, onClose }) {
useEffect(() => {
if (step < 4) return undefined;
if (redirectCount <= 0) {
window.location.href = "/auth";
window.location.href = "/signup";
return undefined;
}
const t = setTimeout(() => setRedirectCount(redirectCount - 1), 1000);
@@ -2727,7 +2727,7 @@ function LaunchModal({ prompt, onClose }) {
}}
>
<a
href="/auth"
href="/signup"
className="btn btn-primary"
style={{
width: "100%",

View File

@@ -1,5 +1,5 @@
import NewSite from "@/marketing/new-site";
import "./styles/new-site.css";
import NewSite from "./new-site";
import "../styles/new-site.css";
export const metadata = {
title: "Vibn — Keep vibing. All the way to launch.",

16
app/[workspace]/page.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { redirect } from "next/navigation";
/**
* Workspace root (`/{workspace}`). There's no standalone dashboard here — the
* workspace landing is the projects list. Onboarding and the post-auth flow
* both send users to `/{slug}`, so this redirect is what makes that land
* somewhere real instead of a 404.
*/
export default async function WorkspaceIndex({
params,
}: {
params: Promise<{ workspace: string }>;
}) {
const { workspace } = await params;
redirect(`/${workspace}/projects`);
}

View File

@@ -0,0 +1,61 @@
import { NextResponse } from "next/server";
import { queryOne } from "@/lib/db-postgres";
import {
verifyPassword,
createDbSession,
SESSION_COOKIE_NAME,
sessionCookieOptions,
} from "@/lib/auth/password";
// POST /api/auth/login { email, password }
// Verifies the scrypt hash stored on the user's fs_users row and, on success,
// creates a database session + sets the session cookie. Google-only accounts
// have no password hash, so they fall through to the generic invalid message.
export async function POST(request: Request) {
let body: { email?: unknown; password?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
}
const email = String(body.email ?? "").trim().toLowerCase();
const password = String(body.password ?? "");
if (!email || !password) {
return NextResponse.json(
{ error: "Enter your email and password." },
{ status: 400 },
);
}
const invalid = NextResponse.json(
{ error: "Invalid email or password." },
{ status: 401 },
);
try {
const row = await queryOne<{ user_id: string; hash: string | null }>(
`SELECT user_id, data->>'passwordHash' AS hash
FROM fs_users
WHERE lower(data->>'email') = $1
LIMIT 1`,
[email],
);
if (!row || !row.hash) return invalid;
const ok = await verifyPassword(password, row.hash);
if (!ok) return invalid;
const { token, expires } = await createDbSession(row.user_id);
const res = NextResponse.json({ ok: true });
res.cookies.set(SESSION_COOKIE_NAME, token, sessionCookieOptions(expires));
return res;
} catch (err) {
console.error("[login] exception:", err);
return NextResponse.json(
{ error: "Could not sign you in. Please try again." },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,100 @@
import { NextResponse } from "next/server";
import { randomUUID } from "crypto";
import { query, queryOne } from "@/lib/db-postgres";
import { ensureWorkspaceForUser } from "@/lib/workspaces";
import {
hashPassword,
createDbSession,
SESSION_COOKIE_NAME,
sessionCookieOptions,
} from "@/lib/auth/password";
// POST /api/auth/register { email, password, name? }
// Creates an email/password account: a NextAuth `users` row, the custom
// `fs_users` row (with the scrypt password hash + workspace metadata, mirroring
// the Google signIn callback), a Vibn workspace, and a database session — then
// sets the session cookie so the user is signed in immediately.
export async function POST(request: Request) {
let body: { email?: unknown; password?: unknown; name?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
}
const email = String(body.email ?? "").trim().toLowerCase();
const password = String(body.password ?? "");
const name = body.name ? String(body.name).trim() : null;
if (!/^\S+@\S+\.\S+$/.test(email)) {
return NextResponse.json(
{ error: "Enter a valid email address." },
{ status: 400 },
);
}
if (password.length < 8) {
return NextResponse.json(
{ error: "Password must be at least 8 characters." },
{ status: 400 },
);
}
try {
const existing = await queryOne<{ id: string }>(
`SELECT id FROM users WHERE lower(email) = $1 LIMIT 1`,
[email],
);
if (existing) {
return NextResponse.json(
{ error: "An account with this email already exists. Try signing in." },
{ status: 409 },
);
}
// 1. NextAuth user row (id is normally a cuid; any unique string works).
const userId = randomUUID();
await query(
`INSERT INTO users (id, name, email, email_verified)
VALUES ($1, $2, $3, NOW())`,
[userId, name, email],
);
// 2. Custom fs_users row — same shape the Google signIn callback writes,
// plus the password hash.
const workspace =
email.split("@")[0].replace(/[^a-z0-9]+/g, "-") + "-account";
const passwordHash = await hashPassword(password);
const data = JSON.stringify({ email, name, image: null, workspace, passwordHash });
const fsRows = await query<{ id: string }>(
`INSERT INTO fs_users (id, user_id, data)
VALUES (gen_random_uuid()::text, $1, $2::jsonb)
RETURNING id`,
[userId, data],
);
const fsUserId = fsRows[0].id;
// 3. Ensure a Vibn workspace (no Coolify/Gitea provisioning yet — happens
// lazily on first project create, same as the OAuth path).
try {
await ensureWorkspaceForUser({
userId: fsUserId,
email,
displayName: name,
});
} catch (wsErr) {
console.error("[register] ensureWorkspaceForUser failed:", wsErr);
}
// 4. Sign them in: create a DB session + set the cookie.
const { token, expires } = await createDbSession(userId);
const res = NextResponse.json({ ok: true });
res.cookies.set(SESSION_COOKIE_NAME, token, sessionCookieOptions(expires));
return res;
} catch (err) {
console.error("[register] exception:", err);
return NextResponse.json(
{ error: "Could not create your account. Please try again." },
{ status: 500 },
);
}
}

View File

@@ -15,13 +15,19 @@
import { NextResponse } from "next/server";
import { authSession } from "@/lib/auth/session-server";
import {
exchangeCodeForToken, getAuthenticatedUser, persistGithubIntegration,
exchangeCodeForToken,
getAuthenticatedUser,
persistGithubIntegration,
isGithubOauthConfigured,
} from "@/lib/integrations/github";
const STATE_COOKIE = "gh_oauth_state";
function bounce(origin: string, returnTo: string, params: Record<string, string>): NextResponse {
function bounce(
origin: string,
returnTo: string,
params: Record<string, string>,
): NextResponse {
const dest = new URL(returnTo.startsWith("/") ? returnTo : "/", origin);
for (const [k, v] of Object.entries(params)) dest.searchParams.set(k, v);
const res = NextResponse.redirect(dest);
@@ -35,40 +41,56 @@ export async function GET(req: Request) {
const origin = (process.env.NEXTAUTH_URL ?? url.origin).replace(/\/$/, "");
// Recover the original target from the state cookie *before* any error path.
const cookieState = req.headers.get("cookie")
?.split(";").map(c => c.trim())
.find(c => c.startsWith(`${STATE_COOKIE}=`))
?.split("=")[1] ?? "";
const [storedState, storedReturnTo = "/"] = decodeURIComponent(cookieState).split(":");
const cookieState =
req.headers
.get("cookie")
?.split(";")
.map((c) => c.trim())
.find((c) => c.startsWith(`${STATE_COOKIE}=`))
?.split("=")[1] ?? "";
const [storedState, storedReturnTo = "/"] =
decodeURIComponent(cookieState).split(":");
if (!isGithubOauthConfigured()) {
return bounce(origin, storedReturnTo, { gh_error: "GitHub OAuth not configured" });
return bounce(origin, storedReturnTo, {
gh_error: "GitHub OAuth not configured",
});
}
const session = await authSession();
if (!session?.user?.email) {
return bounce(origin, "/auth", { gh_error: "Sign in first" });
return bounce(origin, "/signin", { gh_error: "Sign in first" });
}
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const errParam = url.searchParams.get("error_description") ?? url.searchParams.get("error");
const errParam =
url.searchParams.get("error_description") ?? url.searchParams.get("error");
if (errParam) {
return bounce(origin, storedReturnTo, { gh_error: errParam });
}
if (!code || !state) {
return bounce(origin, storedReturnTo, { gh_error: "Missing code or state" });
return bounce(origin, storedReturnTo, {
gh_error: "Missing code or state",
});
}
if (!storedState || storedState !== state) {
return bounce(origin, storedReturnTo, { gh_error: "State mismatch (try again)" });
return bounce(origin, storedReturnTo, {
gh_error: "State mismatch (try again)",
});
}
try {
const callbackUrl = `${origin}/api/integrations/github/callback`;
const tok = await exchangeCodeForToken(code, callbackUrl);
const me = await getAuthenticatedUser(tok.accessToken);
await persistGithubIntegration(session.user.email, me.login, tok.accessToken, tok.scope);
await persistGithubIntegration(
session.user.email,
me.login,
tok.accessToken,
tok.scope,
);
return bounce(origin, storedReturnTo, { gh_connected: me.login });
} catch (err) {
const msg = err instanceof Error ? err.message : "GitHub connect failed";

View File

@@ -3,11 +3,16 @@ import { authSession } from "../../../lib/auth/session-server";
import { query, queryOne } from "../../../lib/db-postgres";
import {
ensureWorkspaceProvisioned,
getWorkspaceByOwner,
type VibnWorkspace,
} from "../../../lib/workspaces";
// Generates a URL-safe slug from a business name, ensuring uniqueness in the database.
async function generateUniqueSlug(name: string): Promise<string> {
// URL-safe, unique slug from a name. Optionally exclude a workspace id so a
// workspace can keep/rename to a slug it already owns.
async function generateUniqueSlug(
name: string,
excludeWorkspaceId?: string,
): Promise<string> {
const base =
name
.toLowerCase()
@@ -17,10 +22,15 @@ async function generateUniqueSlug(name: string): Promise<string> {
let slug = base;
let count = 0;
while (true) {
const existing = await queryOne(
"SELECT id FROM vibn_workspaces WHERE slug = $1 LIMIT 1",
[slug],
);
const existing = excludeWorkspaceId
? await queryOne(
"SELECT id FROM vibn_workspaces WHERE slug = $1 AND id <> $2 LIMIT 1",
[slug, excludeWorkspaceId],
)
: await queryOne(
"SELECT id FROM vibn_workspaces WHERE slug = $1 LIMIT 1",
[slug],
);
if (!existing) return slug;
count++;
slug = `${base}-${count}`;
@@ -28,8 +38,11 @@ async function generateUniqueSlug(name: string): Promise<string> {
}
// POST /api/onboarding
// Saves ALL onboarding choices (Agency or Personal) to the PostgreSQL database,
// creates the workspace/tenant, links the user as owner, and triggers async provisioning.
// Finalises onboarding. Every signed-in user already has a workspace (created
// lazily at sign-in by ensureWorkspaceForUser), so this RENAMES that workspace
// to the name chosen in onboarding rather than creating a second one. Onboarding
// answers are stashed on the user's fs_users row. Returns the workspace slug so
// the client can redirect straight into it.
export async function POST(request: Request) {
const session = await authSession();
if (!session?.user?.email) {
@@ -38,9 +51,10 @@ export async function POST(request: Request) {
try {
const payload = await request.json();
const { isAgency, profile, expertise, tools, data } = payload;
const { isAgency, profile, expertise, tools, data, workspaceName } =
payload;
// 1. Resolve User ID from email
// 1. Resolve the user (fs_users.id is the workspace owner id).
const userRow = await queryOne<{ id: string }>(
"SELECT id FROM fs_users WHERE data->>'email' = $1 LIMIT 1",
[session.user.email],
@@ -50,50 +64,78 @@ export async function POST(request: Request) {
}
const userId = userRow.id;
// 2. Determine business name & create unique slug
const businessName = isAgency
? profile?.name || "My Agency"
: data?.bizName || "My Workspace";
const slug = await generateUniqueSlug(businessName);
// 2. Determine the workspace name. An explicit name from the
// "Name your workspace" step wins; otherwise fall back to what the flow
// already collected.
const explicitName =
typeof workspaceName === "string" ? workspaceName.trim() : "";
const businessName =
explicitName ||
(isAgency ? profile?.name : data?.bizName) ||
"My Workspace";
// 3. Assemble GTM Metadata block to store in JSONB
const onboardingMetadata = isAgency
? {
isAgency: true,
city: profile?.city,
hasWebsite: profile?.hasWebsite,
websiteUrl: profile?.websiteUrl,
hasSocials: profile?.hasSocials,
hasBlog: profile?.hasBlog,
hasCustomDomain: profile?.hasCustomDomain,
hasExistingClients: profile?.hasExistingClients,
expertise,
tools,
}
: {
isAgency: false,
city: data?.bizCity,
websiteUrl: data?.bizWebsite,
bizType: data?.biz,
tools: data?.tools,
theme: data?.theme || "minimal",
template: data?.template || "crm",
buildDesc: data?.buildDesc,
};
// 3. Stash onboarding answers on the user (vibn_workspaces has no `data`
// column; fs_users does). Non-fatal.
// Persist EVERYTHING the flow collected (the full raw payload), not a
// curated subset, so nothing the user chose is lost.
const onboardingMetadata = {
isAgency: !!isAgency,
workspaceName: businessName,
completedAt: new Date().toISOString(),
...(isAgency
? {
profile: profile ?? null,
expertise: expertise ?? null,
tools: tools ?? null,
}
: { data: data ?? null }),
};
try {
await query(
"UPDATE fs_users SET data = data || $2::jsonb, updated_at = NOW() WHERE id = $1",
[
userId,
JSON.stringify({
onboardingComplete: true,
onboarding: onboardingMetadata,
}),
],
);
} catch (metaErr) {
console.error("Onboarding metadata save failed (non-fatal):", metaErr);
}
// 4. Insert Workspace Row (logical multi-tenancy)
const insertedWorkspaces = await query<VibnWorkspace>(
`INSERT INTO vibn_workspaces (slug, name, owner_user_id, data, provision_status)
VALUES ($1, $2, $3, $4, 'pending')
RETURNING *`,
[
slug,
businessName,
userId,
JSON.stringify({ onboarding: onboardingMetadata }),
],
);
const workspace = insertedWorkspaces[0];
// 4. Rename the user's existing workspace, or create one if (unexpectedly)
// none exists yet.
let workspace: VibnWorkspace | undefined;
const existing = await getWorkspaceByOwner(userId);
if (existing) {
const slug = await generateUniqueSlug(businessName, existing.id);
const rows = await query<VibnWorkspace>(
`UPDATE vibn_workspaces
SET name = $2, slug = $3, updated_at = NOW()
WHERE id = $1
RETURNING *`,
[existing.id, businessName, slug],
);
workspace = rows[0];
} else {
const slug = await generateUniqueSlug(businessName);
const rows = await query<VibnWorkspace>(
`INSERT INTO vibn_workspaces (slug, name, owner_user_id)
VALUES ($1, $2, $3)
RETURNING *`,
[slug, businessName, userId],
);
workspace = rows[0];
await query(
`INSERT INTO vibn_workspace_members (workspace_id, user_id, role)
VALUES ($1, $2, 'owner')
ON CONFLICT (workspace_id, user_id) DO NOTHING`,
[workspace.id, userId],
);
}
if (!workspace) {
return NextResponse.json(
@@ -102,15 +144,7 @@ export async function POST(request: Request) {
);
}
// 5. Insert Workspace Member Row (link user as Owner)
await query(
`INSERT INTO vibn_workspace_members (workspace_id, user_id, role)
VALUES ($1, $2, 'owner')`,
[workspace.id, userId],
);
// 6. Trigger Async Tenant Provisioning (Coolify Project boundaries + Gitea org)
// Runs in the background so the user's isolated fleet stands up instantly.
// 5. Kick off provisioning in the background (Coolify project + Gitea org).
try {
ensureWorkspaceProvisioned(workspace).catch((err: unknown) => {
console.error("Background workspace provisioning failed:", err);
@@ -119,8 +153,7 @@ export async function POST(request: Request) {
console.error("Failed to kick off provisioning:", e);
}
// Return the workspace slug so the frontend can redirect they immediately!
return NextResponse.json({ success: true, slug });
return NextResponse.json({ success: true, slug: workspace.slug });
} catch (err) {
console.error("Onboarding GTM save exception:", err);
return NextResponse.json(

View File

@@ -39,13 +39,15 @@ export async function GET(request: Request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userRow = await queryOne<{ id: string }>(
`SELECT id FROM fs_users WHERE data->>'email' = $1 LIMIT 1`,
const userRow = await queryOne<{ id: string; onboarded: string | null }>(
`SELECT id, data->>'onboardingComplete' AS onboarded
FROM fs_users WHERE data->>'email' = $1 LIMIT 1`,
[session.user.email],
);
if (!userRow) {
return NextResponse.json({ workspaces: [] });
return NextResponse.json({ workspaces: [], onboarded: false });
}
const onboarded = userRow.onboarded === "true";
// Migration path: users who signed in before the signIn hook was
// added (or before vibn_workspaces existed) have no row yet. Create
@@ -104,10 +106,14 @@ export async function GET(request: Request) {
return NextResponse.json({
workspaces: list.map(serializeWorkspace),
defaultToken,
onboarded,
});
}
return NextResponse.json({ workspaces: list.map(serializeWorkspace) });
return NextResponse.json({
workspaces: list.map(serializeWorkspace),
onboarded,
});
}
function serializeWorkspace(w: import("@/lib/workspaces").VibnWorkspace) {

View File

@@ -1,22 +0,0 @@
import type { Metadata } from "next";
import { Toaster } from "sonner";
import "../styles/new-site.css";
export const metadata: Metadata = {
title: "Vibn — Sign In",
};
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="new-site-wrapper" style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<main style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '40px 24px' }}>
{children}
</main>
<Toaster />
</div>
);
}

View File

@@ -1,232 +1,26 @@
"use client";
import { redirect } from "next/navigation";
import { useSession } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useEffect, Suspense } from "react";
import NextAuthComponent from "@/app/components/NextAuthComponent";
/**
* Legacy `/auth` route — kept as a redirect for back-compat. NextAuth's
* `pages.signIn`, the VibnCode desktop `vibncode://` SSO deep-link, and any old
* links still point here. `?new=1` maps to /signup; everything else (including
* `?vibncode=true`) carries over to /signin.
*/
export default async function AuthRedirect({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const sp = (await searchParams) ?? {};
const isNew = sp.new !== undefined;
import "../styles/new-site.css";
function deriveWorkspace(email: string): string {
return (
email
.split("@")[0]
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-") + "-account"
);
}
function AuthPageInner() {
const { data: session, status } = useSession();
const router = useRouter();
const searchParams = useSearchParams();
const [ssoProcessing, setSsoProcessing] = React.useState(false);
const [ssoToken, setSsoToken] = React.useState<string | null>(null);
useEffect(() => {
if (status === "authenticated" && session?.user?.email) {
const isVibnCodeSSO = searchParams?.get("vibncode") === "true";
if (isVibnCodeSSO) {
setSsoProcessing(true);
// Call our new secure token endpoint
fetch("/api/auth/token")
.then((r) => r.json())
.then((data) => {
if (data.token) {
setSsoToken(data.token);
// Deep-link redirect back to the VibnCode desktop app
window.location.href = `vibncode://auth/callback?token=${data.token}`;
} else {
console.error("SSO Token missing from response", data);
setSsoProcessing(false);
}
})
.catch((err) => {
console.error("Desktop SSO failed:", err);
setSsoProcessing(false);
});
return;
}
const workspace = deriveWorkspace(session.user.email);
// Check if user has projects. If 0, go to onboarding, else go to projects.
fetch("/api/projects")
.then((r) => r.json())
.then((d) => {
if (d.projects && d.projects.length > 0) {
router.push(`/${workspace}/projects`);
} else {
router.push(`/onboarding`);
}
})
.catch(() => router.push(`/${workspace}/projects`));
}
}, [status, session, router, searchParams]);
if (status === "loading" || ssoProcessing) {
const deepLink = ssoToken ? `vibncode://auth/callback?token=${ssoToken}` : "";
return (
<div
className="new-site-wrapper"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
background: "radial-gradient(circle at 20% 20%, #1c1c1f, #0b0b0f 60%)",
}}
>
{ssoToken ? (
<div
style={{
border: "1px solid rgba(255, 255, 255, 0.08)",
background: "rgba(12, 12, 16, 0.85)",
borderRadius: "20px",
padding: "32px",
boxShadow: "0 18px 50px rgba(0, 0, 0, 0.35)",
backdropFilter: "blur(16px)",
textAlign: "center",
width: "min(480px, 90vw)",
color: "#f5f5f5",
fontFamily: "-apple-system, sans-serif",
}}
>
<div
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: "56px",
height: "56px",
borderRadius: "50%",
border: "1px solid rgba(255, 255, 255, 0.12)",
background: "linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.02))",
fontSize: "28px",
marginBottom: "20px",
}}
>
</div>
<h1 style={{ margin: "0 0 12px", fontSize: "24px", fontWeight: "600", color: "#f8f8f8" }}>
Authentication Successful
</h1>
<p style={{ margin: "0 0 24px", color: "#cfcfd4", fontSize: "14px" }}>
Signed in. Redirecting to VibnCode...
</p>
<div
style={{
margin: "0 auto 20px",
width: "44px",
height: "44px",
borderRadius: "50%",
border: "4px solid rgba(255, 255, 255, 0.15)",
borderTopColor: "#ffffff",
animation: "spin 1s linear infinite",
}}
/>
<style>{`
@keyframes spin { to { transform: rotate(360deg); } }
`}</style>
<p style={{ margin: "0 0 16px", color: "#b6b6bd", lineHeight: "1.6", fontSize: "13px" }}>
If the app doesn't open automatically, copy your Workspace API Key below and paste it into the connection card.
</p>
<div style={{ display: "flex", gap: "10px", justifyContent: "center", marginBottom: "16px" }}>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(ssoToken);
alert("Workspace API Key copied!");
}}
style={{
padding: "10px 16px",
borderRadius: "999px",
border: "1px solid rgba(255, 255, 255, 0.18)",
background: "rgba(255, 255, 255, 0.12)",
color: "#ffffff",
cursor: "pointer",
fontSize: "13px",
fontWeight: "500",
}}
>
Copy Workspace Key
</button>
</div>
<div
style={{
padding: "12px",
borderRadius: "12px",
background: "rgba(255, 255, 255, 0.06)",
fontFamily: "monospace",
fontSize: "12px",
wordBreak: "break-all",
color: "#d8d8df",
}}
>
{ssoToken}
</div>
</div>
) : (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "16px",
}}
>
<div
style={{
width: "24px",
height: "24px",
borderRadius: "50%",
border: "2px solid oklch(0.20 0.009 60)",
borderTopColor: "var(--accent)",
animation: "spin .9s linear infinite",
}}
/>
<style>{`
@keyframes spin { to { transform: rotate(360deg); } }
`}</style>
<div
style={{
color: "var(--fg-mute)",
fontFamily: "var(--font-mono)",
fontSize: "11px",
letterSpacing: "0.1em",
textTransform: "uppercase",
}}
>
Checking session
</div>
</div>
)}
</div>
);
const params = new URLSearchParams();
for (const [key, value] of Object.entries(sp)) {
if (key === "new") continue;
if (typeof value === "string") params.set(key, value);
else if (Array.isArray(value) && value[0] != null)
params.set(key, value[0]);
}
return (
<div
className="new-site-wrapper"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
}}
>
<NextAuthComponent />
</div>
);
}
export default function AuthPage() {
return (
<Suspense>
<AuthPageInner />
</Suspense>
);
const qs = params.toString();
redirect(`${isNew ? "/signup" : "/signin"}${qs ? `?${qs}` : ""}`);
}

View File

@@ -1,18 +0,0 @@
"use client";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
// Dynamically import to avoid SSR issues
const SuperTokensComponentNoSSR = dynamic(
() => import("@/app/components/SuperTokensAuthComponent"),
{ ssr: false }
);
export default function AuthPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<SuperTokensComponentNoSSR />
</div>
);
}

View File

@@ -1,17 +0,0 @@
"use client";
import { signIn } from "next-auth/react";
import React from "react";
export default function NextAuthComponent() {
return (
<div style={{ display: "flex", flexDirection: "column", gap: "16px", alignItems: "center" }}>
<button
onClick={() => signIn("google")}
style={{ padding: "12px 24px", background: "var(--accent)", color: "#fff", borderRadius: "8px", fontWeight: "bold", border: "none", cursor: "pointer" }}
>
Sign in with Google
</button>
</div>
);
}

View File

@@ -0,0 +1,243 @@
"use client";
import { useSession } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useEffect, Suspense } from "react";
import AuthScreen from "./AuthScreen";
function AuthFlowInner({ mode }: { mode: "signin" | "signup" }) {
const { data: session, status } = useSession();
const router = useRouter();
const searchParams = useSearchParams();
const [ssoProcessing, setSsoProcessing] = React.useState(false);
const [ssoToken, setSsoToken] = React.useState<string | null>(null);
useEffect(() => {
if (status === "authenticated" && session?.user?.email) {
const isVibnCodeSSO = searchParams?.get("vibncode") === "true";
if (isVibnCodeSSO) {
setSsoProcessing(true);
// Call our secure token endpoint
fetch("/api/auth/token")
.then((r) => r.json())
.then((data) => {
if (data.token) {
setSsoToken(data.token);
// Deep-link redirect back to the VibnCode desktop app
window.location.href = `vibncode://auth/callback?token=${data.token}`;
} else {
console.error("SSO Token missing from response", data);
setSsoProcessing(false);
}
})
.catch((err) => {
console.error("Desktop SSO failed:", err);
setSsoProcessing(false);
});
return;
}
// Resolve the user's ACTUAL workspace slug (it can differ from their
// email — e.g. after they renamed it in onboarding) and decide where to
// land. Onboarding shows only once: gate on the onboardingComplete flag,
// with project count as a fallback for users who onboarded before the
// flag existed.
Promise.all([
fetch("/api/workspaces")
.then((r) => r.json())
.catch(() => ({})),
fetch("/api/projects")
.then((r) => r.json())
.catch(() => ({})),
]).then(([ws, proj]) => {
const slug: string | undefined = ws?.workspaces?.[0]?.slug;
const onboarded = ws?.onboarded === true;
const hasProjects =
Array.isArray(proj?.projects) && proj.projects.length > 0;
if (slug && (onboarded || hasProjects)) {
router.push(`/${slug}/projects`);
} else {
router.push("/onboarding");
}
});
}
}, [status, session, router, searchParams]);
if (status === "loading" || ssoProcessing) {
return (
<div
className="new-site-wrapper"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
background:
"radial-gradient(circle at 20% 20%, #1c1c1f, #0b0b0f 60%)",
}}
>
{ssoToken ? (
<div
style={{
border: "1px solid rgba(255, 255, 255, 0.08)",
background: "rgba(12, 12, 16, 0.85)",
borderRadius: "20px",
padding: "32px",
boxShadow: "0 18px 50px rgba(0, 0, 0, 0.35)",
backdropFilter: "blur(16px)",
textAlign: "center",
width: "min(480px, 90vw)",
color: "#f5f5f5",
fontFamily: "-apple-system, sans-serif",
}}
>
<div
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: "56px",
height: "56px",
borderRadius: "50%",
border: "1px solid rgba(255, 255, 255, 0.12)",
background:
"linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.02))",
fontSize: "28px",
marginBottom: "20px",
}}
>
</div>
<h1
style={{
margin: "0 0 12px",
fontSize: "24px",
fontWeight: "600",
color: "#f8f8f8",
}}
>
Authentication Successful
</h1>
<p
style={{ margin: "0 0 24px", color: "#cfcfd4", fontSize: "14px" }}
>
Signed in. Redirecting to VibnCode...
</p>
<div
style={{
margin: "0 auto 20px",
width: "44px",
height: "44px",
borderRadius: "50%",
border: "4px solid rgba(255, 255, 255, 0.15)",
borderTopColor: "#ffffff",
animation: "spin 1s linear infinite",
}}
/>
<style>{`
@keyframes spin { to { transform: rotate(360deg); } }
`}</style>
<p
style={{
margin: "0 0 16px",
color: "#b6b6bd",
lineHeight: "1.6",
fontSize: "13px",
}}
>
If the app doesn&apos;t open automatically, copy your Workspace
API Key below and paste it into the connection card.
</p>
<div
style={{
display: "flex",
gap: "10px",
justifyContent: "center",
marginBottom: "16px",
}}
>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(ssoToken);
alert("Workspace API Key copied!");
}}
style={{
padding: "10px 16px",
borderRadius: "999px",
border: "1px solid rgba(255, 255, 255, 0.18)",
background: "rgba(255, 255, 255, 0.12)",
color: "#ffffff",
cursor: "pointer",
fontSize: "13px",
fontWeight: "500",
}}
>
Copy Workspace Key
</button>
</div>
<div
style={{
padding: "12px",
borderRadius: "12px",
background: "rgba(255, 255, 255, 0.06)",
fontFamily: "monospace",
fontSize: "12px",
wordBreak: "break-all",
color: "#d8d8df",
}}
>
{ssoToken}
</div>
</div>
) : (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "16px",
}}
>
<div
style={{
width: "24px",
height: "24px",
borderRadius: "50%",
border: "2px solid oklch(0.20 0.009 60)",
borderTopColor: "var(--accent)",
animation: "spin .9s linear infinite",
}}
/>
<style>{`
@keyframes spin { to { transform: rotate(360deg); } }
`}</style>
<div
style={{
color: "var(--fg-mute)",
fontFamily: "var(--font-mono)",
fontSize: "11px",
letterSpacing: "0.1em",
textTransform: "uppercase",
}}
>
Checking session
</div>
</div>
)}
</div>
);
}
return <AuthScreen mode={mode} />;
}
export default function AuthFlow({ mode }: { mode: "signin" | "signup" }) {
return (
<Suspense>
<AuthFlowInner mode={mode} />
</Suspense>
);
}

View File

@@ -0,0 +1,381 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { signIn } from "next-auth/react";
/**
* Vibn sign-in / sign-up screen.
*
* Ported from design-templates/VIBN (2) (auth.css + signin/signup.jsx). Email +
* password is the primary action (custom endpoints at /api/auth/register and
* /api/auth/login that create real NextAuth database sessions); Google OAuth is
* offered below. Mode is driven by the `?new=1` query param.
*/
export default function AuthScreen({
mode = "signin",
}: {
mode?: "signin" | "signup";
}) {
const isSignup = mode === "signup";
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [googleLoading, setGoogleLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const emailValid = /\S+@\S+\.\S+/.test(email);
const canSubmit = emailValid && password.length >= 8 && !submitting;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!canSubmit) return;
setError(null);
setSubmitting(true);
try {
const endpoint = isSignup ? "/api/auth/register" : "/api/auth/login";
const res = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
isSignup
? { email, password, name: name.trim() || undefined }
: { email, password },
),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data.error || "Something went wrong. Please try again.");
setSubmitting(false);
return;
}
// Full reload so the session cookie is picked up and AuthFlow routes the
// user onward (new account -> /onboarding, returning -> dashboard).
window.location.href = "/signin";
} catch {
setError("Network error. Please try again.");
setSubmitting(false);
}
};
const handleGoogle = () => {
if (googleLoading) return;
setGoogleLoading(true);
const cb = typeof window !== "undefined" ? window.location.href : "/signin";
signIn("google", { callbackUrl: cb });
};
const trust = isSignup
? ["No credit card", "No homework", "🇨🇦 Built in Canada"]
: ["Built in Canada", "Your data stays safe", "No homework"];
return (
<div className="vibn-auth">
<div className="page">
<header className="topbar">
<Link href="/" className="logo" aria-label="Vibn home">
<span className="logo-mark">
<svg
viewBox="0 0 36 32"
width="74%"
height="74%"
fill="currentColor"
stroke="currentColor"
strokeWidth={1.2}
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M4 5 L10 5 L12 18 L14 5 L20 5 L12 27 Z" />
<rect
x="22.5"
y="23"
width="9.5"
height="3.8"
rx="0.7"
className="logo-caret"
/>
</svg>
</span>
<span>vibn</span>
</Link>
<Link href="/" className="topbar-back">
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<path
d="M13 8H3M7 4 3 8l4 4"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
Back to home
</Link>
</header>
<main className="auth-main">
<Glows />
<div className="auth-card">
<div className="auth-eye">
{isSignup ? "Get started" : "Welcome back"}
</div>
<h1 className="auth-title">
{isSignup ? (
<>
Create your <em>workspace</em>.
</>
) : (
<>
Sign in and <em>keep building</em>.
</>
)}
</h1>
<p className="auth-sub">
{isSignup
? "Set up your account with an email and password — you'll be building in seconds."
: "Pick up right where you left off."}
</p>
<form className="auth-form" onSubmit={handleSubmit} noValidate>
{isSignup && (
<div className="auth-field">
<label className="auth-label" htmlFor="name">
Name{" "}
<span
style={{
color: "var(--fg-faint)",
letterSpacing: 0,
textTransform: "none",
}}
>
(optional)
</span>
</label>
<input
id="name"
type="text"
autoComplete="name"
className="auth-input"
placeholder="First name or handle"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
)}
<div className="auth-field">
<label className="auth-label" htmlFor="email">
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
required
autoFocus={!isSignup}
className="auth-input"
placeholder="you@somewhere.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="password">
Password
</label>
<input
id="password"
type="password"
autoComplete={isSignup ? "new-password" : "current-password"}
required
minLength={8}
className="auth-input"
placeholder={
isSignup ? "At least 8 characters" : "Your password"
}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && <div className="auth-error">{error}</div>}
<button
type="submit"
className="auth-btn auth-btn-primary"
disabled={!canSubmit}
style={{ marginTop: 4 }}
>
{submitting ? (
<>
<span className="auth-spinner" />{" "}
{isSignup ? "Creating your workspace…" : "Signing in…"}
</>
) : isSignup ? (
<>
Create my workspace <Arrow size={13} />
</>
) : (
<>Sign in</>
)}
</button>
</form>
<div className="auth-divider">or continue with</div>
<div className="auth-oauth">
<button
type="button"
className="auth-btn auth-btn-ghost"
onClick={handleGoogle}
disabled={googleLoading}
>
{googleLoading ? (
<>
<span className="auth-spinner ghost" /> Connecting
</>
) : (
<>
<GoogleIcon />{" "}
{isSignup ? "Sign up with Google" : "Continue with Google"}
</>
)}
</button>
</div>
{isSignup && (
<p className="auth-fine">
By continuing you agree to our{" "}
<Link href="/mission">Terms</Link> and{" "}
<Link href="/mission">Privacy Policy</Link>.
</p>
)}
<div className="auth-foot">
{isSignup ? (
<>
Already have an account? <Link href="/signin">Sign in </Link>
</>
) : (
<>
New to Vibn? <Link href="/signup">Create an account </Link>
</>
)}
</div>
</div>
<TrustStrip items={trust} />
</main>
</div>
</div>
);
}
function Glows() {
return (
<>
<div
className="auth-glow"
style={{
width: 700,
height: 700,
top: -150,
left: "50%",
transform: "translateX(-50%)",
background:
"radial-gradient(circle at center, oklch(0.74 0.175 35 / 0.22) 0%, transparent 62%)",
}}
/>
<div
className="auth-glow"
style={{
width: 500,
height: 500,
bottom: -100,
left: 0,
background:
"radial-gradient(circle at center, oklch(0.45 0.10 35 / 0.20) 0%, transparent 62%)",
}}
/>
<div
className="auth-glow"
style={{
width: 450,
height: 450,
top: "50%",
right: -150,
background:
"radial-gradient(circle at center, oklch(0.45 0.10 35 / 0.15) 0%, transparent 62%)",
}}
/>
</>
);
}
function TrustStrip({ items }: { items: string[] }) {
return (
<div className="auth-trust">
{items.map((item, i) => (
<span
key={item}
style={{ display: "inline-flex", alignItems: "center", gap: 14 }}
>
{i > 0 && <span className="sep">·</span>}
<span>{item}</span>
</span>
))}
</div>
);
}
function Arrow({ size = 14 }: { size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<path
d="M3 8h10M9 4l4 4-4 4"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function GoogleIcon({ size = 17 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 18 18" aria-hidden="true">
<path
fill="#EA4335"
d="M9 3.6c1.3 0 2.5.5 3.4 1.3l2.5-2.5C13.4 1 11.3.1 9 .1 5.5.1 2.4 2.1.9 5.1l2.9 2.3C4.5 5.2 6.6 3.6 9 3.6Z"
/>
<path
fill="#34A853"
d="M17.6 9.2c0-.6-.1-1.2-.2-1.8H9v3.4h4.9c-.2 1.1-.9 2-1.9 2.6l2.9 2.3c1.7-1.6 2.7-3.9 2.7-6.5Z"
/>
<path
fill="#FBBC05"
d="M3.8 10.7c-.2-.6-.3-1.1-.3-1.7s.1-1.2.3-1.7L.9 5C.3 6.2 0 7.5 0 9s.3 2.8.9 4l2.9-2.3Z"
/>
<path
fill="#4285F4"
d="M9 17.9c2.4 0 4.4-.8 5.9-2.2l-2.9-2.3c-.8.5-1.8.9-3 .9-2.3 0-4.3-1.6-5-3.7L1.1 12.9C2.6 15.9 5.6 17.9 9 17.9Z"
/>
</svg>
);
}

View File

@@ -0,0 +1,467 @@
/* Vibn auth screens — ported from design-templates/VIBN (2)/auth.css.
Scoped under .vibn-auth so the dark theme + ambient grid/grain never leak
into the rest of the app (the dashboard is a light theme). Same tokens as
the marketing site. */
.vibn-auth {
--bg: oklch(0.155 0.008 60);
--bg-1: oklch(0.185 0.009 60);
--bg-2: oklch(0.225 0.01 60);
--hairline: oklch(0.32 0.01 60 / 0.55);
--hairline-2: oklch(0.4 0.012 60 / 0.35);
--fg: oklch(0.97 0.005 80);
--fg-dim: oklch(0.78 0.006 80);
--fg-mute: oklch(0.58 0.006 80);
--fg-faint: oklch(0.42 0.006 80);
--accent: oklch(0.74 0.175 35);
--accent-soft: oklch(0.74 0.175 35 / 0.18);
--accent-glow: oklch(0.74 0.175 35 / 0.35);
--accent-fg: #1a0f0a;
--ok: oklch(0.78 0.16 155);
--font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
position: relative;
min-height: 100dvh;
background: var(--bg);
color: var(--fg);
font-family: var(--font-sans);
line-height: 1.45;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow-x: hidden;
}
.vibn-auth * {
box-sizing: border-box;
}
/* Ambient grid */
.vibn-auth::before {
content: "";
position: fixed;
inset: 0;
background-image:
linear-gradient(
to right,
oklch(0.3 0.01 60 / 0.1) 1px,
transparent 1px
),
linear-gradient(
to bottom,
oklch(0.3 0.01 60 / 0.1) 1px,
transparent 1px
);
background-size: 56px 56px;
mask-image: radial-gradient(
ellipse 70% 70% at 50% 40%,
#000 30%,
transparent 80%
);
-webkit-mask-image: radial-gradient(
ellipse 70% 70% at 50% 40%,
#000 30%,
transparent 80%
);
pointer-events: none;
z-index: 0;
}
/* Film grain */
.vibn-auth::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 1;
opacity: 0.035;
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%25' height='100%25' filter='url(%23n)' opacity='0.85'/></svg>");
}
.vibn-auth a {
color: inherit;
text-decoration: none;
}
.vibn-auth button {
font: inherit;
color: inherit;
background: none;
border: 0;
padding: 0;
cursor: pointer;
}
.vibn-auth h1,
.vibn-auth h2,
.vibn-auth h3 {
margin: 0;
font-weight: 500;
letter-spacing: -0.02em;
line-height: 1.05;
}
.vibn-auth p {
margin: 0;
}
.vibn-auth ::selection {
background: var(--accent);
color: var(--accent-fg);
}
/* Layout */
.vibn-auth .page {
position: relative;
z-index: 2;
min-height: 100dvh;
display: flex;
flex-direction: column;
}
.vibn-auth .topbar {
position: relative;
z-index: 5;
padding: 22px clamp(20px, 4vw, 48px);
display: flex;
align-items: center;
justify-content: space-between;
}
.vibn-auth .topbar a:hover {
color: var(--fg);
}
.vibn-auth .topbar-back {
color: var(--fg-mute);
font-size: 14px;
display: inline-flex;
align-items: center;
gap: 6px;
}
.vibn-auth .logo {
display: inline-flex;
align-items: center;
gap: 9px;
font-weight: 600;
font-size: 17px;
letter-spacing: -0.02em;
color: var(--fg);
}
.vibn-auth .logo-mark {
width: 26px;
height: 26px;
border-radius: 50%;
background: linear-gradient(
135deg,
var(--accent) 0%,
oklch(0.65 0.2 18) 100%
);
box-shadow:
0 0 22px var(--accent-glow),
inset 0 1px 0 oklch(1 0 0 / 0.25);
display: grid;
place-items: center;
color: var(--accent-fg);
flex-shrink: 0;
}
.vibn-auth .logo-mark svg {
display: block;
}
.vibn-auth .logo-caret {
animation: vibn-auth-caret-blink 1.4s steps(2) infinite;
}
@keyframes vibn-auth-caret-blink {
50% {
opacity: 0.25;
}
}
/* Main */
.vibn-auth .auth-main {
flex: 1;
display: grid;
place-items: center;
padding: clamp(20px, 4vw, 40px);
position: relative;
}
/* Ambient glows */
.vibn-auth .auth-glow {
position: absolute;
pointer-events: none;
filter: blur(20px);
z-index: 0;
}
/* Card */
.vibn-auth .auth-card {
position: relative;
z-index: 2;
width: 100%;
max-width: 440px;
padding: 36px clamp(24px, 4vw, 40px) 32px;
background: linear-gradient(
180deg,
oklch(0.2 0.009 60 / 0.85),
oklch(0.17 0.008 60 / 0.85)
);
border: 1px solid var(--hairline);
border-radius: 22px;
backdrop-filter: blur(20px);
box-shadow:
0 30px 80px -20px oklch(0 0 0 / 0.7),
0 0 80px -30px var(--accent-glow);
}
.vibn-auth .auth-card::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
opacity: 0.6;
}
/* Header */
.vibn-auth .auth-eye {
display: inline-flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--fg-mute);
}
.vibn-auth .auth-eye::before {
content: "";
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 12px var(--accent-glow);
}
.vibn-auth .auth-title {
margin-top: 14px;
font-size: clamp(26px, 3.4vw, 34px);
font-weight: 500;
letter-spacing: -0.022em;
line-height: 1.1;
text-wrap: balance;
}
.vibn-auth .auth-title em {
font-style: normal;
color: var(--accent);
text-shadow: 0 0 30px var(--accent-glow);
}
.vibn-auth .auth-sub {
margin-top: 10px;
color: var(--fg-mute);
font-size: 14.5px;
line-height: 1.5;
text-wrap: balance;
}
/* Form */
.vibn-auth .auth-form {
margin-top: 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.vibn-auth .auth-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.vibn-auth .auth-label {
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--fg-mute);
padding-left: 4px;
}
.vibn-auth .auth-input {
width: 100%;
padding: 13px 16px;
background: oklch(0.16 0.008 60 / 0.8);
border: 1px solid var(--hairline);
border-radius: 12px;
color: var(--fg);
font: 15px/1.5 var(--font-sans);
outline: none;
transition:
border-color 0.15s,
background 0.15s,
box-shadow 0.15s;
}
.vibn-auth .auth-input::placeholder {
color: var(--fg-faint);
}
.vibn-auth .auth-input:focus {
border-color: oklch(0.74 0.175 35 / 0.65);
background: oklch(0.18 0.009 60 / 0.95);
box-shadow:
0 0 0 3px oklch(0.74 0.175 35 / 0.12),
0 0 30px -10px var(--accent-glow);
}
.vibn-auth .auth-error {
margin-top: 14px;
padding: 10px 14px;
border-radius: 10px;
background: oklch(0.62 0.2 25 / 0.12);
border: 1px solid oklch(0.62 0.2 25 / 0.4);
color: oklch(0.82 0.12 30);
font-size: 13px;
line-height: 1.45;
}
/* Buttons */
.vibn-auth .auth-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
height: 50px;
padding: 0 22px;
border-radius: 999px;
font-weight: 500;
font-size: 15px;
transition:
transform 0.12s,
box-shadow 0.2s,
background 0.2s;
white-space: nowrap;
width: 100%;
}
/* Primary — email + password submit. */
.vibn-auth .auth-btn-primary {
background: var(--accent);
color: var(--accent-fg);
box-shadow:
0 0 0 1px oklch(0.84 0.16 35 / 0.5) inset,
0 10px 40px -10px var(--accent-glow),
0 0 40px -8px var(--accent-glow);
}
.vibn-auth .auth-btn-primary:hover {
transform: translateY(-1px);
}
.vibn-auth .auth-btn-primary[disabled] {
opacity: 0.55;
cursor: not-allowed;
transform: none;
}
/* Ghost — OAuth alternatives below the divider. */
.vibn-auth .auth-btn-ghost {
background: oklch(0.2 0.009 60 / 0.6);
border: 1px solid var(--hairline);
color: var(--fg-dim);
}
.vibn-auth .auth-btn-ghost:hover {
color: var(--fg);
border-color: var(--hairline-2);
background: oklch(0.22 0.01 60 / 0.8);
}
.vibn-auth .auth-btn-ghost[disabled] {
opacity: 0.6;
cursor: not-allowed;
}
.vibn-auth .auth-btn-primary svg,
.vibn-auth .auth-btn-ghost svg {
flex-shrink: 0;
}
/* Divider */
.vibn-auth .auth-divider {
display: flex;
align-items: center;
gap: 14px;
margin: 18px 0 2px;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--fg-faint);
}
.vibn-auth .auth-divider::before,
.vibn-auth .auth-divider::after {
content: "";
flex: 1;
height: 1px;
background: var(--hairline);
}
/* OAuth row */
.vibn-auth .auth-oauth {
display: flex;
flex-direction: column;
gap: 10px;
}
/* Footer */
.vibn-auth .auth-foot {
margin-top: 26px;
padding-top: 22px;
border-top: 1px solid var(--hairline);
text-align: center;
font-size: 14px;
color: var(--fg-mute);
}
.vibn-auth .auth-foot a {
color: var(--accent);
font-weight: 500;
}
.vibn-auth .auth-foot a:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
.vibn-auth .auth-fine {
margin-top: 18px;
text-align: center;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.04em;
color: var(--fg-faint);
}
.vibn-auth .auth-fine a {
color: var(--fg-mute);
text-decoration: underline;
text-underline-offset: 3px;
}
/* Spinner */
.vibn-auth .auth-spinner {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid oklch(0 0 0 / 0.2);
border-top-color: var(--accent-fg);
animation: vibn-auth-spin 0.9s linear infinite;
}
.vibn-auth .auth-spinner.ghost {
border: 2px solid oklch(1 0 0 / 0.15);
border-top-color: var(--fg-dim);
}
@keyframes vibn-auth-spin {
to {
transform: rotate(360deg);
}
}
/* Trust strip */
.vibn-auth .auth-trust {
margin-top: 32px;
display: flex;
gap: 14px;
justify-content: center;
align-items: center;
flex-wrap: wrap;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.03em;
color: var(--fg-faint);
}
.vibn-auth .auth-trust .sep {
color: var(--fg-faint);
opacity: 0.5;
}

View File

@@ -1,685 +1,5 @@
"use client";
import React, { useState, useEffect, useMemo, Fragment } from "react";
import "./onboarding.css";
import { ForkScreen } from "./onboarding-fork";
import { EntrepreneurPath } from "./onboarding-entrepreneur";
import { OwnerPath } from "./onboarding-owner";
import { ConsultantPath } from "./onboarding-consultant";
import { BuildScreen } from "./onboarding-build";
import { ReadyScreen } from "./onboarding-build"; // Assuming ReadyScreen is exported from build
import { AgencyOnboarding } from "./onboarding-agency";
import { type AgencyOnboardingResult } from "./onboarding-agency-types";
import { WizardTop, WizardBody, WizardQ } from "./onboarding-primitives";
import OnboardingPage from "@/_onboarding/page";
// Root onboarding app — owns the route state and the answers dict.
// Routes: fork → <path> → build → ready. A floating debug navigator (toggle
// in the lower-right) lets reviewers jump between any screen without
// filling out the form.
export default function OnboardingApp() {
const initialName = React.useMemo(() => {
try {
return typeof window !== "undefined"
? localStorage.getItem("vibn:firstName") || ""
: "";
} catch {
return "";
}
}, []);
const [stage, setStage] = React.useState("door"); // door | agency | fork | path | choice | build | ready
const [path, setPath] = React.useState(null); // entrepreneur | owner | consultant
const [forkChoice, setForkChoice] = React.useState(null);
const [step, setStep] = React.useState(0);
const [data, setData] = React.useState<Record<string, unknown>>({});
const [createdSlug, setCreatedSlug] = React.useState<string | null>(null);
const [saving, setSaving] = React.useState(false);
const [debugOpen, setDebugOpen] = React.useState(false);
const update = (patch: Record<string, unknown>) =>
setData((d) => ({ ...d, ...patch }));
// ── GTM Onboarding database saving endpoints ────────────────────────────────
const saveOnboarding = async (
payload: Record<string, unknown>,
): Promise<string | null> => {
setSaving(true);
try {
const res = await fetch("/api/onboarding", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (res.ok) {
const bodyData = await res.json();
setCreatedSlug(bodyData.slug);
setSaving(false);
return bodyData.slug;
}
} catch (err) {
console.error("Failed to save onboarding selections:", err);
}
setSaving(false);
return null;
};
const finishAgency = async (result: AgencyOnboardingResult) => {
const slug = await saveOnboarding({
isAgency: true,
profile: result.profile,
expertise: result.expertise,
tools: result.tools,
});
if (slug && typeof window !== "undefined") {
window.location.href = "/" + slug;
}
};
const finishPersonal = async (choice: "workspace" | "build") => {
const slug = await saveOnboarding({ isAgency: false, data });
if (slug && typeof window !== "undefined") {
if (choice === "workspace") {
window.location.href = "/" + slug;
} else {
setStage("build");
}
}
};
// ── transitions ──────────────────────────────────────────────────────
const confirmFork = () => {
if (!forkChoice) return;
setPath(forkChoice);
setStep(0);
setStage("path");
};
const backToFork = () => {
setStage("fork");
setStep(0);
};
const completePath = () => setStage("choice");
const openWorkspace = () => {
if (createdSlug && typeof window !== "undefined") {
window.location.href = "/" + createdSlug; // Route directly to their live chat workspace!
} else {
setStage("ready");
}
};
const close = () => {
if (typeof window !== "undefined") window.location.href = "/";
};
const openChat = () => {
if (createdSlug && typeof window !== "undefined") {
window.location.href = "/" + createdSlug;
} else if (typeof window !== "undefined") {
window.location.href = "/";
}
};
const openAgency = () => setStage("agency");
const openSelf = () => {
setStage("fork");
setStep(0);
};
// ⌘↵ advances on whatever the current primary action is
React.useEffect(() => {
const handler = (e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
const btn = document.querySelector(
".btn-primary:not([disabled])",
) as HTMLElement;
if (btn) btn.click();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
// ── render ───────────────────────────────────────────────────────────
let body;
if (stage === "door") {
body = (
<DoorScreen onAgency={openAgency} onSelf={openSelf} onClose={close} />
);
} else if (stage === "agency") {
body = (
<AgencyOnboarding
onComplete={finishAgency}
onExit={close}
onBack={() => setStage("door")}
/>
);
} else if (stage === "fork") {
body = (
<ForkScreen
name={initialName}
value={forkChoice}
onChange={setForkChoice}
onClose={close}
onNext={confirmFork}
/>
);
} else if (stage === "path") {
const props = {
data,
onUpdate: update,
onBack: backToFork,
onClose: close,
onComplete: completePath,
onJumpToStep: setStep,
step,
};
if (path === "entrepreneur") body = <EntrepreneurPath {...props} />;
else if (path === "owner") body = <OwnerPath {...props} />;
else body = <ConsultantPath {...props} />;
} else if (stage === "choice") {
body = (
<ChoiceScreen
onWorkspace={() => finishPersonal("workspace")}
onBuild={() => finishPersonal("build")}
onClose={close}
resolving={saving}
/>
);
} else if (stage === "build") {
body = (
<BuildScreen
path={path}
data={data}
onBack={() => setStage("path")}
onClose={close}
onOpen={openWorkspace}
/>
);
} else {
body = (
<ReadyScreen
path={path}
data={data}
onClose={close}
onOpenChat={openChat}
/>
);
}
return (
<div className="app">
{body}
<DebugNav
open={debugOpen}
setOpen={setDebugOpen}
stage={stage}
path={path}
step={step}
onJump={(s, p, idx) => {
if (s === "fork") setStage("fork");
else if (s === "build") {
setPath(p);
setStage("build");
} else if (s === "ready") {
setPath(p);
setStage("ready");
} else {
setPath(p);
setStep(idx);
setStage("path");
}
}}
/>
</div>
);
}
// ── Debug navigator ──────────────────────────────────────────────────────
function DebugNav({ open, setOpen, stage, path, step, onJump }) {
const groups = [
{
title: "Start",
rows: [
{
label: "01 · Fork",
active: stage === "fork",
go: () => onJump("fork"),
},
],
},
{
title: "Entrepreneur",
rows: [
{
label: "02 · Idea",
active: stage === "path" && path === "entrepreneur" && step === 0,
go: () => onJump("path", "entrepreneur", 0),
},
{
label: "03 · Audience",
active: stage === "path" && path === "entrepreneur" && step === 1,
go: () => onJump("path", "entrepreneur", 1),
},
{
label: "04 · Goal",
active: stage === "path" && path === "entrepreneur" && step === 2,
go: () => onJump("path", "entrepreneur", 2),
},
{
label: "05 · Vibe",
active: stage === "path" && path === "entrepreneur" && step === 3,
go: () => onJump("path", "entrepreneur", 3),
},
],
},
{
title: "Owner",
rows: [
{
label: "02 · Business",
active: stage === "path" && path === "owner" && step === 0,
go: () => onJump("path", "owner", 0),
},
{
label: "03 · Stack",
active: stage === "path" && path === "owner" && step === 1,
go: () => onJump("path", "owner", 1),
},
{
label: "04 · First fix",
active: stage === "path" && path === "owner" && step === 2,
go: () => onJump("path", "owner", 2),
},
{
label: "05 · Scale",
active: stage === "path" && path === "owner" && step === 3,
go: () => onJump("path", "owner", 3),
},
],
},
{
title: "Consultant",
rows: [
{
label: "02 · Client",
active: stage === "path" && path === "consultant" && step === 0,
go: () => onJump("path", "consultant", 0),
},
{
label: "03 · Brief",
active: stage === "path" && path === "consultant" && step === 1,
go: () => onJump("path", "consultant", 1),
},
{
label: "04 · Scope",
active: stage === "path" && path === "consultant" && step === 2,
go: () => onJump("path", "consultant", 2),
},
{
label: "05 · Handoff",
active: stage === "path" && path === "consultant" && step === 3,
go: () => onJump("path", "consultant", 3),
},
],
},
{
title: "Finish",
rows: [
{
label: "Build · entrepreneur",
active: stage === "build" && path === "entrepreneur",
go: () => onJump("build", "entrepreneur"),
},
{
label: "Build · owner",
active: stage === "build" && path === "owner",
go: () => onJump("build", "owner"),
},
{
label: "Build · consultant",
active: stage === "build" && path === "consultant",
go: () => onJump("build", "consultant"),
},
{
label: "Ready",
active: stage === "ready",
go: () => onJump("ready", path || "entrepreneur"),
},
],
},
];
return (
<div className="debug">
{open && (
<div className="debug-panel">
{groups.map((g) => (
<React.Fragment key={g.title}>
<div
style={{
fontFamily: "var(--font-mono)",
fontSize: 9.5,
color: "var(--fg-faint)",
letterSpacing: "0.14em",
textTransform: "uppercase",
padding: "8px 8px 4px",
}}
>
{g.title}
</div>
{g.rows.map((r) => (
<button
key={r.label}
type="button"
className={"debug-row" + (r.active ? " active" : "")}
onClick={r.go}
>
{r.active && <b> </b>}
{r.label}
</button>
))}
</React.Fragment>
))}
<button
type="button"
className="debug-row"
onClick={() => setOpen(false)}
style={{
marginTop: 8,
justifyContent: "center",
color: "var(--fg-mute)",
}}
>
Close
</button>
</div>
)}
<button
type="button"
className="debug-toggle"
onClick={() => setOpen((o) => !o)}
title="Designer navigator"
>
<span style={{ color: "var(--accent)", marginRight: 6 }}></span>
{stage === "path" ? `${path} · step ${step + 1}` : stage}
</button>
</div>
);
}
// ── Transition Choice Screen ────────────────────────────────────────────────
// Displays after completing Step 3 (Design) on the Self-Builder / Personal path.
// Lets them choose whether they want to explore their workspace console first,
// or go straight into the co-founder build chat session.
function ChoiceScreen({ onWorkspace, onBuild, onClose, resolving }) {
if (resolving) {
return (
<>
<WizardTop onBack={null} onClose={onClose} stepText="Saving" />
<WizardBody>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 16,
padding: "44px 0",
}}
>
<span className="spinner" style={{ width: 30, height: 30 }}>
<span className="spinner-line" />
</span>
<div style={{ textAlign: "center" }}>
<div
style={{ fontSize: 15, color: "var(--fg)", fontWeight: 500 }}
>
Registering your workspace...
</div>
<div
className="mono"
style={{
marginTop: 6,
fontSize: 12.5,
color: "var(--accent)",
}}
>
Setting up Gitea & GMB pipelines on the server
</div>
</div>
</div>
</WizardBody>
</>
);
}
return (
<>
<WizardTop onBack={null} onClose={onClose} stepText="Launch option" />
<WizardBody>
<WizardQ
title="Where would you like to start?"
sub="Your workspace is fully mapped out. Choose how you want to dive in."
/>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<DoorCard
onClick={onWorkspace}
title="Go to my workspace"
sub="Explore your live dashboard, manage projects, and view your billing console."
icon={
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M3 3h12v12H3V3Zm6 0v12" />
</svg>
}
/>
<DoorCard
emphasized
onClick={onBuild}
title="Start working on my tool"
sub="Launch the AI co-founder build session and watch your custom tool scaffold in real-time."
icon={
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="m3 12 9-9 9 9" />
<path d="M5 10v10h14V10" />
</svg>
}
/>
</div>
</WizardBody>
</>
);
}
// ── Front door ─────────────────────────────────────────────────────────────
// The very first choice. Motivations are opposite, so the openings diverge:
// consultants set up an agency; self-builders go straight to build.
function DoorCard({
emphasized,
icon,
title,
sub,
onClick,
}: {
emphasized?: boolean;
icon: React.ReactNode;
title: React.ReactNode;
sub: React.ReactNode;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
style={{
display: "flex",
alignItems: "center",
gap: 14,
padding: "16px 18px",
borderRadius: 12,
textAlign: "left",
cursor: "pointer",
color: "var(--fg)",
border: `1px solid ${emphasized ? "var(--accent)" : "var(--hairline)"}`,
background: emphasized ? "var(--accent-soft)" : "var(--bg-1)",
transition: "border-color .15s, background .15s, transform .1s",
}}
>
<span
style={{
width: 38,
height: 38,
flexShrink: 0,
borderRadius: 10,
display: "grid",
placeItems: "center",
background: emphasized ? "var(--accent)" : "var(--bg-2)",
color: emphasized ? "var(--accent-fg)" : "var(--fg-mute)",
}}
>
{icon}
</span>
<span style={{ flex: 1, minWidth: 0 }}>
<span
style={{
display: "block",
fontSize: 15,
fontWeight: 600,
letterSpacing: "-0.01em",
}}
>
{title}
</span>
<span
style={{
display: "block",
marginTop: 3,
fontSize: 13,
color: "var(--fg-mute)",
lineHeight: 1.45,
}}
>
{sub}
</span>
</span>
<svg
width="15"
height="15"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
style={{ color: "var(--fg-faint)" }}
>
<path d="M3 8h10M9 4l4 4-4 4" />
</svg>
</button>
);
}
function DoorScreen({ onAgency, onSelf, onClose }) {
return (
<>
<WizardTop onBack={null} onClose={onClose} stepText="Get started" />
<WizardBody>
<WizardQ
title="What brings you to Vibn?"
sub="This sets up the right workspace for you. You can do both later."
/>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<DoorCard
onClick={onSelf}
title="Personal"
sub={
<>
<span>I want to build my own ideas</span>
<span
style={{
display: "block",
marginTop: 8,
fontSize: 12,
color: "var(--fg-faint)",
lineHeight: 1.4,
}}
>
Go from idea to market, and beyond.
</span>
</>
}
icon={
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="9" cy="9" r="3" />
<path d="M9 2.5v2M9 13.5v2M2.5 9h2M13.5 9h2" />
</svg>
}
/>
<DoorCard
onClick={onAgency}
title="Agency"
sub={
<>
<span>I want to do billable AI work for others</span>
<span
style={{
display: "block",
marginTop: 8,
fontSize: 12,
color: "var(--fg-faint)",
lineHeight: 1.4,
}}
>
VIBN will help you find local businesses that you can build
custom solutions for
</span>
</>
}
icon={
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M2.5 15 9 3l6.5 12" />
<path d="M5.5 12h7" />
</svg>
}
/>
</div>
</WizardBody>
</>
);
}
export default OnboardingPage;

21
app/signin/layout.tsx Normal file
View File

@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import { Toaster } from "sonner";
import "@/app/styles/new-site.css";
import "@/app/components/auth/auth.css";
export const metadata: Metadata = {
title: "Vibn — Sign in",
};
export default function SignInLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
{children}
<Toaster />
</>
);
}

5
app/signin/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import AuthFlow from "@/app/components/auth/AuthFlow";
export default function SignInPage() {
return <AuthFlow mode="signin" />;
}

21
app/signup/layout.tsx Normal file
View File

@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import { Toaster } from "sonner";
import "@/app/styles/new-site.css";
import "@/app/components/auth/auth.css";
export const metadata: Metadata = {
title: "Vibn — Create your account",
};
export default function SignUpLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
{children}
<Toaster />
</>
);
}

5
app/signup/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import AuthFlow from "@/app/components/auth/AuthFlow";
export default function SignUpPage() {
return <AuthFlow mode="signup" />;
}

View File

@@ -1,356 +0,0 @@
/**
* Verbatim from justine/01_homepage.html <style>, scoped under [data-justine].
* Do not mix Tailwind/shadcn tokens on surfaces inside this root.
*/
[data-justine] {
--ink: #1a1a1a;
--ink2: #2c2c2a;
--ink3: #444441;
--mid: #6b7280;
--muted: #9ca3af;
--stone: #b4b2a9;
--parch: #d3d1c7;
--cream: #f1efe8;
--paper: #f7f4ee;
--white: #ffffff;
--border: #e5e7eb;
--serif: var(--font-justine-jakarta), "Plus Jakarta Sans", sans-serif;
--sans: var(--font-justine-jakarta), "Plus Jakarta Sans", sans-serif;
font-family: var(--sans);
background: linear-gradient(to bottom, #fafafe, #f0eeff);
min-height: 100vh;
color: var(--ink);
}
[data-justine] > main {
flex: 1;
width: 100%;
}
/* Only Justines static homepage needs the full * reset; it beats Tailwind utilities if applied to main. */
[data-justine] .justine-home-page * {
box-sizing: border-box;
margin: 0;
padding: 0;
}
[data-justine] .f {
font-family: var(--serif);
}
[data-justine] nav {
background: rgba(250, 250, 250, 0.95);
border-bottom: 1px solid var(--border);
padding: 0 52px;
height: 62px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 50;
}
[data-justine] .nav-links {
display: flex;
gap: 32px;
align-items: center;
}
[data-justine] .btn-ink {
background: linear-gradient(135deg, #2e2a5e, #4338ca);
color: #ffffff;
border: none;
border-radius: 8px;
padding: 9px 22px;
font-family: var(--sans);
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 10px 25px rgba(30, 27, 75, 0.15);
transition: box-shadow 0.2s ease, transform 0.2s ease;
}
[data-justine] .btn-ink:hover {
box-shadow: 0 10px 25px rgba(30, 27, 75, 0.15), 0 0 0 6px rgba(99, 102, 241, 0.15);
transform: translateY(-1px);
}
[data-justine] .btn-ink-lg {
background: linear-gradient(135deg, #2e2a5e, #4338ca);
color: #ffffff;
border: none;
border-radius: 10px;
padding: 15px 36px;
font-family: var(--sans);
font-size: 15px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 10px 25px rgba(30, 27, 75, 0.15);
transition: box-shadow 0.2s ease, transform 0.2s ease;
}
[data-justine] .btn-ink-lg:hover {
box-shadow: 0 10px 25px rgba(30, 27, 75, 0.15), 0 0 0 6px rgba(99, 102, 241, 0.15);
transform: translateY(-1px);
}
[data-justine] .gradient-em {
background: linear-gradient(to right, #6366f1, #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-style: italic;
}
[data-justine] .gradient-text {
background: linear-gradient(to right, #6366f1, #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
[data-justine] .gradient-num {
background: linear-gradient(135deg, #2e2a5e, #4338ca);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
[data-justine] .empathy-card {
background: var(--white);
border: 1px solid var(--border);
border-left: 3px solid rgba(99, 102, 241, 0.8);
border-radius: 12px;
padding: 18px 20px;
display: flex;
gap: 14px;
align-items: flex-start;
box-shadow: 0 10px 30px rgba(30, 27, 75, 0.05);
transition: border-color 0.2s ease, background 0.2s ease;
}
[data-justine] .empathy-card:hover {
border-color: #6366f1;
background: #fafaff;
}
[data-justine] .hero-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 96px;
align-items: center;
}
[data-justine] .empathy-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 72px;
align-items: center;
}
[data-justine] .phase-grid {
display: grid;
grid-template-columns: 1fr 1fr;
border: 1px solid rgba(99, 102, 241, 0.2);
border-radius: 14px;
overflow: hidden;
}
[data-justine] .wyg-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
}
[data-justine] .quote-grid {
display: grid;
grid-template-columns: 1fr 1.6fr 1fr;
gap: 28px;
align-items: center;
margin-bottom: 20px;
}
[data-justine] .stats-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
}
[data-justine] .footer-tagline {
display: block;
font-size: 12px;
color: var(--muted);
margin-top: 4px;
font-family: var(--sans);
}
[data-justine] .hamburger {
display: none;
flex-direction: column;
gap: 5px;
background: none;
border: none;
cursor: pointer;
padding: 6px;
}
[data-justine] .hamburger span {
display: block;
width: 22px;
height: 2px;
background: var(--ink);
border-radius: 2px;
transition: transform 0.25s ease, opacity 0.25s ease;
}
[data-justine] .hamburger.open span:nth-child(1) {
transform: translateY(7px) rotate(45deg);
}
[data-justine] .hamburger.open span:nth-child(2) {
opacity: 0;
}
[data-justine] .hamburger.open span:nth-child(3) {
transform: translateY(-7px) rotate(-45deg);
}
[data-justine] .mobile-menu {
display: none;
position: fixed;
top: 62px;
left: 0;
right: 0;
background: rgba(250, 250, 250, 0.98);
border-bottom: 1px solid var(--border);
padding: 20px 24px 28px;
z-index: 49;
flex-direction: column;
gap: 0;
box-shadow: 0 8px 24px rgba(30, 27, 75, 0.08);
}
[data-justine] .mobile-menu.open {
display: flex;
}
[data-justine] .mobile-menu a {
font-size: 15px;
color: var(--ink);
text-decoration: none;
padding: 13px 0;
border-bottom: 1px solid var(--border);
font-weight: 500;
}
[data-justine] .mobile-menu a:last-of-type {
border-bottom: none;
}
[data-justine] .mobile-menu .mobile-menu-cta {
margin-top: 18px;
}
[data-justine] footer {
background: rgba(250, 250, 250, 0.95);
border-top: 1px solid var(--border);
padding: 32px 52px;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
}
[data-justine] .footer-links {
display: flex;
gap: 28px;
}
@media (max-width: 768px) {
[data-justine] nav {
padding: 0 20px;
}
[data-justine] .nav-links {
display: none;
}
[data-justine] .nav-right-btns {
display: none !important;
}
[data-justine] .hamburger {
display: flex;
}
[data-justine] .hero-grid {
grid-template-columns: 1fr;
gap: 44px;
}
[data-justine] .hero-section {
padding: 52px 24px 48px !important;
}
[data-justine] .empathy-section {
padding: 56px 24px !important;
}
[data-justine] .empathy-grid {
grid-template-columns: 1fr;
gap: 36px;
}
[data-justine] .how-section {
padding: 64px 24px !important;
}
[data-justine] .phase-grid {
grid-template-columns: 1fr;
}
[data-justine] .phase-grid > div {
border-right: none !important;
padding: 28px 24px !important;
}
[data-justine] .wyg-grid {
grid-template-columns: 1fr;
}
[data-justine] .wyg-grid > div {
border-right: none !important;
border-bottom: 1px solid var(--border);
padding: 32px 24px !important;
}
[data-justine] .wyg-grid > div:last-child {
border-bottom: none;
}
[data-justine] .wyg-section {
padding: 0 24px !important;
}
[data-justine] .quote-grid {
grid-template-columns: 1fr;
}
[data-justine] .quote-side {
display: none !important;
}
[data-justine] .quote-section {
padding: 32px 24px 28px !important;
}
[data-justine] .stats-grid {
grid-template-columns: 1fr 1fr;
}
[data-justine] .stats-grid > div {
padding: 28px 16px !important;
}
[data-justine] .stats-grid > div:nth-child(odd) {
padding-left: 0 !important;
}
[data-justine] .stats-grid > div:nth-child(3),
[data-justine] .stats-grid > div:nth-child(4) {
border-top: 1px solid var(--border);
}
[data-justine] .stats-grid > div:nth-child(even) {
border-right: none !important;
}
[data-justine] .stats-section {
padding: 0 24px !important;
}
[data-justine] .cta-section {
padding: 56px 20px !important;
}
[data-justine] .cta-card {
padding: 44px 28px !important;
}
[data-justine] .hero-h1 {
font-size: 40px !important;
line-height: 1.1 !important;
}
[data-justine] .hero-sub {
font-size: 15px !important;
}
[data-justine] footer {
display: flex !important;
flex-direction: column;
gap: 20px;
text-align: center;
padding: 32px 24px !important;
}
[data-justine] .footer-links {
flex-wrap: wrap;
justify-content: center;
}
}

View File

@@ -1,204 +0,0 @@
/**
* From justine/02_signup.html — scoped to [data-justine-auth] only.
*/
[data-justine-auth] {
--ink: #1a1a1a;
--mid: #6b7280;
--muted: #9ca3af;
--border: #e5e7eb;
--white: #ffffff;
--soft: #f5f3ff;
--hover: #fafaff;
--sans: var(--font-justine-jakarta), "Plus Jakarta Sans", sans-serif;
--serif: var(--font-justine-jakarta), "Plus Jakarta Sans", sans-serif;
}
[data-justine-auth].justine-auth-root {
font-family: var(--sans);
background: linear-gradient(to bottom, #fafafa, #f5f3ff);
min-height: 100vh;
color: var(--ink);
}
[data-justine-auth] .f {
font-family: var(--serif);
}
[data-justine-auth] .justine-auth-nav {
background: rgba(250, 250, 250, 0.95);
border-bottom: 1px solid var(--border);
padding: 0 40px;
height: 62px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 50;
}
@media (max-width: 640px) {
[data-justine-auth] .justine-auth-nav {
padding: 0 20px;
}
}
[data-justine-auth] .justine-auth-nav-brand {
display: flex;
align-items: center;
gap: 9px;
text-decoration: none;
}
[data-justine-auth] .justine-auth-nav-logo {
width: 28px;
height: 28px;
background: linear-gradient(135deg, #2e2a5e, #4338ca);
border-radius: 7px;
display: flex;
align-items: center;
justify-content: center;
}
[data-justine-auth] .justine-auth-nav-logo span {
font-size: 14px;
font-weight: 700;
color: #ffffff;
}
[data-justine-auth] .justine-auth-nav-wordmark {
font-size: 17px;
font-weight: 700;
color: var(--ink);
letter-spacing: -0.02em;
}
[data-justine-auth] .justine-auth-nav-aside {
font-size: 13.5px;
color: var(--muted);
}
[data-justine-auth] .justine-auth-nav-aside a {
color: #6366f1;
font-weight: 600;
text-decoration: none;
}
[data-justine-auth] .justine-auth-nav-aside a:hover {
text-decoration: underline;
}
[data-justine-auth] .justine-auth-main {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 24px;
}
[data-justine-auth] .justine-auth-inner {
width: 100%;
max-width: 440px;
}
[data-justine-auth] .justine-auth-card {
background: var(--white);
border: 1px solid var(--border);
border-radius: 16px;
padding: 32px;
box-shadow: 0 10px 30px rgba(30, 27, 75, 0.05);
}
[data-justine-auth] .justine-auth-title {
font-size: 23px;
font-weight: 700;
color: var(--ink);
letter-spacing: -0.02em;
margin-bottom: 6px;
line-height: 1.2;
}
[data-justine-auth] .justine-auth-sub {
font-size: 14px;
color: var(--muted);
margin-bottom: 22px;
line-height: 1.5;
}
[data-justine-auth] .justine-auth-btn-google {
width: 100%;
background: transparent;
border: 1px solid var(--border);
color: var(--ink);
border-radius: 10px;
padding: 11px;
font-family: var(--sans);
font-size: 13.5px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 9px;
transition:
border-color 0.15s,
background 0.15s;
}
[data-justine-auth] .justine-auth-btn-google:hover:not(:disabled) {
border-color: #6366f1;
background: var(--hover);
}
[data-justine-auth] .justine-auth-btn-google:disabled {
opacity: 0.5;
cursor: not-allowed;
}
[data-justine-auth] .justine-auth-legal {
text-align: center;
font-size: 11.5px;
color: var(--muted);
margin-top: 18px;
line-height: 1.5;
}
[data-justine-auth] .justine-auth-legal a {
color: var(--muted);
text-decoration: underline;
}
[data-justine-auth] .justine-auth-legal a:hover {
color: var(--mid);
}
[data-justine-auth] .justine-auth-loading-wrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 48px 24px;
text-align: center;
}
[data-justine-auth] .justine-auth-spinner {
width: 36px;
height: 36px;
border-radius: 50%;
border: 3px solid var(--border);
border-top-color: #6366f1;
animation: justine-auth-spin 0.75s linear infinite;
}
@keyframes justine-auth-spin {
to {
transform: rotate(360deg);
}
}
[data-justine-auth] .justine-auth-loading-text {
font-size: 14px;
color: var(--muted);
}

View File

@@ -1,370 +0,0 @@
/**
* From justine/03_dashboard.html, scoped under [data-justine-dashboard].
* Top bar uses .jd-topnav. Root layout: .justine-dashboard-root
*/
[data-justine-dashboard] *{box-sizing:border-box;margin:0;padding:0;}
[data-justine-dashboard]{
--ink:#1A1A1A; --mid:#6B7280; --muted:#9CA3AF; --border:#E5E7EB;
--cream:#FAFAFF; --paper:#F5F3FF; --white:#FFFFFF;
--indigo:#6366F1; --indigo-dim:rgba(99,102,241,0.08); --indigo-border:rgba(99,102,241,0.18);
--green:#059669; --green-dim:#D1FAE5;
--amber:#F59E0B; --amber-dim:#FFFBEB; --amber-border:#FDE68A;
--serif:var(--font-justine-jakarta),"Plus Jakarta Sans",sans-serif; --sans:var(--font-justine-jakarta),"Plus Jakarta Sans",sans-serif;
}
[data-justine-dashboard].justine-dashboard-root{font-family:var(--sans);background:#FAFAFA;min-height:100vh;display:flex;flex-direction:column;height:100vh;max-height:100vh;overflow:hidden;color:var(--ink);}
[data-justine-dashboard] .f{font-family:var(--serif);}
@keyframes pulse{0%,100%{opacity:1;}50%{opacity:0.35;}}
/* ── App shell ── */
.app-shell{display:flex;flex:1;overflow:hidden;}
/* ── Left panel ── */
.proj-nav{width:256px;flex-shrink:0;background:#EDECEA;border-right:1px solid rgba(0,0,0,0.08);display:flex;flex-direction:column;overflow:hidden;}
/* ── Nav item buttons (Dashboard, Clients, Invoices…) ── */
.nav-item-btn{display:flex;align-items:center;gap:9px;padding:7px 8px;border-radius:8px;border:none;background:transparent;cursor:pointer;width:100%;text-align:left;transition:background 0.18s ease;margin-bottom:1px;font-family:var(--sans);}
.nav-item-btn:hover{background:rgba(0,0,0,0.06);}
.nav-item-btn.active{background:rgba(99,102,241,0.12);}
.nav-icon{width:28px;height:28px;border-radius:7px;background:rgba(99,102,241,0.12);color:#3730A3;display:flex;align-items:center;justify-content:center;font-size:13px;flex-shrink:0;transition:background 0.18s ease;}
.nav-item-btn.active .nav-icon{background:var(--indigo-dim);}
.nav-label{font-size:13px;font-weight:500;color:var(--ink);line-height:1.3;}
.nav-item-btn.active .nav-label{color:var(--indigo);font-weight:600;}
.nav-sub{font-size:10.5px;color:var(--muted);}
.nav-group-label{font-size:10px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:var(--muted);padding:0 8px;margin-bottom:4px;}
/* ── Project rows ── */
.proj-list{max-height:168px;overflow-y:auto;padding:4px 8px 8px;flex-shrink:0;}
.proj-row{display:flex;align-items:flex-start;gap:10px;padding:9px 10px;border-radius:10px;cursor:pointer;border:1px solid transparent;transition:background 0.18s ease,border-color 0.18s ease;margin-bottom:2px;}
.proj-edit-btn{opacity:0;transition:opacity 0.18s ease;background:none;border:none;cursor:pointer;padding:3px;border-radius:5px;display:flex;align-items:center;color:var(--muted);flex-shrink:0;margin-left:2px;}
.proj-row:hover .proj-edit-btn{opacity:1;}
.proj-edit-btn:hover{color:var(--indigo);background:var(--indigo-dim);}
/* ── Project edit popover ── */
#proj-edit-popover{position:fixed;display:none;background:var(--white);border:1px solid var(--border);border-radius:12px;padding:14px;z-index:300;box-shadow:0 8px 28px rgba(0,0,0,0.14);width:206px;}
#proj-edit-name{width:100%;box-sizing:border-box;border:1px solid var(--border);border-radius:6px;padding:6px 9px;font-family:var(--sans);font-size:13px;color:var(--ink);background:var(--white);outline:none;transition:border-color 0.15s;}
#proj-edit-name:focus{border-color:var(--indigo);}
.color-swatch{width:24px;height:24px;border-radius:50%;cursor:pointer;border:2.5px solid transparent;transition:transform 0.12s ease,border-color 0.12s ease;}
.color-swatch:hover{transform:scale(1.18);}
.color-swatch.active{border-color:var(--ink);}
.proj-row:hover{background:rgba(0,0,0,0.05);}
.proj-row.active{background:rgba(99,102,241,0.12);border-color:var(--indigo-border);}
.proj-icon{width:30px;height:30px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-family:var(--serif);font-size:12px;font-weight:700;color:var(--white);flex-shrink:0;}
.proj-row-name{font-size:12.5px;font-weight:600;color:var(--ink);display:flex;align-items:center;gap:5px;flex-wrap:wrap;margin-bottom:2px;}
.proj-row-metric{font-size:11.5px;color:var(--mid);margin-bottom:1px;font-weight:500;}
.proj-row-time{font-size:10px;color:var(--muted);}
.alert-dot{width:6px;height:6px;border-radius:50%;background:var(--amber);flex-shrink:0;margin-top:5px;}
/* ── Status pills ── */
.pill{display:inline-flex;align-items:center;gap:3px;font-size:10px;font-weight:600;padding:2px 7px;border-radius:4px;white-space:nowrap;}
.pill-live{background:var(--green-dim);color:var(--green);}
.pill-building{background:#EDE9FE;color:#4338CA;}
.pill-draft{background:#F3F4F6;color:var(--mid);}
.dot-live{width:5px;height:5px;border-radius:50%;background:var(--green);display:inline-block;flex-shrink:0;}
.dot-building{width:5px;height:5px;border-radius:50%;background:#6366F1;display:inline-block;flex-shrink:0;animation:pulse 1.8s ease infinite;}
/* ── Search ── */
.nav-search-wrap{position:relative;}
.nav-search{width:100%;border:1px solid rgba(0,0,0,0.1);border-radius:8px;padding:7px 10px 7px 30px;font-family:var(--sans);font-size:12px;color:var(--ink);background:rgba(0,0,0,0.05);outline:none;transition:border-color 0.18s ease,background 0.18s ease;}
.nav-search:focus{border-color:var(--indigo);background:rgba(255,255,255,0.7);}
.nav-search::placeholder{color:var(--muted);}
/* ── Buttons ── */
.btn-primary{background:linear-gradient(135deg,#2E2A5E,#4338CA);color:var(--white);border:none;border-radius:8px;padding:10px 20px;font-family:var(--sans);font-size:13px;font-weight:600;cursor:pointer;box-shadow:0 4px 14px rgba(30,27,75,0.14);transition:box-shadow 0.2s,transform 0.15s;white-space:nowrap;}
.btn-primary:hover{box-shadow:0 6px 20px rgba(30,27,75,0.22);transform:translateY(-1px);}
.btn-secondary{background:var(--white);color:var(--ink);border:1px solid var(--border);border-radius:8px;padding:9px 18px;font-family:var(--sans);font-size:13px;font-weight:500;cursor:pointer;transition:border-color 0.15s,background 0.15s,color 0.15s;white-space:nowrap;}
.btn-secondary:hover{border-color:var(--indigo);background:var(--cream);color:var(--indigo);}
.btn-ghost{background:none;color:var(--mid);border:none;font-family:var(--sans);font-size:12px;cursor:pointer;padding:6px 10px;border-radius:6px;transition:background 0.12s,color 0.12s;white-space:nowrap;}
.btn-ghost:hover{background:var(--cream);color:var(--ink);}
.btn-amber{background:var(--amber-dim);color:#92400E;border:1px solid var(--amber-border);border-radius:8px;padding:9px 16px;font-family:var(--sans);font-size:13px;font-weight:600;cursor:pointer;white-space:nowrap;transition:background 0.15s;}
.btn-amber:hover{background:#FEF3C7;}
/* ── Nav footer ── */
.nav-footer{padding:12px 14px;border-top:1px solid var(--border);flex-shrink:0;}
/* ── Workspace ── */
.workspace{flex:1;overflow-y:auto;background:linear-gradient(160deg,#F4F3F0 0%,#EDE9FA 100%);}
.ws-inner{max-width:1140px;padding:32px 36px;margin:0 auto;}
.ws-section{display:none;}
.ws-section.active{display:block;}
/* ── Workspace header ── */
.ws-header{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:28px;padding-bottom:24px;border-bottom:1px solid var(--border);}
.ws-header-left{flex:1;min-width:0;}
.ws-header-identity{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:7px;}
.proj-name-heading{border-radius:6px;padding:2px 5px;margin:-2px -5px;}
.proj-name-input{font-size:22px;font-weight:700;color:var(--ink);letter-spacing:-0.02em;font-family:var(--serif);border:none;border-bottom:2px solid var(--indigo);background:transparent;outline:none;padding:2px 0;width:auto;min-width:60px;max-width:280px;}
.client-card-header{position:relative;}
.client-edit-btn{position:absolute;top:12px;right:12px;opacity:0;transition:opacity 0.18s ease;background:none;border:none;cursor:pointer;padding:4px;border-radius:5px;display:flex;align-items:center;color:var(--muted);}
.client-card-header:hover .client-edit-btn{opacity:1;}
.client-edit-btn:hover{color:var(--indigo);background:var(--indigo-dim);}
#client-edit-popover{position:fixed;display:none;background:var(--white);border:1px solid var(--border);border-radius:12px;padding:14px;z-index:300;box-shadow:0 8px 28px rgba(0,0,0,0.14);width:220px;}
.client-edit-field{width:100%;box-sizing:border-box;border:1px solid var(--border);border-radius:6px;padding:6px 9px;font-family:var(--sans);font-size:13px;color:var(--ink);background:var(--white);outline:none;transition:border-color 0.15s;margin-bottom:8px;}
.client-edit-field:last-of-type{margin-bottom:0;}
.client-edit-field:focus{border-color:var(--indigo);}
.ws-header-desc{font-size:13px;color:var(--mid);padding-left:48px;line-height:1.5;}
.ws-header-actions{display:flex;gap:8px;align-items:center;flex-shrink:0;padding-top:4px;}
/* ── Priority card ── */
.priority-card{background:var(--white);border:1px solid #E0E7FF;border-left:4px solid var(--indigo);border-radius:12px;padding:22px 24px;display:flex;align-items:flex-start;gap:18px;margin-bottom:24px;box-shadow:0 2px 16px rgba(99,102,241,0.07);}
.priority-icon{width:42px;height:42px;border-radius:10px;background:var(--indigo-dim);display:flex;align-items:center;justify-content:center;font-size:20px;flex-shrink:0;margin-top:2px;}
.priority-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.07em;color:var(--indigo);margin-bottom:5px;}
.priority-title{font-size:17px;font-weight:700;color:var(--ink);margin-bottom:7px;letter-spacing:-0.01em;}
.priority-desc{font-size:13px;color:var(--mid);line-height:1.65;margin-bottom:16px;}
/* ── Metric card states ── */
.metric-card-up{border-color:#6EE7B7!important;background:#ECFDF5!important;}
.metric-card-up .metric-label{color:#065F46!important;}
.metric-card-up .metric-value{color:#065F46!important;}
.metric-card-down{border-color:var(--amber-border)!important;background:var(--amber-dim)!important;}
.metric-card-down .metric-label{color:#92400E!important;}
.metric-card-down .metric-value{color:#92400E!important;}
.metric-card-flat{border-color:#BFDBFE!important;background:#EFF6FF!important;}
.metric-card-flat .metric-label{color:#1E40AF!important;}
.metric-card-flat .metric-value{color:#1E40AF!important;}
/* Dark mode overrides */
[data-justine-dashboard][data-theme="dark"] .metric-card-up{border-color:rgba(5,150,105,0.35)!important;background:rgba(5,150,105,0.12)!important;}
[data-justine-dashboard][data-theme="dark"] .metric-card-up .metric-label,[data-justine-dashboard][data-theme="dark"] .metric-card-up .metric-value{color:#6EE7B7!important;}
[data-justine-dashboard][data-theme="dark"] .metric-card-down{border-color:rgba(245,158,11,0.30)!important;background:rgba(245,158,11,0.12)!important;}
[data-justine-dashboard][data-theme="dark"] .metric-card-down .metric-label,[data-justine-dashboard][data-theme="dark"] .metric-card-down .metric-value{color:#FDE68A!important;}
[data-justine-dashboard][data-theme="dark"] .metric-card-flat{border-color:rgba(59,130,246,0.30)!important;background:rgba(59,130,246,0.10)!important;}
[data-justine-dashboard][data-theme="dark"] .metric-card-flat .metric-label,[data-justine-dashboard][data-theme="dark"] .metric-card-flat .metric-value{color:#93C5FD!important;}
/* ── Metrics ── */
.metrics-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:24px;}
.metric-card{background:var(--white);border:1px solid #E0DDD8;border-radius:10px;padding:16px 18px;box-shadow:0 1px 4px rgba(0,0,0,0.05);}
.metric-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted);margin-bottom:6px;}
.metric-value{font-family:var(--serif);font-size:24px;font-weight:700;color:var(--ink);letter-spacing:-0.02em;margin-bottom:4px;}
.metric-sub{font-size:11.5px;color:var(--mid);}
.trend-up{font-size:11.5px;color:var(--green);font-weight:600;}
.trend-down{font-size:11.5px;color:var(--amber);font-weight:500;}
.trend-neutral{font-size:11.5px;color:#3B82F6;font-weight:500;}
[data-justine-dashboard][data-theme="dark"] .trend-neutral{color:#93C5FD;}
/* ── Progress ── */
.progress-bar{height:5px;background:#E5E7EB;border-radius:3px;overflow:hidden;margin-top:10px;}
.progress-fill{height:100%;border-radius:3px;background:linear-gradient(90deg,#6366F1,#8B5CF6);}
.progress-fill-gray{height:100%;border-radius:3px;background:linear-gradient(90deg,#9CA3AF,#6B7280);}
/* ── Content cards ── */
.cards-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
.content-card{background:var(--white);border:1px solid #E0DDD8;border-radius:12px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,0.05);}
.card-head{padding:14px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;}
.card-title{font-size:13px;font-weight:600;color:var(--ink);}
.card-body{padding:16px 20px;}
/* ── Rows inside cards ── */
.health-row{display:flex;align-items:flex-start;gap:12px;padding:10px 0;border-bottom:1px solid var(--border);}
.health-row:last-child{border-bottom:none;padding-bottom:0;}
.health-icon{font-size:15px;width:22px;text-align:center;flex-shrink:0;margin-top:1px;}
.health-title{font-size:13px;font-weight:500;color:var(--ink);margin-bottom:2px;}
.health-sub{font-size:11.5px;color:var(--muted);}
.activity-row{display:flex;align-items:flex-start;gap:10px;padding:10px 0;border-bottom:1px solid var(--border);}
.activity-row:last-child{border-bottom:none;padding-bottom:0;}
.activity-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;margin-top:5px;}
.fin-row{display:flex;align-items:center;justify-content:space-between;padding:9px 0;border-bottom:1px solid var(--border);}
.fin-row:last-child{border-bottom:none;}
.milestone-row{display:flex;align-items:flex-start;gap:12px;padding:11px 0;border-bottom:1px solid var(--border);}
.milestone-row:last-child{border-bottom:none;padding-bottom:0;}
.milestone-row.is-current{background:rgba(99,102,241,0.04);border-radius:8px;padding:11px 10px;margin:2px -10px;border-left:3px solid var(--indigo);border-bottom:none;padding-left:7px;}
.m-check{width:20px;height:20px;border-radius:50%;border:2px solid var(--border);flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:700;margin-top:1px;}
.m-check.done{background:var(--green);border-color:var(--green);color:var(--white);}
.m-check.current{background:var(--indigo);border-color:var(--indigo);color:var(--white);animation:pulse 2s ease infinite;}
.m-check.pending{opacity:0.35;}
.setup-row{display:flex;align-items:flex-start;gap:10px;padding:10px 0;border-bottom:1px solid var(--border);}
.setup-row:last-child{border-bottom:none;padding-bottom:0;}
.s-check{width:18px;height:18px;border-radius:5px;border:2px solid var(--border);flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:700;margin-top:1px;}
.s-check.done{background:var(--indigo);border-color:var(--indigo);color:var(--white);}
.rec-block{padding-bottom:14px;margin-bottom:14px;border-bottom:1px solid var(--border);}
.rec-block:last-child{padding-bottom:0;margin-bottom:0;border-bottom:none;}
/* ════════════════════════════════════
DASHBOARD LANDING PAGE styles
════════════════════════════════════ */
.dash-section-title{font-family:var(--serif);font-size:14px;font-weight:600;color:var(--ink);margin-bottom:13px;letter-spacing:-0.01em;}
/* Attention cards */
.attn-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:36px;}
.attn-card{border-radius:12px;padding:22px;border:1px solid transparent;display:flex;flex-direction:column;}
.attn-card-amber{background:#FFFBEB;border-color:#FDE68A;}
.attn-card-indigo{background:#EDE9FE;border-color:#DDD6FE;}
.attn-card-slate{background:#F8FAFC;border-color:#E2E8F0;}
.attn-value{font-family:var(--serif);font-size:30px;font-weight:700;letter-spacing:-0.03em;margin-bottom:5px;}
.attn-value-amber{color:#92400E;}
.attn-value-indigo{color:#3730A3;}
.attn-value-slate{color:#334155;}
.attn-desc{font-size:13px;line-height:1.5;flex:1;}
.attn-desc-amber{color:#78350F;}
.attn-desc-indigo{color:#3730A3;}
.attn-desc-slate{color:#475569;}
.attn-cta{margin-top:18px;font-size:12.5px;font-weight:600;cursor:pointer;background:none;border:none;padding:0;display:inline-flex;align-items:center;gap:5px;transition:gap 0.15s;font-family:var(--sans);}
.attn-cta:hover{gap:9px;}
.attn-cta-amber{color:#92400E;}
.attn-cta-indigo{color:#4338CA;}
.attn-cta-slate{color:#475569;}
/* Snapshot cards */
.snap-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:36px;}
.snap-card{background:var(--white);border:1px solid #E0DDD8;border-radius:10px;padding:16px 18px;box-shadow:0 1px 4px rgba(0,0,0,0.05);}
.snap-value{font-family:var(--serif);font-size:24px;font-weight:700;color:var(--ink);letter-spacing:-0.02em;margin-bottom:4px;}
.snap-label{font-size:11.5px;color:var(--muted);}
/* Performance cards */
.perf-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:36px;}
.perf-card{background:var(--white);border:1px solid #E0DDD8;border-radius:12px;padding:22px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,0.05);}
.perf-value{font-family:var(--serif);font-size:32px;font-weight:700;color:var(--ink);letter-spacing:-0.03em;margin-bottom:4px;}
.perf-change{font-size:12px;font-weight:600;margin-bottom:3px;}
.perf-sublabel{font-size:11.5px;color:var(--muted);}
.perf-chart-wrap{margin-top:18px;}
/* Article cards */
.article-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;}
.article-card{background:var(--white);border:1px solid #E0DDD8;border-radius:12px;padding:22px;display:flex;flex-direction:column;box-shadow:0 1px 4px rgba(0,0,0,0.05);}
.article-emoji{font-size:22px;margin-bottom:12px;}
.article-title{font-family:var(--serif);font-size:15px;font-weight:600;color:var(--ink);margin-bottom:7px;}
.article-desc{font-size:12.5px;color:var(--mid);line-height:1.6;flex:1;}
.article-cta{margin-top:14px;font-size:12px;font-weight:600;color:var(--indigo);cursor:pointer;background:none;border:none;padding:0;font-family:var(--sans);display:inline-flex;align-items:center;gap:4px;transition:gap 0.15s;}
.article-cta:hover{gap:7px;}
/* ── Section table (Clients, Invoices, Costs) ── */
.sec-table{width:100%;border-collapse:collapse;}
.sec-table th{font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:0.06em;color:var(--muted);padding:10px 16px;border-bottom:2px solid var(--border);text-align:left;background:#FAFAFA;}
.sec-table td{font-size:13px;padding:13px 16px;border-bottom:1px solid var(--border);color:var(--ink);vertical-align:middle;}
.sec-table tr:last-child td{border-bottom:none;}
.sec-table tbody tr:hover td{background:var(--cream);}
/* ── Modal ── */
.modal-overlay{position:fixed;inset:0;background:rgba(15,14,26,0.45);display:none;align-items:center;justify-content:center;z-index:100;backdrop-filter:blur(2px);}
.modal-overlay.open{display:flex;}
.modal-card{background:var(--white);border-radius:16px;padding:28px;width:100%;max-width:420px;box-shadow:0 24px 64px rgba(30,27,75,0.18);}
.modal-input{width:100%;border:1px solid var(--border);border-radius:8px;padding:10px 13px;font-family:var(--sans);font-size:14px;color:var(--ink);background:#FAFAFA;outline:none;transition:border-color 0.15s;}
.modal-input:focus{border-color:var(--indigo);}
.modal-input::placeholder{color:var(--muted);}
.for-card{flex:1;border:1px solid var(--border);border-radius:9px;padding:14px;cursor:pointer;text-align:center;background:#FAFAFA;transition:all 0.15s;}
.for-card:hover,.for-card.sel{border-color:var(--indigo);background:var(--cream);}
/* ── Responsive ── */
@media(max-width:860px){
.attn-grid,.perf-grid,.article-grid{grid-template-columns:1fr 1fr;}
.snap-grid{grid-template-columns:1fr 1fr;}
.cards-grid{grid-template-columns:1fr;}
.ws-header{flex-direction:column;gap:12px;}
.ws-header-actions{flex-wrap:wrap;}
.ws-inner{padding:24px 20px;}
}
@media(max-width:600px){
.proj-nav{display:none!important;}
.workspace{padding-bottom:64px;}
.attn-grid,.perf-grid,.article-grid,.snap-grid{grid-template-columns:1fr;}
.ws-inner{padding:20px 16px;}
.ws-header-actions{gap:6px;width:100%;}
.ws-header-actions .btn-secondary,.ws-header-actions .btn-primary{font-size:12px;padding:10px 8px;flex:1;text-align:center;justify-content:center;}
.dash-header-actions{flex-direction:column;gap:6px!important;padding-top:0!important;}
.dash-header-actions .btn-secondary,.dash-header-actions .btn-primary{font-size:11.5px;padding:7px 12px;width:100%;}
.clients-grid{grid-template-columns:1fr!important;}
.content-card{overflow-x:auto;overflow-y:visible;-webkit-overflow-scrolling:touch;}
.sec-table{min-width:460px;}
.mob-col-hide{display:none!important;}
.mob-hide{display:none!important;}
}
/* ── Mobile bottom tab bar ── */
.mob-tab-bar{display:none;position:fixed;bottom:0;left:0;right:0;height:60px;background:var(--white);border-top:1px solid var(--border);align-items:stretch;z-index:200;padding-bottom:env(safe-area-inset-bottom);}
.mob-tab{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;border:none;background:transparent;cursor:pointer;font-family:var(--sans);font-size:10px;font-weight:500;color:var(--muted);padding:8px 4px;transition:color 0.15s;}
.mob-tab.active{color:var(--indigo);}
.mob-tab svg{flex-shrink:0;transition:transform 0.15s;}
.mob-tab.active svg{transform:scale(1.1);}
@media(max-width:600px){.mob-tab-bar{display:flex;}}
/* ── Mobile project cards ── */
.mob-proj-card{background:var(--white);border:1px solid var(--border);border-radius:14px;padding:16px;display:flex;align-items:center;gap:14px;cursor:pointer;transition:border-color 0.15s,box-shadow 0.15s;}
.mob-proj-card:hover{border-color:var(--indigo);box-shadow:0 2px 12px rgba(99,102,241,0.1);}
/* ── Dark mode: mobile tab bar ── */
[data-justine-dashboard][data-theme="dark"] .mob-tab-bar{background:#212840;border-top-color:#3A4260;}
[data-justine-dashboard][data-theme="dark"] .mob-tab{color:#6A7490;}
[data-justine-dashboard][data-theme="dark"] .mob-tab.active{color:#A5B4FC;}
[data-justine-dashboard][data-theme="dark"] .mob-proj-card{background:#2A3250;border-color:#3A4260;}
/* ── Dark mode ── */
/* Surface hierarchy: body #1A1F2E → nav #212840 → cards #2A3250 → borders #3A4260 */
[data-justine-dashboard][data-theme="dark"]{
--ink:#ECE9F5; --mid:#9AA3BC; --muted:#6A7490; --indigo:#A5B4FC;
--border:#3A4260; --cream:#2A3250; --paper:#242B48; --white:#2A3250;
--indigo-dim:rgba(99,102,241,0.18); --indigo-border:rgba(99,102,241,0.35);
--green-dim:rgba(5,150,105,0.18);
--amber-dim:rgba(245,158,11,0.14); --amber-border:rgba(245,158,11,0.30);
}
[data-justine-dashboard][data-theme="dark"].justine-dashboard-root{background:#1A1F2E;}
[data-justine-dashboard][data-theme="dark"] .jd-topnav{background:rgba(26,31,46,0.97)!important;border-bottom-color:#3A4260!important;}
[data-justine-dashboard][data-theme="dark"] .jd-topnav .f,[data-justine-dashboard][data-theme="dark"] .jd-topnav span{color:var(--ink)!important;}
[data-justine-dashboard][data-theme="dark"] .proj-nav{background:#212840;border-right-color:#3A4260;}
[data-justine-dashboard][data-theme="dark"] .proj-row:hover{background:rgba(255,255,255,0.06);}
[data-justine-dashboard][data-theme="dark"] .proj-row.active{background:rgba(99,102,241,0.18);}
[data-justine-dashboard][data-theme="dark"] .proj-row:hover .proj-edit-btn{color:#A5B4FC;}
[data-justine-dashboard][data-theme="dark"] .proj-edit-btn:hover{color:#A5B4FC!important;background:rgba(165,180,252,0.15)!important;}
[data-justine-dashboard][data-theme="dark"] #proj-edit-popover{background:#2A3250;border-color:#3A4260;}
[data-justine-dashboard][data-theme="dark"] #proj-edit-name{background:#1E2640;border-color:#3A4260;color:#ECE9F5;}
[data-justine-dashboard][data-theme="dark"] #proj-edit-name:focus{border-color:#A5B4FC;}
[data-justine-dashboard][data-theme="dark"] .color-swatch.active{border-color:#ECE9F5;}
[data-justine-dashboard][data-theme="dark"] .client-edit-btn{color:#6A7490;}
[data-justine-dashboard][data-theme="dark"] .client-card-header:hover .client-edit-btn{color:#A5B4FC;}
[data-justine-dashboard][data-theme="dark"] .client-edit-btn:hover{color:#A5B4FC!important;background:rgba(165,180,252,0.15)!important;}
[data-justine-dashboard][data-theme="dark"] #client-edit-popover{background:#2A3250;border-color:#3A4260;}
[data-justine-dashboard][data-theme="dark"] .client-edit-field{background:#1E2640;border-color:#3A4260;color:#ECE9F5;}
[data-justine-dashboard][data-theme="dark"] .client-edit-field:focus{border-color:#A5B4FC;}
[data-justine-dashboard][data-theme="dark"] .nav-item-btn:hover{background:rgba(255,255,255,0.07);}
[data-justine-dashboard][data-theme="dark"] .nav-item-btn.active{background:rgba(99,102,241,0.18);}
[data-justine-dashboard][data-theme="dark"] .nav-icon{background:#A5B4FC;color:#1E1B6E;}
[data-justine-dashboard][data-theme="dark"] #nav-dashboard{background:rgba(255,255,255,0.08)!important;}
[data-justine-dashboard][data-theme="dark"] #nav-dashboard:hover{background:rgba(255,255,255,0.12)!important;}
[data-justine-dashboard][data-theme="dark"] .nav-search{background:rgba(255,255,255,0.07);border-color:#3A4260;color:var(--ink);}
[data-justine-dashboard][data-theme="dark"] .nav-search:focus{background:rgba(255,255,255,0.10);}
[data-justine-dashboard][data-theme="dark"] .workspace{background:linear-gradient(160deg,#1C2235 0%,#212840 100%);}
[data-justine-dashboard][data-theme="dark"] .ws-header{border-bottom-color:#3A4260;}
[data-justine-dashboard][data-theme="dark"] .snap-card,[data-justine-dashboard][data-theme="dark"] .perf-card,[data-justine-dashboard][data-theme="dark"] .article-card,
[data-justine-dashboard][data-theme="dark"] .metric-card,[data-justine-dashboard][data-theme="dark"] .content-card{border-color:#3A4260;}
[data-justine-dashboard][data-theme="dark"] .card-head{border-bottom-color:#3A4260;}
[data-justine-dashboard][data-theme="dark"] .health-row,[data-justine-dashboard][data-theme="dark"] .activity-row,[data-justine-dashboard][data-theme="dark"] .fin-row,
[data-justine-dashboard][data-theme="dark"] .milestone-row,[data-justine-dashboard][data-theme="dark"] .setup-row,[data-justine-dashboard][data-theme="dark"] .rec-block{border-bottom-color:#3A4260;}
[data-justine-dashboard][data-theme="dark"] .attn-card-amber{background:rgba(245,158,11,0.12);border-color:rgba(245,158,11,0.30);}
[data-justine-dashboard][data-theme="dark"] .attn-card-indigo{background:rgba(99,102,241,0.15);border-color:rgba(99,102,241,0.32);}
[data-justine-dashboard][data-theme="dark"] .attn-card-slate{background:#242B48;border-color:#3A4260;}
[data-justine-dashboard][data-theme="dark"] .attn-value-slate{color:#B0BAD0;}
[data-justine-dashboard][data-theme="dark"] .attn-desc-slate{color:#8A96B0;}
[data-justine-dashboard][data-theme="dark"] .attn-cta-slate{color:#8A96B0;}
[data-justine-dashboard][data-theme="dark"] .pill-building{background:rgba(99,102,241,0.22);color:#A5B4FC;}
[data-justine-dashboard][data-theme="dark"] .pill-draft{background:rgba(255,255,255,0.10);color:var(--mid);}
[data-justine-dashboard][data-theme="dark"] .progress-bar{background:#3A4260;}
[data-justine-dashboard][data-theme="dark"] .btn-primary{color:#FFFFFF;}
[data-justine-dashboard][data-theme="dark"] .proj-icon{color:#FFFFFF;}
[data-justine-dashboard][data-theme="dark"] .btn-amber{color:#FDE68A;}
[data-justine-dashboard][data-theme="dark"] [style*="color:#92400E"]{color:#FDE68A!important;}
[data-justine-dashboard][data-theme="dark"] [style*="color:#4338CA"]{color:var(--indigo)!important;}
[data-justine-dashboard][data-theme="dark"] [style*="color:#6366F1"]{color:var(--indigo)!important;}
[data-justine-dashboard][data-theme="dark"] .btn-secondary{background:#2A3250;border-color:#3A4260;color:var(--ink);}
[data-justine-dashboard][data-theme="dark"] .btn-secondary:hover{background:#323C5E;border-color:var(--indigo);}
[data-justine-dashboard][data-theme="dark"] .btn-ghost:hover{background:#323C5E;color:var(--ink);}
[data-justine-dashboard][data-theme="dark"] .sec-table th{background:#212840;border-bottom-color:#3A4260;}
[data-justine-dashboard][data-theme="dark"] .sec-table td{border-bottom-color:#3A4260;}
[data-justine-dashboard][data-theme="dark"] .sec-table tbody tr:hover td{background:#242B48;}
[data-justine-dashboard][data-theme="dark"] .modal-overlay{background:rgba(10,12,24,0.65);}
[data-justine-dashboard][data-theme="dark"] .modal-input{background:#212840;border-color:#3A4260;}
[data-justine-dashboard][data-theme="dark"] .for-card{background:#212840;border-color:#3A4260;}
[data-justine-dashboard][data-theme="dark"] .for-card:hover,[data-justine-dashboard][data-theme="dark"] .for-card.sel{background:#2A3250;}
/* Inline-style hardcode overrides */
[data-justine-dashboard][data-theme="dark"] [style*="background:#EDE9FE"]{background:rgba(165,180,252,0.45)!important;}
[data-justine-dashboard][data-theme="dark"] [style*="background:#FAFAFA"]{background:#212840!important;}
[data-justine-dashboard][data-theme="dark"] [style*="background:#F3F4F6"]{background:rgba(255,255,255,0.08)!important;}
[data-justine-dashboard][data-theme="dark"] [style*="background:#E5E7EB"]{background:#3A4260!important;}
[data-justine-dashboard][data-theme="dark"] [style*="border-color:#E5E7EB"]{border-color:#3A4260!important;}
[data-justine-dashboard][data-theme="dark"] [style*="background:#F8FAFC"]{background:#242B48!important;}
/* Scrollbars */
[data-justine-dashboard][data-theme="dark"] ::-webkit-scrollbar{width:6px;height:6px;}
[data-justine-dashboard][data-theme="dark"] ::-webkit-scrollbar-track{background:#1A1F2E;}
[data-justine-dashboard][data-theme="dark"] ::-webkit-scrollbar-thumb{background:#3A4260;border-radius:3px;}
[data-justine-dashboard][data-theme="dark"] ::-webkit-scrollbar-thumb:hover{background:#5865A0;}
[data-justine-dashboard][data-theme="dark"] *{scrollbar-color:#3A4260 #1A1F2E;scrollbar-width:thin;}

View File

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

View File

@@ -24,7 +24,6 @@ interface ProjectData {
status?: string;
}
// ── Main sidebar ─────────────────────────────────────────────────────────────
const COLLAPSED_KEY = "vibn_sidebar_collapsed";
@@ -42,7 +41,9 @@ export function VIBNSidebar({ workspace, tabs, activeTab }: VIBNSidebarProps) {
const [project, setProject] = useState<ProjectData | null>(null);
// Global projects list (used when NOT inside a project)
const [projects, setProjects] = useState<Array<{ id: string; productName: string; status?: string }>>([]);
const [projects, setProjects] = useState<
Array<{ id: string; productName: string; status?: string }>
>([]);
const activeProjectId = pathname?.match(/\/project\/([^/]+)/)?.[1] ?? null;
@@ -54,7 +55,7 @@ export function VIBNSidebar({ workspace, tabs, activeTab }: VIBNSidebarProps) {
}, []);
const toggle = () => {
setCollapsed(prev => {
setCollapsed((prev) => {
localStorage.setItem(COLLAPSED_KEY, prev ? "0" : "1");
return !prev;
});
@@ -64,34 +65,55 @@ export function VIBNSidebar({ workspace, tabs, activeTab }: VIBNSidebarProps) {
useEffect(() => {
if (activeProjectId) return;
fetch("/api/projects")
.then(r => r.json())
.then(d => setProjects(d.projects ?? []))
.then((r) => r.json())
.then((d) => setProjects(d.projects ?? []))
.catch(() => {});
}, [activeProjectId]);
// Fetch project-specific data when inside a project
useEffect(() => {
if (!activeProjectId) { setProject(null); return; }
if (!activeProjectId) {
setProject(null);
return;
}
fetch(`/api/projects/${activeProjectId}`)
.then(r => r.json())
.then(d => setProject(d.project ?? null))
.then((r) => r.json())
.then((d) => setProject(d.project ?? null))
.catch(() => {});
}, [activeProjectId]);
const isProjects = !activeProjectId && (pathname?.includes("/projects") || pathname?.includes("/project"));
const isProjects =
!activeProjectId &&
(pathname?.includes("/projects") || pathname?.includes("/project"));
const isActivity = !activeProjectId && pathname?.includes("/activity");
const isSettings = !activeProjectId && pathname?.includes("/settings");
const topNavItems = [
{ id: "projects", label: "Projects", icon: "⌗", href: `/${workspace}/projects` },
{ id: "activity", label: "Activity", icon: "↗", href: `/${workspace}/activity` },
{ id: "settings", label: "Settings", icon: "⚙", href: `/${workspace}/settings` },
{
id: "projects",
label: "Projects",
icon: "⌗",
href: `/${workspace}/projects`,
},
{
id: "activity",
label: "Activity",
icon: "↗",
href: `/${workspace}/activity`,
},
{
id: "settings",
label: "Settings",
icon: "⚙",
href: `/${workspace}/settings`,
},
];
const userInitial = session?.user?.name?.[0]?.toUpperCase()
?? session?.user?.email?.[0]?.toUpperCase()
?? "?";
const userInitial =
session?.user?.name?.[0]?.toUpperCase() ??
session?.user?.email?.[0]?.toUpperCase() ??
"?";
const w = collapsed ? COLLAPSED_W : EXPANDED_W;
const transition = mounted ? "width 0.2s cubic-bezier(0.4,0,0.2,1)" : "none";
@@ -99,80 +121,215 @@ export function VIBNSidebar({ workspace, tabs, activeTab }: VIBNSidebarProps) {
const base = `/${workspace}/project/${activeProjectId}`;
return (
<nav style={{
width: w, height: "100vh",
background: "#fff", borderRight: "1px solid #e8e4dc",
display: "flex", flexDirection: "column",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
flexShrink: 0, overflow: "hidden",
transition, position: "relative",
}}>
<nav
style={{
width: w,
height: "100vh",
background: "#fff",
borderRight: "1px solid #e8e4dc",
display: "flex",
flexDirection: "column",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
flexShrink: 0,
overflow: "hidden",
transition,
position: "relative",
}}
>
{/* ── Logo + toggle ── */}
{collapsed ? (
<div style={{ flexShrink: 0 }}>
<div style={{ display: "flex", justifyContent: "center", padding: "14px 0 6px" }}>
<Link href={`/${workspace}/projects`} title="VIBN" style={{ textDecoration: "none" }}>
<div style={{ width: 26, height: 26, borderRadius: 7, overflow: "hidden" }}>
<img src="/vibn-black-circle-logo.png" alt="VIBN" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
<div
style={{
display: "flex",
justifyContent: "center",
padding: "14px 0 6px",
}}
>
<Link
href={`/${workspace}/projects`}
title="VIBN"
style={{ textDecoration: "none" }}
>
<div
style={{
width: 26,
height: 26,
borderRadius: 7,
overflow: "hidden",
}}
>
<img
src="/vibn-black-circle-logo.png"
alt="VIBN"
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
</div>
</Link>
</div>
<div style={{ display: "flex", justifyContent: "center", paddingBottom: 8 }}>
<button onClick={toggle} title="Expand sidebar" style={{
background: "#f0ece4", border: "none", cursor: "pointer",
color: "#6b6560", width: 26, height: 20, borderRadius: 5,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.8rem", fontWeight: 700,
<div
style={{
display: "flex",
justifyContent: "center",
paddingBottom: 8,
}}
onMouseEnter={e => { (e.currentTarget.style.background = "#e0dcd4"); }}
onMouseLeave={e => { (e.currentTarget.style.background = "#f0ece4"); }}
></button>
>
<button
onClick={toggle}
title="Expand sidebar"
style={{
background: "#f0ece4",
border: "none",
cursor: "pointer",
color: "#6b6560",
width: 26,
height: 20,
borderRadius: 5,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "0.8rem",
fontWeight: 700,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "#e0dcd4";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "#f0ece4";
}}
>
</button>
</div>
</div>
) : (
<div style={{ padding: "14px 10px 14px 16px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 9, flexShrink: 0 }}>
<Link href={`/${workspace}/projects`} style={{ display: "flex", alignItems: "center", gap: 9, textDecoration: "none", minWidth: 0 }}>
<div style={{ width: 26, height: 26, borderRadius: 7, overflow: "hidden", flexShrink: 0 }}>
<img src="/vibn-black-circle-logo.png" alt="VIBN" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
<div
style={{
padding: "14px 10px 14px 16px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 9,
flexShrink: 0,
}}
>
<Link
href={`/${workspace}/projects`}
style={{
display: "flex",
alignItems: "center",
gap: 9,
textDecoration: "none",
minWidth: 0,
}}
>
<div
style={{
width: 26,
height: 26,
borderRadius: 7,
overflow: "hidden",
flexShrink: 0,
}}
>
<img
src="/vibn-black-circle-logo.png"
alt="VIBN"
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
</div>
<span style={{ fontSize: "0.92rem", fontWeight: 600, color: "#1a1a1a", letterSpacing: "-0.03em", fontFamily: "var(--font-lora), ui-serif, serif", whiteSpace: "nowrap" }}>
<span
style={{
fontSize: "0.92rem",
fontWeight: 600,
color: "#1a1a1a",
letterSpacing: "-0.03em",
fontFamily: "var(--font-lora), ui-serif, serif",
whiteSpace: "nowrap",
}}
>
vibn
</span>
</Link>
<button onClick={toggle} title="Collapse sidebar" style={{
background: "#f0ece4", border: "none", cursor: "pointer",
color: "#6b6560", width: 24, height: 22, borderRadius: 5,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.8rem", fontWeight: 700, flexShrink: 0,
}}
onMouseEnter={e => { (e.currentTarget.style.background = "#e0dcd4"); }}
onMouseLeave={e => { (e.currentTarget.style.background = "#f0ece4"); }}
></button>
<button
onClick={toggle}
title="Collapse sidebar"
style={{
background: "#f0ece4",
border: "none",
cursor: "pointer",
color: "#6b6560",
width: 24,
height: 22,
borderRadius: 5,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "0.8rem",
fontWeight: 700,
flexShrink: 0,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "#e0dcd4";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "#f0ece4";
}}
>
</button>
</div>
)}
{/* ── Top nav ── */}
<div style={{ padding: collapsed ? "2px 6px" : "2px 8px", flexShrink: 0 }}>
{topNavItems.map(n => {
const isActive = n.id === "projects" ? isProjects
: n.id === "activity" ? isActivity
: isSettings;
<div
style={{ padding: collapsed ? "2px 6px" : "2px 8px", flexShrink: 0 }}
>
{topNavItems.map((n) => {
const isActive =
n.id === "projects"
? isProjects
: n.id === "activity"
? isActivity
: isSettings;
return (
<Link key={n.id} href={n.href} title={collapsed ? n.label : undefined} style={{
width: "100%", display: "flex", alignItems: "center",
justifyContent: collapsed ? "center" : "flex-start",
gap: 8, padding: collapsed ? "8px 0" : "7px 10px",
borderRadius: 6,
background: isActive ? "#f6f4f0" : "transparent",
color: isActive ? "#1a1a1a" : "#6b6560",
fontSize: "0.8rem", fontWeight: isActive ? 600 : 500,
transition: "background 0.12s", textDecoration: "none",
}}
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
<Link
key={n.id}
href={n.href}
title={collapsed ? n.label : undefined}
style={{
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: collapsed ? "center" : "flex-start",
gap: 8,
padding: collapsed ? "8px 0" : "7px 10px",
borderRadius: 6,
background: isActive ? "#f6f4f0" : "transparent",
color: isActive ? "#1a1a1a" : "#6b6560",
fontSize: "0.8rem",
fontWeight: isActive ? 600 : 500,
transition: "background 0.12s",
textDecoration: "none",
}}
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: collapsed ? "0.95rem" : "0.78rem", opacity: collapsed ? (isActive ? 0.9 : 0.45) : 0.45, width: collapsed ? "auto" : 16, textAlign: "center" }}>
<span
style={{
fontSize: collapsed ? "0.95rem" : "0.78rem",
opacity: collapsed ? (isActive ? 0.9 : 0.45) : 0.45,
width: collapsed ? "auto" : 16,
textAlign: "center",
}}
>
{n.icon}
</span>
{!collapsed && n.label}
@@ -181,51 +338,101 @@ export function VIBNSidebar({ workspace, tabs, activeTab }: VIBNSidebarProps) {
})}
</div>
<div style={{ height: 1, background: "#eae6de", margin: "8px 14px", flexShrink: 0 }} />
<div
style={{
height: 1,
background: "#eae6de",
margin: "8px 14px",
flexShrink: 0,
}}
/>
{/* ── Lower section ── */}
<div style={{ flex: 1, overflow: "auto", paddingBottom: 8 }}>
{activeProjectId && project ? (
/* ── PROJECT VIEW: name + status + section tabs ── */
<>
{!collapsed && (
<>
<div style={{ padding: "6px 12px 8px" }}>
<div style={{ fontSize: "0.82rem", fontWeight: 700, color: "#1a1a1a", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
<div
style={{
fontSize: "0.82rem",
fontWeight: 700,
color: "#1a1a1a",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{project.productName || project.name || "Project"}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 5, marginTop: 3 }}>
<span style={{
width: 6, height: 6, borderRadius: "50%", flexShrink: 0, display: "inline-block",
background: project.status === "live" ? "#2e7d32"
: project.status === "building" ? "#3d5afe"
: "#d4a04a",
}} />
<div
style={{
display: "flex",
alignItems: "center",
gap: 5,
marginTop: 3,
}}
>
<span
style={{
width: 6,
height: 6,
borderRadius: "50%",
flexShrink: 0,
display: "inline-block",
background:
project.status === "live"
? "#2e7d32"
: project.status === "building"
? "#3d5afe"
: "#d4a04a",
}}
/>
<span style={{ fontSize: "0.68rem", color: "#8a8478" }}>
{project.status === "live" ? "Live" : project.status === "building" ? "Building" : "Defining"}
{project.status === "live"
? "Live"
: project.status === "building"
? "Building"
: "Defining"}
</span>
</div>
</div>
{tabs && tabs.length > 0 && (
<div style={{ padding: "2px 8px" }}>
{tabs.map(t => {
{tabs.map((t) => {
const isActive = activeTab === t.id;
return (
<Link
key={t.id}
href={`/${workspace}/project/${activeProjectId}/${t.path}`}
style={{
width: "100%", display: "flex", alignItems: "center",
padding: "7px 10px", borderRadius: 6,
width: "100%",
display: "flex",
alignItems: "center",
padding: "7px 10px",
borderRadius: 6,
background: isActive ? "#f6f4f0" : "transparent",
color: isActive ? "#1a1a1a" : "#6b6560",
fontSize: "0.8rem", fontWeight: isActive ? 600 : 500,
transition: "background 0.12s", textDecoration: "none",
fontSize: "0.8rem",
fontWeight: isActive ? 600 : 500,
transition: "background 0.12s",
textDecoration: "none",
}}
onMouseEnter={(e) => {
if (!isActive)
(
e.currentTarget as HTMLElement
).style.background = "#f6f4f0";
}}
onMouseLeave={(e) => {
if (!isActive)
(
e.currentTarget as HTMLElement
).style.background = "transparent";
}}
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
{t.label}
</Link>
@@ -236,36 +443,69 @@ export function VIBNSidebar({ workspace, tabs, activeTab }: VIBNSidebarProps) {
</>
)}
{collapsed && (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", paddingTop: 8, gap: 6 }}>
<span style={{
width: 7, height: 7, borderRadius: "50%", display: "inline-block",
background: project.status === "live" ? "#2e7d32"
: project.status === "building" ? "#3d5afe"
: "#d4a04a",
}} title={project.productName || project.name} />
{tabs && tabs.map(t => {
const isActive = activeTab === t.id;
return (
<Link
key={t.id}
href={`/${workspace}/project/${activeProjectId}/${t.path}`}
title={t.label}
style={{
width: 28, height: 28, borderRadius: 6, display: "flex",
alignItems: "center", justifyContent: "center",
background: isActive ? "#f6f4f0" : "transparent",
color: isActive ? "#1a1a1a" : "#a09a90",
fontSize: "0.6rem", fontWeight: 700, textDecoration: "none",
textTransform: "uppercase", letterSpacing: "0.02em",
transition: "background 0.12s",
}}
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
{t.label.slice(0, 2)}
</Link>
);
})}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
paddingTop: 8,
gap: 6,
}}
>
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
display: "inline-block",
background:
project.status === "live"
? "#2e7d32"
: project.status === "building"
? "#3d5afe"
: "#d4a04a",
}}
title={project.productName || project.name}
/>
{tabs &&
tabs.map((t) => {
const isActive = activeTab === t.id;
return (
<Link
key={t.id}
href={`/${workspace}/project/${activeProjectId}/${t.path}`}
title={t.label}
style={{
width: 28,
height: 28,
borderRadius: 6,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: isActive ? "#f6f4f0" : "transparent",
color: isActive ? "#1a1a1a" : "#a09a90",
fontSize: "0.6rem",
fontWeight: 700,
textDecoration: "none",
textTransform: "uppercase",
letterSpacing: "0.02em",
transition: "background 0.12s",
}}
onMouseEnter={(e) => {
if (!isActive)
(e.currentTarget as HTMLElement).style.background =
"#f6f4f0";
}}
onMouseLeave={(e) => {
if (!isActive)
(e.currentTarget as HTMLElement).style.background =
"transparent";
}}
>
{t.label.slice(0, 2)}
</Link>
);
})}
</div>
)}
</>
@@ -273,32 +513,77 @@ export function VIBNSidebar({ workspace, tabs, activeTab }: VIBNSidebarProps) {
/* ── GLOBAL VIEW: projects list ── */
<div style={{ padding: collapsed ? "2px 6px" : "2px 8px" }}>
{!collapsed && (
<div style={{ fontSize: "0.58rem", fontWeight: 600, color: "#a09a90", letterSpacing: "0.1em", textTransform: "uppercase", padding: "6px 10px 8px" }}>
<div
style={{
fontSize: "0.58rem",
fontWeight: 600,
color: "#a09a90",
letterSpacing: "0.1em",
textTransform: "uppercase",
padding: "6px 10px 8px",
}}
>
Projects
</div>
)}
{projects.map(p => {
{projects.map((p) => {
const isActive = activeProjectId === p.id;
const color = p.status === "live" ? "#2e7d32" : p.status === "building" ? "#3d5afe" : "#d4a04a";
const color =
p.status === "live"
? "#2e7d32"
: p.status === "building"
? "#3d5afe"
: "#d4a04a";
return (
<Link key={p.id} href={`/${workspace}/project/${p.id}`}
<Link
key={p.id}
href={`/${workspace}/project/${p.id}`}
title={collapsed ? p.productName : undefined}
style={{
width: "100%", display: "flex", alignItems: "center",
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: collapsed ? "center" : "flex-start",
gap: 9, padding: collapsed ? "9px 0" : "7px 10px",
gap: 9,
padding: collapsed ? "9px 0" : "7px 10px",
borderRadius: 6,
background: isActive ? "#f6f4f0" : "transparent",
color: "#1a1a1a", fontSize: "0.8rem",
color: "#1a1a1a",
fontSize: "0.8rem",
fontWeight: isActive ? 600 : 450,
transition: "background 0.12s", textDecoration: "none", overflow: "hidden",
transition: "background 0.12s",
textDecoration: "none",
overflow: "hidden",
}}
onMouseEnter={(e) => {
if (!isActive)
(e.currentTarget as HTMLElement).style.background =
"#f6f4f0";
}}
onMouseLeave={(e) => {
if (!isActive)
(e.currentTarget as HTMLElement).style.background =
"transparent";
}}
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: color, display: "inline-block", flexShrink: 0 }} />
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: color,
display: "inline-block",
flexShrink: 0,
}}
/>
{!collapsed && (
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{p.productName}
</span>
)}
@@ -310,32 +595,68 @@ export function VIBNSidebar({ workspace, tabs, activeTab }: VIBNSidebarProps) {
</div>
{/* ── User footer ── */}
<div style={{
padding: collapsed ? "10px 0" : "12px 14px",
borderTop: "1px solid #eae6de",
display: "flex", alignItems: "center",
justifyContent: collapsed ? "center" : "flex-start",
gap: 9, flexShrink: 0,
}}>
<div title={collapsed ? (session?.user?.name ?? session?.user?.email ?? "Account") : undefined}
<div
style={{
padding: collapsed ? "10px 0" : "12px 14px",
borderTop: "1px solid #eae6de",
display: "flex",
alignItems: "center",
justifyContent: collapsed ? "center" : "flex-start",
gap: 9,
flexShrink: 0,
}}
>
<div
title={
collapsed
? (session?.user?.name ?? session?.user?.email ?? "Account")
: undefined
}
style={{
width: 26, height: 26, borderRadius: "50%",
background: "#f0ece4", display: "flex", alignItems: "center",
justifyContent: "center", fontSize: "0.7rem", fontWeight: 600,
color: "#8a8478", flexShrink: 0, cursor: "default",
}}>
width: 26,
height: 26,
borderRadius: "50%",
background: "#f0ece4",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "0.7rem",
fontWeight: 600,
color: "#8a8478",
flexShrink: 0,
cursor: "default",
}}
>
{userInitial}
</div>
{!collapsed && (
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: "0.76rem", fontWeight: 500, color: "#1a1a1a", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{session?.user?.name ?? session?.user?.email?.split("@")[0] ?? "Account"}
<div
style={{
fontSize: "0.76rem",
fontWeight: 500,
color: "#1a1a1a",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{session?.user?.name ??
session?.user?.email?.split("@")[0] ??
"Account"}
</div>
<button onClick={() => signOut({ callbackUrl: "/auth" })} style={{
background: "none", border: "none", padding: 0,
fontSize: "0.62rem", color: "#a09a90", cursor: "pointer",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
}}>
<button
onClick={() => signOut({ callbackUrl: "/signin" })}
style={{
background: "none",
border: "none",
padding: 0,
fontSize: "0.62rem",
color: "#a09a90",
cursor: "pointer",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
}}
>
Sign out
</button>
</div>

View File

@@ -1,205 +0,0 @@
/**
* Marketing content for the homepage
* Centralized location for all copy to make updates easier
*/
export const homepage = {
hero: {
title: "AI Coding Tools for Vibe Coders.",
subtitle: "We take you from idea to market and beyond — so you can finally finish what you start and share it with the world.",
cta: {
primary: "Start Your Idea",
secondary: "See How It Works",
},
},
emotionalHook: {
title: "You've been waiting",
subtitle: "Now you can",
description: "You've had the ideas. You've started the projects. But somewhere between no-code limitations and overwhelming dev tools, momentum stalls. Not anymore.",
},
whoItsFor: {
title: "Creators stuck between ideas and code",
subtitle: "You're a Vibe Coder",
description: "You're ambitious. You have vision. You know what you want to build. But you're not fluent in code yet, and no-code tools feel limiting. You're stuck in the gap — and we built Vibn to meet you there.",
traits: [
"You have big ideas but hit walls with no-code tools",
"You want to learn to code, but need to ship while you learn",
"You're tired of starting projects that never launch",
"You need guidance, not just more tutorials",
"You want to build real products, not just prototypes",
],
},
transformation: {
title: "From stalled chaos to unstoppable momentum",
description: "When you join Vibn, something shifts. The overwhelm fades. The path becomes clear. You're not just learning — you're building. You're not just building — you're shipping. And you're not alone.",
outcomes: [
"Clarity on what to build next",
"Confidence in your technical decisions",
"Momentum that carries you forward",
"A product you're proud to share",
],
},
features: {
title: "Everything you need in one calm, guided flow",
description: "Vibn gives you structure without rigidity, guidance without hand-holding, and momentum without overwhelm.",
list: [
{
title: "Project Home",
description: "One central place for your vision, progress, and next steps.",
},
{
title: "Scope & Roadmap",
description: "Define what you're building and break it into achievable milestones.",
},
{
title: "AI Guardrails",
description: "Keep AI coding assistants on track with your project's scope and standards.",
},
{
title: "Build, Don't Babysit",
description: "Track your coding sessions, costs, and progress automatically.",
},
{
title: "Backend & Hosting Simplified",
description: "Deploy with confidence. No DevOps degree required.",
},
{
title: "v0 Integration",
description: "Generate beautiful UI components and iterate visually.",
},
{
title: "Collaboration & Chat",
description: "Get unstuck with AI and human support that understands your project.",
},
{
title: "Launch Suite",
description: "Go from localhost to production with guided deployment.",
},
{
title: "Market & Grow",
description: "Tools to help you share your product and find your first users.",
},
{
title: "Progress & Cost Tracking",
description: "Stay on budget. Stay motivated. See how far you've come.",
},
],
},
howItWorks: {
title: "Plan. Build. Launch. Grow",
description: "Vibn guides you from idea to market, one clear step at a time.",
steps: [
{
number: 1,
title: "Start with your idea",
description: "Tell us what you want to build. We'll help you define scope and create a roadmap that feels achievable.",
},
{
number: 2,
title: "Build with AI assistance",
description: "Use Cursor, ChatGPT, and other AI tools — but with guardrails that keep you on track and on budget.",
},
{
number: 3,
title: "Launch with confidence",
description: "Deploy your product with guided setup for hosting, domains, and everything you need to go live.",
},
{
number: 4,
title: "Grow your product",
description: "Market your launch, gather feedback, and iterate with the same support that got you here.",
},
],
},
pricing: {
title: "Simple, fair, transparent",
description: "Pay for what you use. No surprises. No hidden fees.",
tiers: [
{
name: "Starter",
price: "Free",
description: "For exploring and getting started",
features: [
"1 active project",
"Basic AI tracking",
"Community support",
"7-day history",
],
},
{
name: "Creator",
price: "$29/mo",
description: "For builders shipping real products",
features: [
"5 active projects",
"Full AI tracking & analytics",
"Priority support",
"Unlimited history",
"Deployment guides",
"v0 integration",
],
highlighted: true,
},
{
name: "Studio",
price: "$99/mo",
description: "For teams and agencies",
features: [
"Unlimited projects",
"Team collaboration",
"White-label options",
"Custom integrations",
"Dedicated support",
"SLA guarantee",
],
},
],
},
finalCTA: {
title: "Start your idea — we'll meet you where you are and help you finish what you start.",
cta: {
primary: "Get Started Free",
secondary: "Book a Demo",
},
},
meta: {
title: "VIBN - From Ideas to Market for Vibe Coders",
description: "AI coding tools aren't made for you. We are. Take your product from idea to market with guidance, structure, and momentum.",
},
};
/**
* Future content sections (from PROJECT_INSTRUCTIONS.md)
* TODO: Implement these sections
*/
export const futureContent = {
emotionalHook: {
title: "You've been waiting. Now you can.",
// Short, empathetic copy about frustration and transition
},
whoItsFor: {
title: "Creators stuck between ideas and code",
// Describes Vibe Coders: ambitious, not yet fluent in code
},
transformation: {
title: "From stalled chaos to unstoppable momentum",
// Emotional shift when users join VIBN
},
howItWorks: {
title: "Plan. Build. Launch. Grow.",
// Step-based explanation
},
pricing: {
title: "Simple, fair, transparent",
// Pricing tiers
},
};

View File

@@ -1 +0,0 @@
// Public components exports from subdirectories when needed

View File

@@ -1,21 +0,0 @@
import Link from "next/link";
/** Compact nav from justine/02_signup.html — use inside [data-justine-auth] + 02-signup.css */
export function JustineAuthShell({ children }: { children: React.ReactNode }) {
return (
<>
<nav className="justine-auth-nav" aria-label="Auth">
<Link href="/" className="justine-auth-nav-brand">
<div className="justine-auth-nav-logo">
<span className="f">V</span>
</div>
<span className="justine-auth-nav-wordmark f">vibn</span>
</Link>
<span className="justine-auth-nav-aside">
New to vibn? <Link href="/">View homepage</Link>
</span>
</nav>
<div className="justine-auth-main">{children}</div>
</>
);
}

View File

@@ -1,32 +0,0 @@
import Link from "next/link";
/** Footer from justine/01_homepage.html */
export function JustineFooter() {
return (
<footer>
<div>
<span className="f" style={{ fontSize: 16, fontWeight: 700, color: "var(--ink)" }}>
vibn
</span>
<span className="footer-tagline">The fastest way from idea to product.</span>
</div>
<div className="footer-links">
<Link href="/#how-it-works" style={{ fontSize: 13, color: "var(--muted)", textDecoration: "none" }}>
How it works
</Link>
<Link href="/pricing" style={{ fontSize: 13, color: "var(--muted)", textDecoration: "none" }}>
Pricing
</Link>
<Link href="/privacy" style={{ fontSize: 13, color: "var(--muted)", textDecoration: "none" }}>
Privacy
</Link>
<Link href="/terms" style={{ fontSize: 13, color: "var(--muted)", textDecoration: "none" }}>
Terms
</Link>
</div>
<span style={{ fontSize: 12.5, color: "var(--muted)", textAlign: "right", display: "block" }}>
© {new Date().getFullYear()} vibn
</span>
</footer>
);
}

View File

@@ -1,518 +0,0 @@
import type { CSSProperties } from "react";
import Link from "next/link";
/**
* Body sections from justine/01_homepage.html — inline styles + classes match the source file.
* Lives under [data-justine]; tokens are --ink, --mid, --muted, --border, --white (see 01-homepage.css).
*/
export function JustineHomePage() {
return (
<div className="justine-home-page">
<section
className="hero-section"
style={{ maxWidth: 980, margin: "0 auto", padding: "88px 52px 72px" }}
>
<div className="hero-grid">
<div>
<div
style={{
fontSize: 11,
fontWeight: 600,
letterSpacing: "0.13em",
textTransform: "uppercase",
color: "var(--muted)",
marginBottom: 22,
}}
>
For non-technical founders
</div>
<h1
className="f hero-h1"
style={{
fontSize: 58,
fontWeight: 700,
color: "var(--ink)",
letterSpacing: "-0.03em",
lineHeight: 1.06,
marginBottom: 28,
}}
>
You have the idea.
<br />
We handle
<br />
<em className="gradient-em">everything else.</em>
</h1>
<p className="hero-sub" style={{ fontSize: 17, color: "var(--mid)", lineHeight: 1.75 }}>
You describe it. Vibn builds it, launches it, and markets it. From idea to{" "}
<strong style={{ color: "var(--ink)" }}>live</strong> product in{" "}
<strong style={{ color: "var(--ink)" }}>72 hours</strong> no code, no agencies, no
waiting.
</p>
</div>
<div style={{ flexShrink: 0 }}>
<div
style={{
background: "var(--white)",
border: "1px solid var(--border)",
borderRadius: 16,
overflow: "hidden",
boxShadow: "0 20px 60px rgba(30,27,75,0.05)",
}}
>
<div
style={{
padding: "24px 26px 20px",
background: "#FCFCFF",
borderBottom: "1px solid var(--border)",
}}
>
<div
style={{
fontSize: 10,
fontWeight: 600,
letterSpacing: "0.12em",
textTransform: "uppercase",
color: "var(--muted)",
marginBottom: 12,
}}
>
Your idea
</div>
<p
className="f"
style={{
fontSize: 15,
fontStyle: "italic",
color: "var(--ink)",
lineHeight: 1.65,
marginBottom: 14,
}}
>
&quot;I want to build a booking tool for independent personal trainers.&quot;
</p>
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<span
style={{
fontSize: 11,
color: "var(--muted)",
background: "var(--white)",
border: "1px solid var(--border)",
borderRadius: 5,
padding: "3px 9px",
letterSpacing: "0.04em",
}}
>
Enter
</span>
</div>
</div>
<div style={{ padding: "20px 26px 24px", background: "var(--white)" }}>
<div
style={{
fontSize: 10,
fontWeight: 600,
letterSpacing: "0.12em",
textTransform: "uppercase",
color: "var(--muted)",
marginBottom: 16,
}}
>
vibn generated
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
{[
["Pages", "Landing, Dashboard, Booking, Payments"],
["Stack", "Auth, database, payments — handled"],
["Revenue", "Subscription · $29 / mo"],
].map(([k, v]) => (
<div
key={k}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
padding: "10px 0",
borderBottom: "1px solid var(--border)",
}}
>
<span style={{ fontSize: 12, color: "var(--muted)", fontWeight: 500 }}>{k}</span>
<span style={{ fontSize: 13, color: "var(--ink)", fontWeight: 600 }}>{v}</span>
</div>
))}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
padding: "10px 0",
}}
>
<span style={{ fontSize: 12, color: "var(--muted)", fontWeight: 500 }}>Status</span>
<span style={{ fontSize: 13, fontWeight: 600, color: "#6366F1" }}>
&nbsp; Ready to build
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
textAlign: "center",
gap: 10,
marginTop: 52,
}}
>
<Link href="/auth?new=1">
<button type="button" className="btn-ink-lg">
Start free no code needed
</button>
</Link>
<div>
<span style={{ fontSize: 13.5, color: "#818CF8" }}></span>
<span style={{ fontSize: 13.5, color: "var(--stone)" }}>
&nbsp;&nbsp;280 founders launched
</span>
</div>
<p style={{ fontSize: 12, color: "#9CA3AF" }}>No credit card required · Free forever plan</p>
<Link
href="/#how-it-works"
style={{
fontSize: 13.5,
color: "#6366F1",
textDecoration: "none",
fontWeight: 500,
marginTop: 4,
}}
>
See how it works
</Link>
</div>
</section>
<section
className="empathy-section"
style={{ borderTop: "1px solid var(--border)", borderBottom: "1px solid var(--border)", padding: "80px 52px" }}
>
<div style={{ maxWidth: 980, margin: "0 auto" }}>
<div className="empathy-grid">
<div>
<div
style={{
fontSize: 11,
fontWeight: 600,
letterSpacing: "0.13em",
textTransform: "uppercase",
color: "var(--muted)",
marginBottom: 18,
}}
>
Sound familiar?
</div>
<h2
className="f"
style={{
fontSize: 36,
fontWeight: 700,
color: "#1A1A1A",
lineHeight: 1.18,
marginBottom: 24,
letterSpacing: "-0.02em",
}}
>
The idea is the hard part.{" "}
<span className="gradient-text">Everything else shouldn&apos;t be.</span>
</h2>
<p style={{ fontSize: 15, color: "var(--mid)", lineHeight: 1.82, marginBottom: 20 }}>
You know exactly what you want to build and who it&apos;s for. But the moment you
think about servers, databases, deployment pipelines, SEO the whole thing stalls.
</p>
<p style={{ fontSize: 15, color: "var(--mid)", lineHeight: 1.82 }}>
vibn exists to remove all of that. Not abstract it {" "}
<em className="f" style={{ fontStyle: "italic" }}>
remove it entirely.
</em>
</p>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
{[
{
t: `No more "I need to hire a developer first"`,
d: "vibn is your developer. Start building the moment you have an idea.",
},
{
t: "No more staring at a blank marketing calendar",
d: "AI generates and publishes your content every single week.",
},
{
t: `No more "I'll launch when it's ready"`,
d: "Most founders ship their first version in under 72 hours.",
},
].map((row) => (
<div key={row.t} className="empathy-card">
<div
style={{
width: 20,
height: 20,
borderRadius: "50%",
border: "1.5px solid rgba(99,102,241,0.4)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
marginTop: 2,
}}
>
<div
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: "#6366F1",
}}
/>
</div>
<div>
<div className="f" style={{ fontSize: 14, fontWeight: 600, color: "#1A1A1A", marginBottom: 4 }}>
{row.t}
</div>
<div style={{ fontSize: 13, color: "var(--mid)", lineHeight: 1.7 }}>{row.d}</div>
</div>
</div>
))}
</div>
</div>
</div>
</section>
<section id="how-it-works" className="how-section" style={{ maxWidth: 980, margin: "0 auto", padding: "84px 52px" }}>
<div
style={{
fontSize: 11,
fontWeight: 600,
letterSpacing: "0.13em",
textTransform: "uppercase",
color: "var(--muted)",
marginBottom: 16,
}}
>
How it works
</div>
<h2
className="f"
style={{
fontSize: 42,
fontWeight: 700,
color: "#1A1A1A",
letterSpacing: "-0.02em",
marginBottom: 54,
maxWidth: 480,
lineHeight: 1.15,
}}
>
Four phases. One <span className="gradient-text">complete</span> product.
</h2>
<div className="phase-grid">
{[
{
k: "01 — Discover",
t: "Define your idea",
p: "Six guided questions turn a rough idea into a full product plan — pages, architecture, revenue model. No jargon.",
style: {
padding: "40px 44px",
background: "var(--white)",
borderRight: "1px solid rgba(99,102,241,0.2)",
borderBottom: "1px solid rgba(99,102,241,0.2)",
} satisfies CSSProperties,
},
{
k: "02 — Design",
t: "Choose your style",
p: "Pick a visual style and see your exact site and emails live before a single line of code is written.",
style: {
padding: "40px 44px",
background: "var(--white)",
borderBottom: "1px solid rgba(99,102,241,0.2)",
} satisfies CSSProperties,
},
{
k: "03 — Build",
t: "Your app, live",
p: "AI writes every line. Auth, database, payments, all pages — deployed and live. Describe changes in plain English.",
style: {
padding: "40px 44px",
background: "var(--white)",
borderRight: "1px solid rgba(99,102,241,0.2)",
} satisfies CSSProperties,
},
{
k: "04 — Grow",
t: "Market & automate",
p: "AI generates your blog, emails, and social schedule — publishing on autopilot so you can focus on users.",
style: { padding: "40px 44px", background: "var(--white)" } satisfies CSSProperties,
},
].map((cell) => (
<div key={cell.k} style={cell.style}>
<div
style={{
fontSize: 11,
fontWeight: 600,
letterSpacing: "0.1em",
textTransform: "uppercase",
color: "rgba(99,102,241,0.6)",
marginBottom: 14,
}}
>
{cell.k}
</div>
<div className="f" style={{ fontSize: 22, fontWeight: 700, color: "#1A1A1A", marginBottom: 10 }}>
{cell.t}
</div>
<p style={{ fontSize: 13.5, color: "var(--mid)", lineHeight: 1.72 }}>{cell.p}</p>
</div>
))}
</div>
</section>
<section style={{ background: "var(--white)", borderTop: "1px solid var(--border)", borderBottom: "1px solid var(--border)" }}>
<div className="wyg-grid wyg-section" style={{ maxWidth: 980, margin: "0 auto", padding: "0 52px" }}>
<div style={{ padding: "44px 40px 44px 0", borderRight: "1px solid var(--border)" }}>
<div style={{ fontSize: 13, fontWeight: 700, color: "#6366F1", marginBottom: 12, textAlign: "center" }}></div>
<div className="f" style={{ fontSize: 17, fontWeight: 700, color: "#1A1A1A", marginBottom: 8, textAlign: "center" }}>
A live, working product
</div>
<p style={{ fontSize: 13.5, color: "var(--mid)", lineHeight: 1.7, textAlign: "center" }}>
Not a prototype. Real auth, real payments, real database on your own URL from day one.
</p>
<p style={{ fontSize: 12, color: "var(--muted)", lineHeight: 1.6, textAlign: "center", marginTop: 10 }}>
Runs on your own servers your data, your infrastructure, no lock-in.
</p>
</div>
<div style={{ padding: "44px 40px", borderRight: "1px solid var(--border)" }}>
<div style={{ fontSize: 13, fontWeight: 700, color: "#6366F1", marginBottom: 12, textAlign: "center" }}></div>
<div className="f" style={{ fontSize: 17, fontWeight: 700, color: "#1A1A1A", marginBottom: 8, textAlign: "center" }}>
A full marketing engine
</div>
<p style={{ fontSize: 13.5, color: "var(--mid)", lineHeight: 1.7, textAlign: "center" }}>
Blog posts, onboarding emails, and social content written and published automatically every week.
</p>
</div>
<div style={{ padding: "44px 0 44px 40px" }}>
<div style={{ fontSize: 13, fontWeight: 700, color: "#6366F1", marginBottom: 12, textAlign: "center" }}></div>
<div className="f" style={{ fontSize: 17, fontWeight: 700, color: "#1A1A1A", marginBottom: 8, textAlign: "center" }}>
A product that evolves
</div>
<p style={{ fontSize: 13.5, color: "var(--mid)", lineHeight: 1.7, textAlign: "center" }}>
Describe changes in plain English. Vibn handles the code so your product grows as fast as your ideas.
</p>
</div>
</div>
</section>
<section className="quote-section" style={{ background: "#1A1A1A", padding: "32px 52px 28px" }}>
<div style={{ maxWidth: 980, margin: "0 auto" }}>
<div className="quote-grid">
<div className="quote-side" style={{ display: "flex", gap: 14, opacity: 0.85 }}>
<div style={{ width: 2, background: "#6366F1", borderRadius: 2, flexShrink: 0 }} />
<div>
<p className="f" style={{ fontSize: 12.5, color: "#FFFFFF", lineHeight: 1.65, fontStyle: "italic", marginBottom: 8 }}>
&quot;I had the idea for 2 years. The backend terrified me. vibn shipped it in 4 days and handles all my marketing.&quot;
</p>
<span style={{ fontSize: 10.5, color: "var(--muted)", fontWeight: 600 }}> Alex K., founder of Taskly</span>
</div>
</div>
<div style={{ background: "rgba(255,255,255,0.05)", borderRadius: 12, padding: "22px 26px" }}>
<div style={{ width: 3, height: 16, background: "#6366F1", borderRadius: 2, marginBottom: 12, opacity: 0.7 }} />
<p className="f" style={{ fontSize: 16, color: "#FFFFFF", lineHeight: 1.7, fontStyle: "italic", marginBottom: 12 }}>
&quot;I have zero coding experience. Three weeks in, I have 300 paying users. That&apos;s entirely because of vibn.&quot;
</p>
<span style={{ fontSize: 11, color: "var(--muted)", fontWeight: 600 }}> Marcus L., founder of Flowmatic</span>
</div>
<div className="quote-side" style={{ display: "flex", gap: 14, opacity: 0.85 }}>
<div style={{ width: 2, background: "#6366F1", borderRadius: 2, flexShrink: 0 }} />
<div>
<p className="f" style={{ fontSize: 12.5, color: "#FFFFFF", lineHeight: 1.65, fontStyle: "italic", marginBottom: 8 }}>
&quot;The marketing autopilot saved me ten hours a week. My blog runs itself. I just focus on product.&quot;
</p>
<span style={{ fontSize: 10.5, color: "var(--muted)", fontWeight: 600 }}> Sara R., founder of Nudge</span>
</div>
</div>
</div>
<div style={{ display: "flex", justifyContent: "center", gap: 7 }}>
<div style={{ width: 5, height: 5, borderRadius: "50%", background: "rgba(255,255,255,0.3)" }} />
<div style={{ width: 16, height: 5, borderRadius: 3, background: "#FFFFFF" }} />
<div style={{ width: 5, height: 5, borderRadius: "50%", background: "rgba(255,255,255,0.3)" }} />
</div>
</div>
</section>
<section style={{ background: "var(--white)", borderTop: "1px solid var(--border)", borderBottom: "1px solid var(--border)" }}>
<div className="stats-grid stats-section" style={{ maxWidth: 980, margin: "0 auto", padding: "0 52px" }}>
{[
{ num: "280+", label: "founders launched", pl: 0, pr: true },
{ num: "72h", label: "average time to first version", pl: 36, pr: true },
{ num: "4.9★", label: "average rating", pl: 36, pr: true },
{ num: "3×", label: "faster than hiring a developer", pl: 36, pr: false },
].map((row) => (
<div
key={row.label}
style={{
padding: row.pl ? `40px 0 40px ${row.pl}px` : "40px 0",
borderRight: row.pr ? "1px solid var(--border)" : undefined,
}}
>
<div className="f gradient-num" style={{ fontSize: 40, fontWeight: 700, letterSpacing: "-0.03em", marginBottom: 6 }}>
{row.num}
</div>
<div style={{ fontSize: 13, color: "var(--muted)" }}>{row.label}</div>
</div>
))}
</div>
</section>
<section className="cta-section" style={{ padding: "80px 52px", textAlign: "center" }}>
<div
className="cta-card"
style={{
maxWidth: 680,
margin: "0 auto",
background: "#FFFFFF",
borderRadius: 20,
padding: "64px 52px",
boxShadow: "0 0 0 1px rgba(99,102,241,0.15),0 20px 60px rgba(30,27,75,0.08)",
}}
>
<h2
className="f"
style={{
fontSize: 48,
fontWeight: 700,
color: "var(--ink)",
letterSpacing: "-0.03em",
lineHeight: 1.1,
marginBottom: 20,
}}
>
Your idea deserves to exist.
</h2>
<p style={{ fontSize: 16, color: "var(--mid)", lineHeight: 1.75, marginBottom: 38 }}>
Thousands of ideas never make it past a spreadsheet. Yours doesn&apos;t have to be one of them.
</p>
<Link href="/auth?new=1">
<button type="button" className="btn-ink-lg" style={{ marginBottom: 16 }}>
Build my product free
</button>
</Link>
<div style={{ fontSize: 12.5, color: "var(--muted)" }}>Joins 280+ non-technical founders already live</div>
</div>
</section>
</div>
);
}

View File

@@ -1,115 +0,0 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
/** Nav from justine/01_homepage.html — classes defined in app/styles/justine/01-homepage.css */
export function JustineNav() {
const [open, setOpen] = useState(false);
const close = useCallback(() => {
setOpen(false);
document.body.style.overflow = "";
}, []);
const toggle = useCallback(() => {
setOpen((o) => {
const next = !o;
document.body.style.overflow = next ? "hidden" : "";
return next;
});
}, []);
useEffect(() => () => {
document.body.style.overflow = "";
}, []);
return (
<>
<nav>
<Link href="/" style={{ display: "flex", alignItems: "center", gap: 10, textDecoration: "none" }}>
<div
className="logo-box"
style={{
width: 30,
height: 30,
background: "linear-gradient(135deg,#2E2A5E,#4338CA)",
borderRadius: 7,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<span className="f" style={{ fontSize: 15, fontWeight: 700, color: "#FFFFFF" }}>
V
</span>
</div>
<span className="f" style={{ fontSize: 19, fontWeight: 700, color: "var(--ink)", letterSpacing: "-0.02em" }}>
vibn
</span>
</Link>
<div className="nav-links">
<Link href="/#how-it-works" style={{ fontSize: 14, color: "var(--muted)", textDecoration: "none" }}>
How it works
</Link>
<Link href="/pricing" style={{ fontSize: 14, color: "var(--muted)", textDecoration: "none" }}>
Pricing
</Link>
<Link href="/stories" style={{ fontSize: 14, color: "var(--muted)", textDecoration: "none" }}>
Stories
</Link>
<span style={{ fontSize: 14, color: "var(--muted)" }}>Blog</span>
</div>
<div className="nav-right-btns" style={{ display: "flex", alignItems: "center", gap: 12 }}>
<Link href="/auth" style={{ fontSize: 14, color: "#6366F1", fontWeight: 600, textDecoration: "none" }}>
Log in
</Link>
<Link href="/auth?new=1">
<button type="button" className="btn-ink">
Get started free
</button>
</Link>
</div>
<button
type="button"
className={`hamburger ${open ? "open" : ""}`}
aria-label={open ? "Close menu" : "Open menu"}
aria-expanded={open}
onClick={toggle}
>
<span />
<span />
<span />
</button>
</nav>
<div className={`mobile-menu ${open ? "open" : ""}`}>
<Link href="/#how-it-works" onClick={close}>
How it works
</Link>
<Link href="/pricing" onClick={close}>
Pricing
</Link>
<Link href="/stories" onClick={close}>
Stories
</Link>
<Link href="#" onClick={(e) => { e.preventDefault(); close(); }}>
Blog
</Link>
<Link href="/auth" style={{ color: "#6366F1", fontWeight: 600 }} onClick={close}>
Log in
</Link>
<div className="mobile-menu-cta">
<Link href="/auth?new=1" onClick={close} style={{ display: "block", width: "100%" }}>
<button type="button" className="btn-ink-lg" style={{ width: "100%" }}>
Get started free
</button>
</Link>
</div>
</div>
</>
);
}

View File

@@ -1,4 +0,0 @@
export { JustineNav } from "./JustineNav";
export { JustineFooter } from "./JustineFooter";
export { JustineHomePage } from "./JustineHomePage";
export { JustineAuthShell } from "./JustineAuthShell";

File diff suppressed because it is too large Load Diff

View File

@@ -2163,8 +2163,8 @@ export function ChatPanel({
);
const authHref = pathname
? `/auth?callbackUrl=${encodeURIComponent(pathname)}`
: "/auth";
? `/signin?callbackUrl=${encodeURIComponent(pathname)}`
: "/signin";
const structuralChatSignedOutColumn = (
<div

View File

@@ -15,19 +15,16 @@ const isLocalNextAuth =
(process.env.NODE_ENV === "development" && !nextAuthUrl);
/** Set in .env.local (server + client): one email for local dev bypass. */
const devLocalEmail = (process.env.NEXT_PUBLIC_DEV_LOCAL_AUTH_EMAIL ?? "").trim();
const devLocalEmail = (
process.env.NEXT_PUBLIC_DEV_LOCAL_AUTH_EMAIL ?? ""
).trim();
const devLocalSecret = (process.env.DEV_LOCAL_AUTH_SECRET ?? "").trim();
const devLocalAuthEnabled =
process.env.NODE_ENV === "development" && devLocalEmail.length > 0;
function isLocalhostHost(host: string): boolean {
const h = host.split(":")[0]?.toLowerCase() ?? "";
return (
h === "localhost" ||
h === "127.0.0.1" ||
h === "[::1]" ||
h === "::1"
);
return h === "localhost" || h === "127.0.0.1" || h === "[::1]" || h === "::1";
}
export const authOptions: NextAuthOptions = {
@@ -44,7 +41,8 @@ export const authOptions: NextAuthOptions = {
password: { label: "Dev secret", type: "password" },
},
async authorize(credentials, req) {
const headers = (req as { headers?: Headers } | undefined)?.headers;
const headers = (req as { headers?: Headers } | undefined)
?.headers;
const host =
headers && typeof headers.get === "function"
? (headers.get("host") ?? "")
@@ -87,8 +85,8 @@ export const authOptions: NextAuthOptions = {
}),
],
pages: {
signIn: "/auth",
error: "/auth",
signIn: "/signin",
error: "/signin",
},
callbacks: {
async session({ session, user }) {
@@ -101,7 +99,10 @@ export const authOptions: NextAuthOptions = {
if (!user?.email) return true;
try {
const workspace =
user.email.split("@")[0].toLowerCase().replace(/[^a-z0-9]+/g, "-") + "-account";
user.email
.split("@")[0]
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-") + "-account";
const data = JSON.stringify({
email: user.email,
name: user.name,
@@ -112,7 +113,7 @@ export const authOptions: NextAuthOptions = {
// Two-step upsert avoids relying on ON CONFLICT expression matching
const existing = await query<{ id: string }>(
`SELECT id FROM fs_users WHERE data->>'email' = $1 LIMIT 1`,
[user.email]
[user.email],
);
let fsUserId: string;
if (existing.length === 0) {
@@ -120,13 +121,13 @@ export const authOptions: NextAuthOptions = {
`INSERT INTO fs_users (id, user_id, data)
VALUES (gen_random_uuid()::text, $1, $2::jsonb)
RETURNING id`,
[user.id, data]
[user.id, data],
);
fsUserId = inserted[0].id;
} else {
await query(
`UPDATE fs_users SET user_id = $1, data = data || $2::jsonb, updated_at = NOW() WHERE id = $3`,
[user.id, data, existing[0].id]
[user.id, data, existing[0].id],
);
fsUserId = existing[0].id;
}
@@ -157,7 +158,9 @@ export const authOptions: NextAuthOptions = {
cookies: {
sessionToken: {
// __Secure- prefix requires Secure; localhost HTTP needs plain name + secure: false
name: isLocalNextAuth ? "next-auth.session-token" : "__Secure-next-auth.session-token",
name: isLocalNextAuth
? "next-auth.session-token"
: "__Secure-next-auth.session-token",
options: {
httpOnly: true,
sameSite: "lax",

76
lib/auth/password.ts Normal file
View File

@@ -0,0 +1,76 @@
import { scrypt, randomBytes, randomUUID, timingSafeEqual } from "crypto";
import { promisify } from "util";
import { query } from "@/lib/db-postgres";
const scryptAsync = promisify(scrypt) as (
password: string,
salt: string,
keylen: number,
) => Promise<Buffer>;
/**
* Hash a password with scrypt (Node stdlib — no native deps, Alpine-safe).
* Format: `scrypt$<saltHex>$<keyHex>`.
*/
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16).toString("hex");
const derived = await scryptAsync(password, salt, 64);
return `scrypt$${salt}$${derived.toString("hex")}`;
}
export async function verifyPassword(
password: string,
stored: string,
): Promise<boolean> {
const parts = stored.split("$");
if (parts.length !== 3 || parts[0] !== "scrypt") return false;
const [, salt, keyHex] = parts;
const keyBuf = Buffer.from(keyHex, "hex");
const derived = await scryptAsync(password, salt, 64);
return keyBuf.length === derived.length && timingSafeEqual(keyBuf, derived);
}
// ── Session cookie ───────────────────────────────────────────────────────────
// Must match the cookie NextAuth issues for OAuth (see lib/auth/authOptions.ts)
// so getServerSession reads sessions created here exactly the same way.
const nextAuthUrl = (process.env.NEXTAUTH_URL ?? "").trim();
const isLocalNextAuth =
nextAuthUrl.startsWith("http://localhost") ||
nextAuthUrl.startsWith("http://127.0.0.1") ||
(process.env.NODE_ENV === "development" && !nextAuthUrl);
export const SESSION_COOKIE_NAME = isLocalNextAuth
? "next-auth.session-token"
: "__Secure-next-auth.session-token";
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
export function sessionCookieOptions(expires: Date) {
return {
httpOnly: true,
sameSite: "lax" as const,
path: "/",
secure: !isLocalNextAuth,
expires,
...(isLocalNextAuth ? {} : { domain: ".vibnai.com" }),
};
}
/**
* Create a database-backed NextAuth session row for a user and return the token
* to set as the session cookie. This is the same `sessions` table the Prisma
* adapter uses for OAuth, so the resulting session works everywhere
* (`authSession()`, `useSession()`, sign-out, etc.).
*/
export async function createDbSession(
userId: string,
): Promise<{ token: string; expires: Date }> {
const token = randomBytes(32).toString("hex");
const expires = new Date(Date.now() + SESSION_MAX_AGE_MS);
await query(
`INSERT INTO sessions (id, session_token, user_id, expires)
VALUES ($1, $2, $3, $4)`,
[randomUUID(), token, userId, expires],
);
return { token, expires };
}

View File

@@ -20,8 +20,7 @@
},
],
"paths": {
"@/marketing/*": ["./components/marketing/*"],
"@/onboarding/*": ["./app/onboarding/*"],
"@/onboarding/*": ["./_onboarding/*"],
"@/*": ["./*"],
},
},