feat(frontend): email+password auth, /signin + /signup pages, marketing consolidation, onboarding workspace naming + full data persistence
This commit is contained in:
692
vibn-frontend/_onboarding/page.tsx
Normal file
692
vibn-frontend/_onboarding/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
@@ -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%",
|
||||
@@ -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
vibn-frontend/app/[workspace]/page.tsx
Normal file
16
vibn-frontend/app/[workspace]/page.tsx
Normal 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`);
|
||||
}
|
||||
61
vibn-frontend/app/api/auth/login/route.ts
Normal file
61
vibn-frontend/app/api/auth/login/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
100
vibn-frontend/app/api/auth/register/route.ts
Normal file
100
vibn-frontend/app/api/auth/register/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}` : ""}`);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
243
vibn-frontend/app/components/auth/AuthFlow.tsx
Normal file
243
vibn-frontend/app/components/auth/AuthFlow.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
381
vibn-frontend/app/components/auth/AuthScreen.tsx
Normal file
381
vibn-frontend/app/components/auth/AuthScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
467
vibn-frontend/app/components/auth/auth.css
Normal file
467
vibn-frontend/app/components/auth/auth.css
Normal 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;
|
||||
}
|
||||
@@ -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
vibn-frontend/app/signin/layout.tsx
Normal file
21
vibn-frontend/app/signin/layout.tsx
Normal 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
vibn-frontend/app/signin/page.tsx
Normal file
5
vibn-frontend/app/signin/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AuthFlow from "@/app/components/auth/AuthFlow";
|
||||
|
||||
export default function SignInPage() {
|
||||
return <AuthFlow mode="signin" />;
|
||||
}
|
||||
21
vibn-frontend/app/signup/layout.tsx
Normal file
21
vibn-frontend/app/signup/layout.tsx
Normal 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
vibn-frontend/app/signup/page.tsx
Normal file
5
vibn-frontend/app/signup/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AuthFlow from "@/app/components/auth/AuthFlow";
|
||||
|
||||
export default function SignUpPage() {
|
||||
return <AuthFlow mode="signup" />;
|
||||
}
|
||||
@@ -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 Justine’s 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;}
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
// Public components exports from subdirectories when needed
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
"I want to build a booking tool for independent personal trainers."
|
||||
</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" }}>
|
||||
⬤ 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)" }}>
|
||||
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'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'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 }}>
|
||||
"I had the idea for 2 years. The backend terrified me. vibn shipped it in 4 days and handles all my marketing."
|
||||
</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 }}>
|
||||
"I have zero coding experience. Three weeks in, I have 300 paying users. That's entirely because of vibn."
|
||||
</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 }}>
|
||||
"The marketing autopilot saved me ten hours a week. My blog runs itself. I just focus on product."
|
||||
</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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -2163,8 +2163,8 @@ export function ChatPanel({
|
||||
);
|
||||
|
||||
const authHref = pathname
|
||||
? `/auth?callbackUrl=${encodeURIComponent(pathname)}`
|
||||
: "/auth";
|
||||
? `/signin?callbackUrl=${encodeURIComponent(pathname)}`
|
||||
: "/signin";
|
||||
|
||||
const structuralChatSignedOutColumn = (
|
||||
<div
|
||||
|
||||
@@ -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
vibn-frontend/lib/auth/password.ts
Normal file
76
vibn-frontend/lib/auth/password.ts
Normal 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 };
|
||||
}
|
||||
@@ -20,8 +20,7 @@
|
||||
},
|
||||
],
|
||||
"paths": {
|
||||
"@/marketing/*": ["./components/marketing/*"],
|
||||
"@/onboarding/*": ["./app/onboarding/*"],
|
||||
"@/onboarding/*": ["./_onboarding/*"],
|
||||
"@/*": ["./*"],
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user