diff --git a/app/onboarding/onboarding-agency-mock.ts b/_onboarding/onboarding-agency-mock.ts similarity index 100% rename from app/onboarding/onboarding-agency-mock.ts rename to _onboarding/onboarding-agency-mock.ts diff --git a/app/onboarding/onboarding-agency-types.ts b/_onboarding/onboarding-agency-types.ts similarity index 100% rename from app/onboarding/onboarding-agency-types.ts rename to _onboarding/onboarding-agency-types.ts diff --git a/app/onboarding/onboarding-agency.tsx b/_onboarding/onboarding-agency.tsx similarity index 100% rename from app/onboarding/onboarding-agency.tsx rename to _onboarding/onboarding-agency.tsx diff --git a/app/onboarding/onboarding-build.tsx b/_onboarding/onboarding-build.tsx similarity index 100% rename from app/onboarding/onboarding-build.tsx rename to _onboarding/onboarding-build.tsx diff --git a/app/onboarding/onboarding-consultant.tsx b/_onboarding/onboarding-consultant.tsx similarity index 100% rename from app/onboarding/onboarding-consultant.tsx rename to _onboarding/onboarding-consultant.tsx diff --git a/app/onboarding/onboarding-entrepreneur.tsx b/_onboarding/onboarding-entrepreneur.tsx similarity index 100% rename from app/onboarding/onboarding-entrepreneur.tsx rename to _onboarding/onboarding-entrepreneur.tsx diff --git a/app/onboarding/onboarding-fork.tsx b/_onboarding/onboarding-fork.tsx similarity index 100% rename from app/onboarding/onboarding-fork.tsx rename to _onboarding/onboarding-fork.tsx diff --git a/app/onboarding/onboarding-owner.tsx b/_onboarding/onboarding-owner.tsx similarity index 100% rename from app/onboarding/onboarding-owner.tsx rename to _onboarding/onboarding-owner.tsx diff --git a/app/onboarding/onboarding-primitives.tsx b/_onboarding/onboarding-primitives.tsx similarity index 100% rename from app/onboarding/onboarding-primitives.tsx rename to _onboarding/onboarding-primitives.tsx diff --git a/app/onboarding/onboarding.css b/_onboarding/onboarding.css similarity index 100% rename from app/onboarding/onboarding.css rename to _onboarding/onboarding.css diff --git a/_onboarding/page.tsx b/_onboarding/page.tsx new file mode 100644 index 00000000..6cc9cea0 --- /dev/null +++ b/_onboarding/page.tsx @@ -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 → → 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>({}); + const [createdSlug, setCreatedSlug] = React.useState(null); + const [saving, setSaving] = React.useState(false); + + const [debugOpen, setDebugOpen] = React.useState(false); + + const update = (patch: Record) => + setData((d) => ({ ...d, ...patch })); + + // ── GTM Onboarding database saving endpoints ──────────────────────────────── + const saveOnboarding = async ( + payload: Record, + ): Promise => { + 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 = ( + + ); + } else if (stage === "agency") { + body = ( + setStage("door")} + /> + ); + } else if (stage === "fork") { + body = ( + + ); + } else if (stage === "path") { + const props = { + data, + onUpdate: update, + onBack: backToFork, + onClose: close, + onComplete: completePath, + onJumpToStep: setStep, + step, + }; + if (path === "entrepreneur") body = ; + else if (path === "owner") body = ; + else body = ; + } else if (stage === "choice") { + body = ( + + ); + } else if (stage === "build") { + body = ( + setStage("path")} + onClose={close} + onOpen={openWorkspace} + /> + ); + } else { + body = ( + + ); + } + + return ( +
+ {body} + { + 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"); + } + }} + /> +
+ ); +} + +// ── 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 ( +
+ {open && ( +
+ {groups.map((g) => ( + +
+ {g.title} +
+ {g.rows.map((r) => ( + + ))} +
+ ))} + +
+ )} + +
+ ); +} + +// ── 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 ( + <> + + +
+ + + +
+
+ Creating your workspace… +
+
+ Provisioning your server, repos & dashboard… +
+
+
+
+ + ); + } + + return ( + <> + + + + + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && trimmed) onSubmit(trimmed); + }} + /> + + {slug && ( +
+ vibnai.com/{slug} +
+ )} + +
+ + ); +} + +// ── 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 ( + + ); +} + +function DoorScreen({ onAgency, onSelf, onClose }) { + return ( + <> + + + +
+ + I want to build my own ideas + + Go from idea to market, and beyond. + + + } + icon={ + + } + /> + + I want to do billable AI work for others + + VIBN will help you find local businesses that you can build + custom solutions for + + + } + icon={ + + } + /> +
+
+ + ); +} diff --git a/app/mission/page.tsx b/app/(marketing)/mission/page.tsx similarity index 99% rename from app/mission/page.tsx rename to app/(marketing)/mission/page.tsx index 6623a60f..02ae6f8f 100644 --- a/app/mission/page.tsx +++ b/app/(marketing)/mission/page.tsx @@ -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", diff --git a/_marketing/components/new-site/index.tsx b/app/(marketing)/new-site.tsx similarity index 99% rename from _marketing/components/new-site/index.tsx rename to app/(marketing)/new-site.tsx index b99f82e8..4ca1646d 100644 --- a/_marketing/components/new-site/index.tsx +++ b/app/(marketing)/new-site.tsx @@ -2354,7 +2354,7 @@ function Closing() {
@@ -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 }) { }} > ; +}) { + const { workspace } = await params; + redirect(`/${workspace}/projects`); +} diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 00000000..988327bd --- /dev/null +++ b/app/api/auth/login/route.ts @@ -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 }, + ); + } +} diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts new file mode 100644 index 00000000..2a4bb19e --- /dev/null +++ b/app/api/auth/register/route.ts @@ -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 }, + ); + } +} diff --git a/app/api/integrations/github/callback/route.ts b/app/api/integrations/github/callback/route.ts index 030f74a0..e6231673 100644 --- a/app/api/integrations/github/callback/route.ts +++ b/app/api/integrations/github/callback/route.ts @@ -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): NextResponse { +function bounce( + origin: string, + returnTo: string, + params: Record, +): 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"; diff --git a/app/api/onboarding/route.ts b/app/api/onboarding/route.ts index f6b6d318..547c4275 100644 --- a/app/api/onboarding/route.ts +++ b/app/api/onboarding/route.ts @@ -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 { +// 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 { const base = name .toLowerCase() @@ -17,10 +22,15 @@ async function generateUniqueSlug(name: string): Promise { 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 { } // 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( - `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( + `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( + `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( diff --git a/app/api/workspaces/route.ts b/app/api/workspaces/route.ts index 1ae1cad9..9db078ce 100644 --- a/app/api/workspaces/route.ts +++ b/app/api/workspaces/route.ts @@ -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) { diff --git a/app/auth/layout.tsx b/app/auth/layout.tsx deleted file mode 100644 index 0e9fb78c..00000000 --- a/app/auth/layout.tsx +++ /dev/null @@ -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 ( -
-
- {children} -
- -
- ); -} diff --git a/app/auth/page.tsx b/app/auth/page.tsx index 033e15b1..f31eda93 100644 --- a/app/auth/page.tsx +++ b/app/auth/page.tsx @@ -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>; +}) { + 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(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 ( -
- {ssoToken ? ( -
-
- ✓ -
-

- Authentication Successful -

-

- Signed in. Redirecting to VibnCode... -

-
- -

- If the app doesn't open automatically, copy your Workspace API Key below and paste it into the connection card. -

-
- -
-
- {ssoToken} -
-
- ) : ( -
-
- -
- Checking session -
-
- )} -
- ); + 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 ( -
- -
- ); -} - -export default function AuthPage() { - return ( - - - - ); + const qs = params.toString(); + redirect(`${isNew ? "/signup" : "/signin"}${qs ? `?${qs}` : ""}`); } diff --git a/app/auth/supertokens-page.tsx b/app/auth/supertokens-page.tsx deleted file mode 100644 index f61b0eec..00000000 --- a/app/auth/supertokens-page.tsx +++ /dev/null @@ -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 ( -
- -
- ); -} diff --git a/app/components/NextAuthComponent.tsx b/app/components/NextAuthComponent.tsx deleted file mode 100644 index a2f6edfc..00000000 --- a/app/components/NextAuthComponent.tsx +++ /dev/null @@ -1,17 +0,0 @@ -"use client"; - -import { signIn } from "next-auth/react"; -import React from "react"; - -export default function NextAuthComponent() { - return ( -
- -
- ); -} diff --git a/app/components/auth/AuthFlow.tsx b/app/components/auth/AuthFlow.tsx new file mode 100644 index 00000000..a4edc5cc --- /dev/null +++ b/app/components/auth/AuthFlow.tsx @@ -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(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 ( +
+ {ssoToken ? ( +
+
+ ✓ +
+

+ Authentication Successful +

+

+ Signed in. Redirecting to VibnCode... +

+
+ +

+ If the app doesn't open automatically, copy your Workspace + API Key below and paste it into the connection card. +

+
+ +
+
+ {ssoToken} +
+
+ ) : ( +
+
+ +
+ Checking session +
+
+ )} +
+ ); + } + + return ; +} + +export default function AuthFlow({ mode }: { mode: "signin" | "signup" }) { + return ( + + + + ); +} diff --git a/app/components/auth/AuthScreen.tsx b/app/components/auth/AuthScreen.tsx new file mode 100644 index 00000000..7f6e6d0b --- /dev/null +++ b/app/components/auth/AuthScreen.tsx @@ -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(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 ( +
+
+
+ + + + + vibn + + + + Back to home + +
+ +
+ + +
+
+ {isSignup ? "Get started" : "Welcome back"} +
+

+ {isSignup ? ( + <> + Create your workspace. + + ) : ( + <> + Sign in and keep building. + + )} +

+

+ {isSignup + ? "Set up your account with an email and password — you'll be building in seconds." + : "Pick up right where you left off."} +

+ +
+ {isSignup && ( +
+ + setName(e.target.value)} + /> +
+ )} + +
+ + setEmail(e.target.value)} + /> +
+ +
+ + setPassword(e.target.value)} + /> +
+ + {error &&
{error}
} + + +
+ +
or continue with
+ +
+ +
+ + {isSignup && ( +

+ By continuing you agree to our{" "} + Terms and{" "} + Privacy Policy. +

+ )} + +
+ {isSignup ? ( + <> + Already have an account? Sign in → + + ) : ( + <> + New to Vibn? Create an account → + + )} +
+
+ + +
+
+
+ ); +} + +function Glows() { + return ( + <> +
+
+
+ + ); +} + +function TrustStrip({ items }: { items: string[] }) { + return ( +
+ {items.map((item, i) => ( + + {i > 0 && ·} + {item} + + ))} +
+ ); +} + +function Arrow({ size = 14 }: { size?: number }) { + return ( + + ); +} + +function GoogleIcon({ size = 17 }: { size?: number }) { + return ( + + ); +} diff --git a/app/components/auth/auth.css b/app/components/auth/auth.css new file mode 100644 index 00000000..b5f81b4c --- /dev/null +++ b/app/components/auth/auth.css @@ -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,"); +} + +.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; +} diff --git a/app/onboarding/page.tsx b/app/onboarding/page.tsx index cf56df49..923a44dc 100644 --- a/app/onboarding/page.tsx +++ b/app/onboarding/page.tsx @@ -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 → → 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>({}); - const [createdSlug, setCreatedSlug] = React.useState(null); - const [saving, setSaving] = React.useState(false); - - const [debugOpen, setDebugOpen] = React.useState(false); - - const update = (patch: Record) => - setData((d) => ({ ...d, ...patch })); - - // ── GTM Onboarding database saving endpoints ──────────────────────────────── - const saveOnboarding = async ( - payload: Record, - ): Promise => { - 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 = ( - - ); - } else if (stage === "agency") { - body = ( - setStage("door")} - /> - ); - } else if (stage === "fork") { - body = ( - - ); - } else if (stage === "path") { - const props = { - data, - onUpdate: update, - onBack: backToFork, - onClose: close, - onComplete: completePath, - onJumpToStep: setStep, - step, - }; - if (path === "entrepreneur") body = ; - else if (path === "owner") body = ; - else body = ; - } else if (stage === "choice") { - body = ( - finishPersonal("workspace")} - onBuild={() => finishPersonal("build")} - onClose={close} - resolving={saving} - /> - ); - } else if (stage === "build") { - body = ( - setStage("path")} - onClose={close} - onOpen={openWorkspace} - /> - ); - } else { - body = ( - - ); - } - - return ( -
- {body} - { - 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"); - } - }} - /> -
- ); -} - -// ── 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 ( -
- {open && ( -
- {groups.map((g) => ( - -
- {g.title} -
- {g.rows.map((r) => ( - - ))} -
- ))} - -
- )} - -
- ); -} - -// ── 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 ( - <> - - -
- - - -
-
- Registering your workspace... -
-
- Setting up Gitea & GMB pipelines on the server… -
-
-
-
- - ); - } - - return ( - <> - - - -
-
-
- - ); -} - -// ── 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 ( - - ); -} - -function DoorScreen({ onAgency, onSelf, onClose }) { - return ( - <> - - - -
- - I want to build my own ideas - - Go from idea to market, and beyond. - - - } - icon={ - - } - /> - - I want to do billable AI work for others - - VIBN will help you find local businesses that you can build - custom solutions for - - - } - icon={ - - } - /> -
-
- - ); -} +export default OnboardingPage; diff --git a/app/signin/layout.tsx b/app/signin/layout.tsx new file mode 100644 index 00000000..f2fa3469 --- /dev/null +++ b/app/signin/layout.tsx @@ -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} + + + ); +} diff --git a/app/signin/page.tsx b/app/signin/page.tsx new file mode 100644 index 00000000..7b967e4a --- /dev/null +++ b/app/signin/page.tsx @@ -0,0 +1,5 @@ +import AuthFlow from "@/app/components/auth/AuthFlow"; + +export default function SignInPage() { + return ; +} diff --git a/app/signup/layout.tsx b/app/signup/layout.tsx new file mode 100644 index 00000000..989c024b --- /dev/null +++ b/app/signup/layout.tsx @@ -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} + + + ); +} diff --git a/app/signup/page.tsx b/app/signup/page.tsx new file mode 100644 index 00000000..5a816772 --- /dev/null +++ b/app/signup/page.tsx @@ -0,0 +1,5 @@ +import AuthFlow from "@/app/components/auth/AuthFlow"; + +export default function SignUpPage() { + return ; +} diff --git a/app/styles/justine/01-homepage.css b/app/styles/justine/01-homepage.css deleted file mode 100644 index 1b8f7349..00000000 --- a/app/styles/justine/01-homepage.css +++ /dev/null @@ -1,356 +0,0 @@ -/** - * Verbatim from justine/01_homepage.html -
-
- {title} - -
-
- {children} - {hasDeckStage && railEnabled && !noDeckControls && ( - - - - )} -
-
- - ); -} - -// ── Layout helpers ────────────────────────────────────────────────────────── - -function TweakSection({ label, children }) { - return ( - <> -
{label}
- {children} - - ); -} - -function TweakRow({ label, value, children, inline = false }) { - return ( -
-
- {label} - {value != null && {value}} -
- {children} -
- ); -} - -// ── Controls ──────────────────────────────────────────────────────────────── - -function TweakSlider({ - label, - value, - min = 0, - max = 100, - step = 1, - unit = "", - onChange, -}) { - return ( - - onChange(Number(e.target.value))} - /> - - ); -} - -function TweakToggle({ label, value, onChange }) { - return ( -
-
- {label} -
- -
- ); -} - -function TweakRadio({ label, value, options, onChange }) { - const trackRef = useRef(null); - const [dragging, setDragging] = useState(false); - // The active value is read by pointer-move handlers attached for the lifetime - // of a drag — ref it so a stale closure doesn't fire onChange for every move. - const valueRef = useRef(value); - valueRef.current = value; - - // Segments wrap mid-word once per-segment width runs out. The track is - // ~248px (280 panel − 28 body pad − 4 seg pad), each button loses 12px - // to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2 - // options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall - // back to a dropdown rather than wrap. - const labelLen = (o) => String(typeof o === "object" ? o.label : o).length; - const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0); - const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0); - if (!fitsAsSegments) { - // onChange(e.target.value)} - > - {options.map((o) => { - const v = typeof o === "object" ? o.value : o; - const l = typeof o === "object" ? o.label : o; - return ( - - ); - })} - - - ); -} - -function TweakText({ label, value, placeholder, onChange }) { - return ( - - onChange(e.target.value)} - /> - - ); -} - -function TweakNumber({ - label, - value, - min, - max, - step = 1, - unit = "", - onChange, -}) { - const clamp = (n) => { - if (min != null && n < min) return min; - if (max != null && n > max) return max; - return n; - }; - const startRef = useRef({ x: 0, val: 0 }); - const onScrubStart = (e) => { - e.preventDefault(); - startRef.current = { x: e.clientX, val: value }; - const decimals = (String(step).split(".")[1] || "").length; - const move = (ev) => { - const dx = ev.clientX - startRef.current.x; - const raw = startRef.current.val + dx * step; - const snapped = Math.round(raw / step) * step; - onChange(clamp(Number(snapped.toFixed(decimals)))); - }; - const up = () => { - window.removeEventListener("pointermove", move); - window.removeEventListener("pointerup", up); - }; - window.addEventListener("pointermove", move); - window.addEventListener("pointerup", up); - }; - return ( -
- - {label} - - onChange(clamp(Number(e.target.value)))} - /> - {unit && {unit}} -
- ); -} - -// Relative-luminance contrast pick — checkmarks drawn over a swatch need to -// read on both #111 and #fafafa without per-option configuration. Hex input -// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light". -function __twkIsLight(hex) { - const h = String(hex).replace("#", ""); - const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, "0"); - const n = parseInt(x.slice(0, 6), 16); - if (Number.isNaN(n)) return true; - const r = (n >> 16) & 255, - g = (n >> 8) & 255, - b = n & 255; - return r * 299 + g * 587 + b * 114 > 148000; -} - -const __TwkCheck = ({ light }) => ( - -); - -// TweakColor — curated color/palette picker. Each option is either a single -// hex string or an array of 1-5 hex strings; the card adapts — a lone color -// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the -// rest stacked in a sharp column on the right. onChange emits the -// option in the shape it was passed (string stays string, array stays array). -// Without options it falls back to the native color input for back-compat. -function TweakColor({ label, value, options, onChange }) { - if (!options || !options.length) { - return ( -
-
- {label} -
- onChange(e.target.value)} - /> -
- ); - } - // Native emits lowercase hex per the HTML spec, so - // compare case-insensitively. String() guards JSON.stringify(undefined), - // which returns the primitive undefined (no .toLowerCase). - const key = (o) => String(JSON.stringify(o)).toLowerCase(); - const cur = key(value); - return ( - -
- {options.map((o, i) => { - const colors = Array.isArray(o) ? o : [o]; - const [hero, ...rest] = colors; - const sup = rest.slice(0, 4); - const on = key(o) === cur; - return ( - - ); - })} -
-
- ); -} - -function TweakButton({ label, onClick, secondary = false }) { - return ( - - ); -} - -// --- primitives.jsx --- -// Small shared primitives: logo, arrow icon, ambient glow, eyebrow, trust strip. - -// The "V_" mark — bold filled V + terminal-cursor underscore. Sized via the -// outer .logo-mark; the SVG fills it. `stroke-linejoin="round"` + a thin -// stroke on the filled paths softens the corners just enough. -export function LogoMark({ - size = 26, - blink = true, -}: { - size?: number; - blink?: boolean; -}) { - return ( - - - - ); -} - -export function Logo({ size = 26 }: { size?: number }) { - return ( - - - vibn - - ); -} - -function Arrow({ size = 14 }) { - return ( - - ); -} - -function Eyebrow({ children }) { - return
{children}
; -} - -// Soft radial glow blob for ambient backgrounds. Place absolutely positioned. -function Glow({ - color = "var(--accent-glow)", - size = 700, - opacity = 1, - style = {}, -}) { - return ( -