feat: welcome desk auth and operator password reset
First visit lands on /welcome instead of burying signup in Settings. Add a host CLI to reset passwords without a session, plus Settings change-password. Refresh as-built design docs and the Unraid operator guide.
This commit is contained in:
@@ -43,6 +43,8 @@ test("panel/page/client source pass the Primary-Rule lint", () => {
|
||||
"../lib/strings.ts",
|
||||
"../lib/trpc.ts",
|
||||
"../stores/active-symbol-store.ts",
|
||||
"../app/welcome/page.tsx",
|
||||
"../components/auth/AuthDesk.tsx",
|
||||
"../components/dealer-flow/DealerFlowView.tsx",
|
||||
"../components/dealer-flow/DealerHeatmap.tsx",
|
||||
"../components/dealer-flow/DealerLevelsStrip.tsx",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LayoutShell } from "@/components/LayoutShell";
|
||||
import { api, type AuthUser } from "@/lib/trpc";
|
||||
import { useFeatureAccess } from "@/lib/useFeatureAccess";
|
||||
import { useWorkspaceProfile } from "@/lib/useWorkspaceProfile";
|
||||
import type { NavItemId } from "@/lib/workspace-profile";
|
||||
@@ -39,6 +41,27 @@ const LINKS: MoreLink[] = [
|
||||
];
|
||||
|
||||
export default function MorePage() {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [authReady, setAuthReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.auth
|
||||
.me()
|
||||
.then((u) => {
|
||||
if (!cancelled) setUser(u);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setUser(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setAuthReady(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { modules } = useFeatureAccess();
|
||||
const { isNavVisible, density } = useWorkspaceProfile();
|
||||
|
||||
@@ -53,6 +76,15 @@ export default function MorePage() {
|
||||
return (
|
||||
<LayoutShell>
|
||||
<div className="space-y-6 max-w-lg mx-auto">
|
||||
{authReady && !user && (
|
||||
<Link
|
||||
href="/welcome?mode=signup"
|
||||
className="block rounded-xl border border-line-strong bg-accent/10 px-4 py-3.5 min-h-[56px] text-sm font-medium text-fg hover:bg-accent/20 transition-colors"
|
||||
>
|
||||
Sign in or create an account
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-fg">More</h1>
|
||||
<p className="text-sm text-fg-faint mt-1">
|
||||
|
||||
+38
-1
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LayoutShell } from "@/components/LayoutShell";
|
||||
import { OverviewPanel } from "@/components/OverviewPanel";
|
||||
import { AnalystRatings } from "@/components/AnalystRatings";
|
||||
@@ -16,6 +17,8 @@ import { ManagementHomeStrip } from "@/components/ManagementHomeStrip";
|
||||
import { FundHoldingsStrip } from "@/components/FundHoldingsStrip";
|
||||
import { DealerLevelsStrip } from "@/components/dealer-flow/DealerLevelsStrip";
|
||||
import { scrollAppMainToTopSoon } from "@/lib/scroll-app-main";
|
||||
import { hasSeenWelcome } from "@/lib/welcome-seen";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
/** Anchor for sidebar "Symbol: TICKER" and mobile Research tab. */
|
||||
export const SYMBOL_WORKBENCH_ID = "symbol-workbench";
|
||||
@@ -26,9 +29,35 @@ export const SYMBOL_WORKBENCH_ID = "symbol-workbench";
|
||||
* ticker, not mid-page under the book strip.
|
||||
*/
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
const activeSymbol = useActiveSymbolGuarded();
|
||||
const { overview } = useWorkspaceProfile();
|
||||
|
||||
const [gate, setGate] = useState<"checking" | "ok">("checking");
|
||||
|
||||
// First visit: unsigned + no skip flag -> /welcome. Hold a spinner so the
|
||||
// workbench does not flash. Skip (iflow-welcome-seen) or a session stays here.
|
||||
useEffect(() => {
|
||||
if (hasSeenWelcome()) {
|
||||
setGate("ok");
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
api.auth
|
||||
.me()
|
||||
.then((me) => {
|
||||
if (cancelled) return;
|
||||
if (!me) router.replace("/welcome");
|
||||
else setGate("ok");
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) router.replace("/welcome");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
// Cross-page entry (Dealer Flow → Symbol): remount alone is not enough — Next
|
||||
// focus / delayed layout can leave the nested main scroller mid-pane.
|
||||
useEffect(() => {
|
||||
@@ -45,6 +74,14 @@ export default function Page() {
|
||||
}
|
||||
}, [activeSymbol]);
|
||||
|
||||
if (gate !== "ok") {
|
||||
return (
|
||||
<div className="h-dvh w-full bg-surface flex items-center justify-center">
|
||||
<div className="w-6 h-6 rounded-full border-2 border-accent/40 border-t-accent animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LayoutShell>
|
||||
<div id={SYMBOL_WORKBENCH_ID} className="space-y-4 sm:space-y-6 scroll-mt-2">
|
||||
|
||||
+94
-395
@@ -3,23 +3,77 @@ import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LayoutShell } from "@/components/LayoutShell";
|
||||
import { api, type AuthUser } from "@/lib/trpc";
|
||||
import {
|
||||
EXPERIENCE_LABELS,
|
||||
DENSITY_LABELS,
|
||||
GOAL_LABELS,
|
||||
HORIZON_LABELS,
|
||||
type Density,
|
||||
type ExperienceStage,
|
||||
type Goal,
|
||||
type Horizon,
|
||||
type JargonComfort,
|
||||
} from "@/lib/workspace-profile";
|
||||
import { UI_STRINGS } from "@/lib/strings";
|
||||
import WorkspaceInterview, { WorkspaceProfileEditor } from "@/components/auth/WorkspaceInterview";
|
||||
|
||||
/**
|
||||
* Settings — account, workspace profile (density), and first-run interview.
|
||||
* Interview configures the management workspace; it is not a course enrollment.
|
||||
*/
|
||||
|
||||
function ChangePasswordForm() {
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-2 pt-2 border-t border-line"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
setMsg(null);
|
||||
try {
|
||||
await api.auth.changePassword(current, next);
|
||||
setCurrent("");
|
||||
setNext("");
|
||||
setMsg(UI_STRINGS.changePasswordDone);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message.replace(/^tRPC.*?:\s*/, "") : "request failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<h3 className="text-sm font-medium text-fg">{UI_STRINGS.changePasswordTitle}</h3>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-fg-faint">{UI_STRINGS.changePasswordCurrent}</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
className="w-full min-h-[44px] bg-surface border border-line-strong rounded px-3 text-sm text-fg"
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-fg-faint">{UI_STRINGS.changePasswordNext}</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
value={next}
|
||||
onChange={(e) => setNext(e.target.value)}
|
||||
placeholder="At least 8 characters"
|
||||
className="w-full min-h-[44px] bg-surface border border-line-strong rounded px-3 text-sm text-fg"
|
||||
/>
|
||||
</label>
|
||||
{err && <p className="text-xs text-danger">{err}</p>}
|
||||
{msg && <p className="text-xs text-up">{msg}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !current || next.length < 8}
|
||||
className="min-h-[44px] px-3 rounded-lg bg-accent text-white text-sm disabled:opacity-50"
|
||||
>
|
||||
{UI_STRINGS.changePasswordSave}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function SecretDisplay({ value, label }: { value: string; label: string }) {
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
return (
|
||||
@@ -196,44 +250,44 @@ function LlmEndpointSettings() {
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [enroll, setEnroll] = useState<{ totpSecret: string; qrUrl: string; backupCodes: string[] } | null>(null);
|
||||
const [code, setCode] = useState("");
|
||||
const [twoFactorMsg, setTwoFactorMsg] = useState<string | null>(null);
|
||||
const [pendingMessage, setPendingMessage] = useState<string | null>(null);
|
||||
|
||||
const refreshUser = () =>
|
||||
api.auth.me().then(setUser).catch(() => setUser(null));
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.auth.me().then((u) => { if (!cancelled) setUser(u); }).catch(() => {});
|
||||
api.auth.me()
|
||||
.then((u) => {
|
||||
if (cancelled) return;
|
||||
setUser(u);
|
||||
setLoading(false);
|
||||
if (!u) router.replace("/welcome?mode=signin");
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setUser(null);
|
||||
setLoading(false);
|
||||
router.replace("/welcome?mode=signin");
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const submit = async (mode: "signup" | "login") => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
if (mode === "signup") {
|
||||
const data = await api.auth.signup(email, password) as { userId?: string; pending?: boolean; message?: string };
|
||||
if (data && "pending" in data && (data as { pending?: boolean }).pending) {
|
||||
setPendingMessage((data as { message?: string }).message ?? "Account created. Awaiting admin approval.");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await api.auth.login(email, password);
|
||||
}
|
||||
await refreshUser();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "request failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
if (loading) {
|
||||
return (
|
||||
<LayoutShell>
|
||||
<div className="h-64 flex items-center justify-center">
|
||||
<div className="w-5 h-5 rounded-full border-2 border-accent/40 border-t-accent animate-spin" />
|
||||
</div>
|
||||
</LayoutShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LayoutShell>
|
||||
@@ -254,6 +308,8 @@ export default function SettingsPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
|
||||
{!enroll && !twoFactorMsg && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
@@ -306,12 +362,15 @@ export default function SettingsPage() {
|
||||
|
||||
{twoFactorMsg && <p className="text-xs text-up">{twoFactorMsg}</p>}
|
||||
|
||||
<ChangePasswordForm />
|
||||
|
||||
<button
|
||||
onClick={async () => {
|
||||
await api.auth.logout();
|
||||
setUser(null);
|
||||
setEnroll(null);
|
||||
setTwoFactorMsg(null);
|
||||
router.replace("/welcome?mode=signin");
|
||||
}}
|
||||
className="text-xs px-3 py-1.5 rounded border border-line-strong text-fg-muted hover:border-danger hover:text-danger"
|
||||
>
|
||||
@@ -322,53 +381,6 @@ export default function SettingsPage() {
|
||||
|
||||
{user && <LlmEndpointSettings />}
|
||||
|
||||
{!user && (
|
||||
<section className="rounded-xl border border-line-strong bg-surface-raised p-5">
|
||||
<p className="text-sm text-fg-muted mb-3">
|
||||
Create an account to track symbols, holdings, and risk in one place.
|
||||
</p>
|
||||
{pendingMessage ? (
|
||||
<div className="rounded border border-warn/40 bg-warn/5 p-3 text-sm text-fg-muted">{pendingMessage}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2 mb-2">
|
||||
<input
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
className="bg-surface border border-line-strong rounded px-2 py-2 text-sm text-fg"
|
||||
/>
|
||||
<input
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
className="bg-surface border border-line-strong rounded px-2 py-2 text-sm text-fg"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-xs text-danger mb-2">{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={() => submit("signup")}
|
||||
className="px-3 py-1.5 rounded text-sm bg-accent text-white disabled:opacity-50"
|
||||
>
|
||||
Create account
|
||||
</button>
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={() => submit("login")}
|
||||
className="px-3 py-1.5 rounded text-sm border border-line-strong text-fg disabled:opacity-50"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{user && !user.onboarded && (
|
||||
<WorkspaceInterview
|
||||
onDone={async () => {
|
||||
@@ -389,316 +401,3 @@ export default function SettingsPage() {
|
||||
</LayoutShell>
|
||||
);
|
||||
}
|
||||
|
||||
function ChoiceButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`w-full text-left rounded-lg border px-4 py-3 text-sm transition-colors ${
|
||||
active
|
||||
? "border-accent bg-accent/10 text-fg"
|
||||
: "border-line bg-surface text-fg hover:bg-surface-sunken"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceInterview({ onDone }: { onDone: () => void | Promise<void> }) {
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState(0);
|
||||
const [experienceStage, setExperienceStage] = useState<ExperienceStage | "">("");
|
||||
const [goal, setGoal] = useState<Goal | "">("");
|
||||
const [horizon, setHorizon] = useState<Horizon | "">("");
|
||||
const [jargonComfort, setJargonComfort] = useState<JargonComfort>("plain");
|
||||
const [starter, setStarter] = useState<{
|
||||
watchlist: Array<{ symbol: string; tickerKind: string; reason: string }>;
|
||||
disclaimer: string;
|
||||
density: string;
|
||||
} | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!experienceStage) return;
|
||||
api.onboarding
|
||||
.starter({ experienceStage })
|
||||
.then(setStarter)
|
||||
.catch(() => setStarter(null));
|
||||
}, [experienceStage]);
|
||||
|
||||
const finish = async () => {
|
||||
if (!experienceStage || !goal || !horizon) return;
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
await api.onboarding.complete({
|
||||
experienceStage,
|
||||
goal,
|
||||
horizon,
|
||||
jargonComfort,
|
||||
});
|
||||
window.dispatchEvent(new Event("workspace-profile-changed"));
|
||||
await onDone();
|
||||
router.push("/portfolio");
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : "Setup failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border-2 border-accent/40 bg-surface-raised p-5 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-fg">Set up your workspace</h2>
|
||||
<p className="text-xs text-fg-faint mt-1">
|
||||
A few questions so we can size risk defaults and how much detail to show. You can change this anytime.
|
||||
</p>
|
||||
<p className="text-[10px] text-fg-muted mt-2">Step {Math.min(step + 1, 4)} of 4</p>
|
||||
</div>
|
||||
|
||||
{step === 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-fg">How much experience do you have with stocks or funds?</p>
|
||||
{(Object.keys(EXPERIENCE_LABELS) as ExperienceStage[]).map((k) => (
|
||||
<ChoiceButton
|
||||
key={k}
|
||||
active={experienceStage === k}
|
||||
onClick={() => {
|
||||
setExperienceStage(k);
|
||||
setStep(1);
|
||||
}}
|
||||
>
|
||||
{EXPERIENCE_LABELS[k]}
|
||||
</ChoiceButton>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-fg">What matters most for money you will track here?</p>
|
||||
{(Object.keys(GOAL_LABELS) as Goal[]).map((k) => (
|
||||
<ChoiceButton
|
||||
key={k}
|
||||
active={goal === k}
|
||||
onClick={() => {
|
||||
setGoal(k);
|
||||
setStep(2);
|
||||
}}
|
||||
>
|
||||
{GOAL_LABELS[k]}
|
||||
</ChoiceButton>
|
||||
))}
|
||||
<button type="button" className="text-xs text-fg-muted hover:text-fg" onClick={() => setStep(0)}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-fg">How long until you may need this money?</p>
|
||||
{(Object.keys(HORIZON_LABELS) as Horizon[]).map((k) => (
|
||||
<ChoiceButton
|
||||
key={k}
|
||||
active={horizon === k}
|
||||
onClick={() => {
|
||||
setHorizon(k);
|
||||
setStep(3);
|
||||
}}
|
||||
>
|
||||
{HORIZON_LABELS[k]}
|
||||
</ChoiceButton>
|
||||
))}
|
||||
<button type="button" className="text-xs text-fg-muted hover:text-fg" onClick={() => setStep(1)}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-fg">How do you prefer labels and terms?</p>
|
||||
{(
|
||||
[
|
||||
{ k: "plain" as const, l: "Everyday language first" },
|
||||
{ k: "mixed" as const, l: "Mix of plain and market terms" },
|
||||
{ k: "technical" as const, l: "Full market terms" },
|
||||
] as const
|
||||
).map(({ k, l }) => (
|
||||
<ChoiceButton key={k} active={jargonComfort === k} onClick={() => setJargonComfort(k)}>
|
||||
{l}
|
||||
</ChoiceButton>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{starter && (
|
||||
<div>
|
||||
<p className="text-xs text-fg-faint mb-1">
|
||||
Starter symbols to track (examples for the product — not recommendations). Density:{" "}
|
||||
<span className="capitalize">{starter.density}</span>
|
||||
</p>
|
||||
<ul className="text-xs space-y-1 max-h-40 overflow-y-auto">
|
||||
{starter.watchlist.map((s) => (
|
||||
<li key={s.symbol} className="flex gap-2">
|
||||
<span className="font-mono text-accent w-12 shrink-0">{s.symbol}</span>
|
||||
<span className="text-fg-muted">{s.reason}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
{err && <p className="text-xs text-danger">{err}</p>}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !experienceStage || !goal || !horizon}
|
||||
onClick={finish}
|
||||
className="px-4 py-2 rounded text-sm bg-accent text-white disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Setting up…" : "Finish and open Portfolio"}
|
||||
</button>
|
||||
<button type="button" className="text-xs text-fg-muted hover:text-fg" onClick={() => setStep(2)}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceProfileEditor({
|
||||
user,
|
||||
onSaved,
|
||||
}: {
|
||||
user: AuthUser;
|
||||
onSaved: () => void | Promise<void>;
|
||||
}) {
|
||||
const [density, setDensity] = useState<Density>((user.density as Density) || "focused");
|
||||
const [experienceStage, setExperienceStage] = useState<ExperienceStage>(
|
||||
(user.experienceStage as ExperienceStage) || "never_invested",
|
||||
);
|
||||
const [goal, setGoal] = useState<Goal>((user.goal as Goal) || "grow");
|
||||
const [horizon, setHorizon] = useState<Horizon>((user.horizon as Horizon) || "long");
|
||||
const [jargonComfort, setJargonComfort] = useState<JargonComfort>(
|
||||
(user.jargonComfort as JargonComfort) || "plain",
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setDensity((user.density as Density) || "focused");
|
||||
setExperienceStage((user.experienceStage as ExperienceStage) || "never_invested");
|
||||
setGoal((user.goal as Goal) || "grow");
|
||||
setHorizon((user.horizon as Horizon) || "long");
|
||||
setJargonComfort((user.jargonComfort as JargonComfort) || "plain");
|
||||
}, [user]);
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
setMsg(null);
|
||||
try {
|
||||
await api.onboarding.updateProfile({
|
||||
density,
|
||||
experienceStage,
|
||||
goal,
|
||||
horizon,
|
||||
jargonComfort,
|
||||
});
|
||||
setMsg("Workspace preferences saved.");
|
||||
window.dispatchEvent(new Event("workspace-profile-changed"));
|
||||
await onSaved();
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : "Save failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-line-strong bg-surface-raised p-5 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-fg">Workspace</h2>
|
||||
<p className="text-xs text-fg-faint mt-1">
|
||||
Control how much detail appears in navigation and research. Focused keeps the book, monitor, and risk front and center.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-fg-faint mb-2">Experience</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{(Object.keys(EXPERIENCE_LABELS) as ExperienceStage[]).map((k) => (
|
||||
<ChoiceButton key={k} active={experienceStage === k} onClick={() => setExperienceStage(k)}>
|
||||
{EXPERIENCE_LABELS[k]}
|
||||
</ChoiceButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-fg-faint mb-2">Workspace density</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{(Object.keys(DENSITY_LABELS) as Density[]).map((k) => (
|
||||
<ChoiceButton key={k} active={density === k} onClick={() => setDensity(k)}>
|
||||
{DENSITY_LABELS[k]}
|
||||
</ChoiceButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-fg-faint mb-2">Goal</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{(Object.keys(GOAL_LABELS) as Goal[]).map((k) => (
|
||||
<ChoiceButton key={k} active={goal === k} onClick={() => setGoal(k)}>
|
||||
{GOAL_LABELS[k]}
|
||||
</ChoiceButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-fg-faint mb-2">Horizon</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{(Object.keys(HORIZON_LABELS) as Horizon[]).map((k) => (
|
||||
<ChoiceButton key={k} active={horizon === k} onClick={() => setHorizon(k)}>
|
||||
{HORIZON_LABELS[k]}
|
||||
</ChoiceButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err && <p className="text-xs text-danger">{err}</p>}
|
||||
{msg && <p className="text-xs text-up">{msg}</p>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={save}
|
||||
className="px-4 py-2 rounded text-sm bg-accent text-white disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Saving…" : "Save workspace"}
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
import { Suspense } from "react";
|
||||
import AuthDesk from "@/components/auth/AuthDesk";
|
||||
|
||||
function WelcomeFallback() {
|
||||
return <div className="h-dvh w-full bg-surface" />;
|
||||
}
|
||||
|
||||
export default function WelcomePage() {
|
||||
return (
|
||||
<Suspense fallback={<WelcomeFallback />}>
|
||||
<AuthDesk />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -168,7 +168,7 @@ export function OverviewPanel() {
|
||||
)}
|
||||
|
||||
{sector && (
|
||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm">
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1 text-sm">
|
||||
<div className="flex justify-between"><dt className="text-fg-muted">{UI_STRINGS.sectorLabel}</dt><dd className="text-fg">{sector.sector ?? "—"}</dd></div>
|
||||
<div className="flex justify-between"><dt className="text-fg-muted">{UI_STRINGS.industryLabel}</dt><dd className="text-fg">{sector.industry ?? "—"}</dd></div>
|
||||
<div className="flex justify-between"><dt className="text-fg-muted">{UI_STRINGS.tickerKindLabel}</dt><dd className="text-fg">{sector.tickerKind}</dd></div>
|
||||
|
||||
@@ -104,7 +104,7 @@ const SECTIONS: NavSection[] = [
|
||||
title: "Settings",
|
||||
icon: Settings,
|
||||
module: "settings",
|
||||
items: [{ label: "Profile / Auth", href: "/settings", navId: "settings" }],
|
||||
items: [{ label: "Settings", href: "/settings", navId: "settings" }],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
} from "@/components/MobileWatchlistSheet";
|
||||
import { LastUpdated } from "@/components/shared/LastUpdated";
|
||||
import { quoteStampTone } from "@/lib/lastUpdatedCopy";
|
||||
import { useFeatureAccess } from "@/lib/useFeatureAccess";
|
||||
import { AuthHeaderActions } from "./auth/AuthHeaderActions";
|
||||
|
||||
/**
|
||||
* Persistent top bar showing the active symbol, price, change, and search.
|
||||
@@ -24,6 +26,8 @@ export function SymbolHeader() {
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [showLists, setShowLists] = useState(false);
|
||||
const [snap, setSnap] = useState<Snapshot | null>(null);
|
||||
const { user, loading: authLoading } = useFeatureAccess();
|
||||
const signedOut = !authLoading && !user;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -84,7 +88,7 @@ export function SymbolHeader() {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 shrink-0 border-b border-line bg-surface-raised/95 backdrop-blur-sm safe-area-px">
|
||||
<div className="flex items-center justify-between gap-2 px-3 sm:px-4 py-2 sm:py-3 min-h-[48px]">
|
||||
{/* Left: Logo mark */}
|
||||
{/* Left: Logo mark. Mobile Sign in sits here so it does not crowd ticker tools. */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="w-8 h-8 sm:w-7 sm:h-7 rounded-lg bg-accent flex items-center justify-center">
|
||||
<span className="text-xs font-bold text-surface">IF</span>
|
||||
@@ -92,6 +96,9 @@ export function SymbolHeader() {
|
||||
<h1 className="hidden sm:block text-sm font-semibold text-fg tracking-tight">
|
||||
Investor Flow
|
||||
</h1>
|
||||
<div className="sm:hidden">
|
||||
<AuthHeaderActions />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center/right: ticker + actions */}
|
||||
@@ -119,8 +126,8 @@ export function SymbolHeader() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile: compact ticker */}
|
||||
<div className="sm:hidden flex flex-col items-end min-w-0 mr-1">
|
||||
{/* Mobile: compact ticker. Hidden when signed out so the Sign in control has room. */}
|
||||
<div className={`sm:hidden flex-col items-end min-w-0 mr-1 ${signedOut ? "hidden" : "flex"}`}>
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="font-bold text-fg font-mono">{symbol}</span>
|
||||
{priceLabel && (
|
||||
@@ -191,6 +198,9 @@ export function SymbolHeader() {
|
||||
<Search className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<div className="hidden sm:block">
|
||||
<AuthHeaderActions />
|
||||
</div>
|
||||
<NotificationBell />
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
"use client";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/lib/trpc";
|
||||
import { markWelcomeSeen } from "@/lib/welcome-seen";
|
||||
import { UI_STRINGS } from "@/lib/strings";
|
||||
import WorkspaceInterview from "./WorkspaceInterview";
|
||||
|
||||
type Mode = "signup" | "signin";
|
||||
|
||||
function parseMode(raw: string | null): Mode {
|
||||
return raw === "signin" ? "signin" : "signup";
|
||||
}
|
||||
|
||||
function errorText(e: unknown): string {
|
||||
return e instanceof Error ? e.message : "request failed";
|
||||
}
|
||||
|
||||
function ForgotPasswordHint() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="text-xs text-fg-muted hover:text-fg underline decoration-dashed"
|
||||
>
|
||||
{UI_STRINGS.welcomeForgotPassword}
|
||||
</button>
|
||||
{open && (
|
||||
<p className="mt-2 text-left text-xs text-fg-faint leading-relaxed">
|
||||
{UI_STRINGS.welcomeForgotPasswordBody}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuthDesk() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [mode, setMode] = useState<Mode>(() => parseMode(searchParams.get("mode")));
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [totpCode, setTotpCode] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [pendingMessage, setPendingMessage] = useState<string | null>(null);
|
||||
const [showTotp, setShowTotp] = useState(false);
|
||||
const [showInterview, setShowInterview] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMode(parseMode(searchParams.get("mode")));
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.auth
|
||||
.me()
|
||||
.then((me) => {
|
||||
if (cancelled || !me) return;
|
||||
if (me.onboarded) {
|
||||
markWelcomeSeen();
|
||||
router.replace("/");
|
||||
return;
|
||||
}
|
||||
setShowInterview(true);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
const switchMode = (next: Mode) => {
|
||||
setMode(next);
|
||||
setError(null);
|
||||
setPendingMessage(null);
|
||||
setShowTotp(false);
|
||||
setTotpCode("");
|
||||
if (next === "signup") setPassword("");
|
||||
router.replace(next === "signin" ? "/welcome?mode=signin" : "/welcome?mode=signup");
|
||||
};
|
||||
|
||||
const afterSession = async () => {
|
||||
const me = await api.auth.me();
|
||||
if (me && !me.onboarded) {
|
||||
setShowInterview(true);
|
||||
return;
|
||||
}
|
||||
markWelcomeSeen();
|
||||
router.replace("/");
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
if (mode === "signup") {
|
||||
const data = (await api.auth.signup(email, password)) as {
|
||||
pending?: boolean;
|
||||
message?: string;
|
||||
};
|
||||
if (data?.pending) {
|
||||
setPendingMessage(data.message ?? UI_STRINGS.welcomePendingBody);
|
||||
return;
|
||||
}
|
||||
await afterSession();
|
||||
return;
|
||||
}
|
||||
|
||||
await api.auth.login(email, password, showTotp ? totpCode.trim() : undefined);
|
||||
await afterSession();
|
||||
} catch (e) {
|
||||
const msg = errorText(e);
|
||||
if (/pending admin approval/i.test(msg)) {
|
||||
setPendingMessage(msg.replace(/^tRPC.*?:\s*/, "").trim() || UI_STRINGS.welcomePendingBody);
|
||||
return;
|
||||
}
|
||||
if (/two[- ]?factor|totp/i.test(msg)) {
|
||||
setShowTotp(true);
|
||||
setError(msg.replace(/^tRPC.*?:\s*/, "").trim() || "Two-factor code required or invalid.");
|
||||
return;
|
||||
}
|
||||
setError(msg.replace(/^tRPC.*?:\s*/, "").trim() || msg);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
markWelcomeSeen();
|
||||
router.replace("/");
|
||||
};
|
||||
|
||||
if (showInterview) {
|
||||
return (
|
||||
<div className="h-dvh w-full bg-surface text-fg flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<WorkspaceInterview
|
||||
onDone={async () => {
|
||||
markWelcomeSeen();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-dvh w-full bg-surface text-fg flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md border border-line-strong bg-surface-raised rounded-xl p-6 space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-accent flex items-center justify-center">
|
||||
<span className="text-xs font-bold text-surface">IF</span>
|
||||
</div>
|
||||
<p className="text-sm font-semibold tracking-tight">{UI_STRINGS.welcomeEyebrow}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex border-b border-line-strong">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => switchMode("signup")}
|
||||
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors -mb-px ${
|
||||
mode === "signup" ? "border-accent text-fg" : "border-transparent text-fg-muted hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{UI_STRINGS.welcomeCreateAccount}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => switchMode("signin")}
|
||||
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors -mb-px ${
|
||||
mode === "signin" ? "border-accent text-fg" : "border-transparent text-fg-muted hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{UI_STRINGS.welcomeSignIn}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{pendingMessage ? (
|
||||
<section className="rounded-lg border border-warn/40 bg-warn/5 p-4 space-y-3">
|
||||
<h2 className="text-sm font-semibold text-fg">{UI_STRINGS.welcomePendingTitle}</h2>
|
||||
<p className="text-xs text-fg-muted">{pendingMessage}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => switchMode("signin")}
|
||||
className="text-xs text-accent hover:text-fg"
|
||||
>
|
||||
{UI_STRINGS.welcomeBackToSignIn}
|
||||
</button>
|
||||
</section>
|
||||
) : showTotp ? (
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-fg">{UI_STRINGS.welcomeTotpTitle}</h2>
|
||||
<p className="text-xs text-fg-faint mt-1">{UI_STRINGS.welcomeTotpHint}</p>
|
||||
</div>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-fg-faint">{UI_STRINGS.twoFactorCodePlaceholder}</span>
|
||||
<input
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
placeholder="000000"
|
||||
className="w-full min-h-[44px] bg-surface border border-line-strong rounded-lg px-3 text-sm font-mono tabular-nums tracking-widest text-center text-fg placeholder:text-fg-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || totpCode.length !== 6}
|
||||
className="w-full min-h-[44px] rounded-lg bg-accent text-white text-sm font-medium disabled:opacity-50 hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
{busy ? "Working..." : UI_STRINGS.welcomeContinue}
|
||||
</button>
|
||||
<p className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowTotp(false);
|
||||
setTotpCode("");
|
||||
setError(null);
|
||||
}}
|
||||
className="text-xs text-fg-muted hover:text-fg underline decoration-dashed"
|
||||
>
|
||||
{UI_STRINGS.welcomeBackToSignIn}
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
) : (
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-fg">{UI_STRINGS.welcomeTitle}</h2>
|
||||
<p className="text-xs text-fg-faint mt-1">{UI_STRINGS.welcomeBody}</p>
|
||||
</div>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-fg-faint">{UI_STRINGS.authEmailLabel}</span>
|
||||
<input
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
type="email"
|
||||
required
|
||||
autoComplete={mode === "signup" ? "email" : "username"}
|
||||
placeholder="you@example.com"
|
||||
className="w-full min-h-[44px] bg-surface border border-line-strong rounded-lg px-3 text-sm text-fg placeholder:text-fg-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-fg-faint">{UI_STRINGS.authPasswordLabel}</span>
|
||||
<input
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
type="password"
|
||||
required
|
||||
minLength={mode === "signup" ? 8 : undefined}
|
||||
autoComplete={mode === "signup" ? "new-password" : "current-password"}
|
||||
placeholder={mode === "signup" ? "At least 8 characters" : "Password"}
|
||||
className="w-full min-h-[44px] bg-surface border border-line-strong rounded-lg px-3 text-sm text-fg placeholder:text-fg-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !email.trim() || (mode === "signup" ? password.length < 8 : !password)}
|
||||
className="w-full min-h-[44px] rounded-lg bg-accent text-white text-sm font-medium disabled:opacity-50 hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
{busy ? "Working..." : UI_STRINGS.welcomeContinue}
|
||||
</button>
|
||||
{mode === "signin" && <ForgotPasswordHint />}
|
||||
<p className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSkip}
|
||||
className="text-xs text-fg-muted hover:text-fg underline decoration-dashed"
|
||||
>
|
||||
{UI_STRINGS.welcomeSkip}
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LogIn } from "lucide-react";
|
||||
import { api, type AuthUser } from "@/lib/trpc";
|
||||
import { UI_STRINGS } from "@/lib/strings";
|
||||
|
||||
/**
|
||||
* Header CTAs for the signed-out state.
|
||||
* Hidden until auth.me settles so signed-in users never flash Sign in.
|
||||
*/
|
||||
export function AuthHeaderActions() {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.auth
|
||||
.me()
|
||||
.then((u) => {
|
||||
if (!cancelled) setUser(u);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setUser(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setReady(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!ready || user) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="hidden sm:flex items-center gap-2">
|
||||
<Link
|
||||
href="/welcome?mode=signin"
|
||||
className="inline-flex items-center h-8 px-2.5 rounded-lg text-xs font-medium text-fg-muted hover:text-fg hover:bg-surface-sunken transition-colors"
|
||||
>
|
||||
{UI_STRINGS.welcomeSignIn}
|
||||
</Link>
|
||||
<Link
|
||||
href="/welcome?mode=signup"
|
||||
className="inline-flex items-center h-8 px-2.5 rounded-lg bg-accent text-white text-xs font-medium hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
{UI_STRINGS.welcomeCreateAccount}
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
href="/welcome?mode=signin"
|
||||
aria-label={UI_STRINGS.welcomeSignIn}
|
||||
className="sm:hidden inline-flex items-center justify-center min-h-[44px] min-w-[44px] shrink-0 rounded-lg text-fg-muted hover:text-fg hover:bg-surface-sunken transition-colors"
|
||||
>
|
||||
<LogIn className="h-5 w-5" />
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
"use client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/lib/trpc";
|
||||
import {
|
||||
EXPERIENCE_LABELS,
|
||||
DENSITY_LABELS,
|
||||
GOAL_LABELS,
|
||||
HORIZON_LABELS,
|
||||
type Density,
|
||||
type ExperienceStage,
|
||||
type Goal,
|
||||
type Horizon,
|
||||
type JargonComfort,
|
||||
} from "@/lib/workspace-profile";
|
||||
|
||||
export function ChoiceButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`w-full text-left rounded-lg border px-4 py-3 text-sm transition-colors ${
|
||||
active
|
||||
? "border-accent bg-accent/10 text-fg"
|
||||
: "border-line bg-surface text-fg hover:bg-surface-sunken"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkspaceInterview({ onDone }: { onDone: () => void | Promise<void> }) {
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState(0);
|
||||
const [experienceStage, setExperienceStage] = useState<ExperienceStage | "">("");
|
||||
const [goal, setGoal] = useState<Goal | "">("");
|
||||
const [horizon, setHorizon] = useState<Horizon | "">("");
|
||||
const [jargonComfort, setJargonComfort] = useState<JargonComfort>("plain");
|
||||
const [starter, setStarter] = useState<{
|
||||
watchlist: Array<{ symbol: string; tickerKind: string; reason: string }>;
|
||||
disclaimer: string;
|
||||
density: string;
|
||||
} | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!experienceStage) return;
|
||||
api.onboarding
|
||||
.starter({ experienceStage })
|
||||
.then(setStarter)
|
||||
.catch(() => setStarter(null));
|
||||
}, [experienceStage]);
|
||||
|
||||
const finish = async () => {
|
||||
if (!experienceStage || !goal || !horizon) return;
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
await api.onboarding.complete({
|
||||
experienceStage,
|
||||
goal,
|
||||
horizon,
|
||||
jargonComfort,
|
||||
});
|
||||
window.dispatchEvent(new Event("workspace-profile-changed"));
|
||||
await onDone();
|
||||
router.push("/portfolio");
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : "Setup failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border-2 border-accent/40 bg-surface-raised p-5 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-fg">Set up your workspace</h2>
|
||||
<p className="text-xs text-fg-faint mt-1">
|
||||
A few questions so we can size risk defaults and how much detail to show. You can change this anytime.
|
||||
</p>
|
||||
<p className="text-[10px] text-fg-muted mt-2">Step {Math.min(step + 1, 4)} of 4</p>
|
||||
</div>
|
||||
|
||||
{step === 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-fg">How much experience do you have with stocks or funds?</p>
|
||||
{(Object.keys(EXPERIENCE_LABELS) as ExperienceStage[]).map((k) => (
|
||||
<ChoiceButton
|
||||
key={k}
|
||||
active={experienceStage === k}
|
||||
onClick={() => {
|
||||
setExperienceStage(k);
|
||||
setStep(1);
|
||||
}}
|
||||
>
|
||||
{EXPERIENCE_LABELS[k]}
|
||||
</ChoiceButton>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-fg">What matters most for money you will track here?</p>
|
||||
{(Object.keys(GOAL_LABELS) as Goal[]).map((k) => (
|
||||
<ChoiceButton
|
||||
key={k}
|
||||
active={goal === k}
|
||||
onClick={() => {
|
||||
setGoal(k);
|
||||
setStep(2);
|
||||
}}
|
||||
>
|
||||
{GOAL_LABELS[k]}
|
||||
</ChoiceButton>
|
||||
))}
|
||||
<button type="button" className="text-xs text-fg-muted hover:text-fg" onClick={() => setStep(0)}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-fg">How long until you may need this money?</p>
|
||||
{(Object.keys(HORIZON_LABELS) as Horizon[]).map((k) => (
|
||||
<ChoiceButton
|
||||
key={k}
|
||||
active={horizon === k}
|
||||
onClick={() => {
|
||||
setHorizon(k);
|
||||
setStep(3);
|
||||
}}
|
||||
>
|
||||
{HORIZON_LABELS[k]}
|
||||
</ChoiceButton>
|
||||
))}
|
||||
<button type="button" className="text-xs text-fg-muted hover:text-fg" onClick={() => setStep(1)}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-fg">How do you prefer labels and terms?</p>
|
||||
{(
|
||||
[
|
||||
{ k: "plain" as const, l: "Everyday language first" },
|
||||
{ k: "mixed" as const, l: "Mix of plain and market terms" },
|
||||
{ k: "technical" as const, l: "Full market terms" },
|
||||
] as const
|
||||
).map(({ k, l }) => (
|
||||
<ChoiceButton key={k} active={jargonComfort === k} onClick={() => setJargonComfort(k)}>
|
||||
{l}
|
||||
</ChoiceButton>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{starter && (
|
||||
<div>
|
||||
<p className="text-xs text-fg-faint mb-1">
|
||||
Starter symbols to track (examples for the product - not recommendations). Density:{" "}
|
||||
<span className="capitalize">{starter.density}</span>
|
||||
</p>
|
||||
<ul className="text-xs space-y-1 max-h-40 overflow-y-auto">
|
||||
{starter.watchlist.map((s) => (
|
||||
<li key={s.symbol} className="flex gap-2">
|
||||
<span className="font-mono text-accent w-12 shrink-0">{s.symbol}</span>
|
||||
<span className="text-fg-muted">{s.reason}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
{err && <p className="text-xs text-danger">{err}</p>}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !experienceStage || !goal || !horizon}
|
||||
onClick={finish}
|
||||
className="px-4 py-2 rounded text-sm bg-accent text-white disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Setting up…" : "Finish and open Portfolio"}
|
||||
</button>
|
||||
<button type="button" className="text-xs text-fg-muted hover:text-fg" onClick={() => setStep(2)}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Kept for the workspace profile editor section that still lives in Settings.
|
||||
function WorkspaceProfileEditor({
|
||||
user,
|
||||
onSaved,
|
||||
}: {
|
||||
user: import("@/lib/trpc").AuthUser;
|
||||
onSaved: () => void | Promise<void>;
|
||||
}) {
|
||||
const [density, setDensity] = useState<Density>((user.density as Density) || "focused");
|
||||
const [experienceStage, setExperienceStage] = useState<ExperienceStage>(
|
||||
(user.experienceStage as ExperienceStage) || "never_invested",
|
||||
);
|
||||
const [goal, setGoal] = useState<Goal>((user.goal as Goal) || "grow");
|
||||
const [horizon, setHorizon] = useState<Horizon>((user.horizon as Horizon) || "long");
|
||||
const [jargonComfort, setJargonComfort] = useState<JargonComfort>(
|
||||
(user.jargonComfort as JargonComfort) || "plain",
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setDensity((user.density as Density) || "focused");
|
||||
setExperienceStage((user.experienceStage as ExperienceStage) || "never_invested");
|
||||
setGoal((user.goal as Goal) || "grow");
|
||||
setHorizon((user.horizon as Horizon) || "long");
|
||||
setJargonComfort((user.jargonComfort as JargonComfort) || "plain");
|
||||
}, [user]);
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
setMsg(null);
|
||||
try {
|
||||
await api.onboarding.updateProfile({
|
||||
density,
|
||||
experienceStage,
|
||||
goal,
|
||||
horizon,
|
||||
jargonComfort,
|
||||
});
|
||||
setMsg("Workspace preferences saved.");
|
||||
window.dispatchEvent(new Event("workspace-profile-changed"));
|
||||
await onSaved();
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : "Save failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-line-strong bg-surface-raised p-5 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-fg">Workspace</h2>
|
||||
<p className="text-xs text-fg-faint mt-1">
|
||||
Control how much detail appears in navigation and research. Focused keeps the book, monitor, and risk front and center.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-fg-faint mb-2">Experience</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{(Object.keys(EXPERIENCE_LABELS) as ExperienceStage[]).map((k) => (
|
||||
<ChoiceButton key={k} active={experienceStage === k} onClick={() => setExperienceStage(k)}>
|
||||
{EXPERIENCE_LABELS[k]}
|
||||
</ChoiceButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-fg-faint mb-2">Workspace density</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{(Object.keys(DENSITY_LABELS) as Density[]).map((k) => (
|
||||
<ChoiceButton key={k} active={density === k} onClick={() => setDensity(k)}>
|
||||
{DENSITY_LABELS[k]}
|
||||
</ChoiceButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-fg-faint mb-2">Goal</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{(Object.keys(GOAL_LABELS) as Goal[]).map((k) => (
|
||||
<ChoiceButton key={k} active={goal === k} onClick={() => setGoal(k)}>
|
||||
{GOAL_LABELS[k]}
|
||||
</ChoiceButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-fg-faint mb-2">Horizon</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{(Object.keys(HORIZON_LABELS) as Horizon[]).map((k) => (
|
||||
<ChoiceButton key={k} active={horizon === k} onClick={() => setHorizon(k)}>
|
||||
{HORIZON_LABELS[k]}
|
||||
</ChoiceButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err && <p className="text-xs text-danger">{err}</p>}
|
||||
{msg && <p className="text-xs text-up">{msg}</p>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={save}
|
||||
className="px-4 py-2 rounded text-sm bg-accent text-white disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Saving…" : "Save workspace"}
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export WorkspaceProfileEditor so Settings can keep using it from the new file.
|
||||
export { WorkspaceProfileEditor };
|
||||
@@ -43,6 +43,28 @@ export const UI_STRINGS = {
|
||||
completeOnboardingButton: "Finish setup",
|
||||
onboardingDone: "Setup complete — your starter watchlist is tracking.",
|
||||
|
||||
// Welcome desk (first-user signup / signin)
|
||||
welcomeEyebrow: "Investor Flow",
|
||||
welcomeTitle: "Research and book in one place.",
|
||||
welcomeBody: "Create an account, then wait for approval. Educational observation only.",
|
||||
welcomeContinue: "Continue",
|
||||
welcomeSkip: "Continue without an account",
|
||||
welcomePendingTitle: "Account created",
|
||||
welcomePendingBody: "An admin must approve your account before you can sign in.",
|
||||
welcomeTotpTitle: "Two-factor code",
|
||||
welcomeTotpHint: "Enter the 6-digit code from your authenticator app.",
|
||||
welcomeBackToSignIn: "Back to sign in",
|
||||
welcomeSignIn: "Sign in",
|
||||
welcomeCreateAccount: "Create account",
|
||||
welcomeForgotPassword: "Forgot password?",
|
||||
welcomeForgotPasswordBody:
|
||||
"There is no email reset. An admin can set a temporary password under Admin, Users. If you operate this host, reset it from the machine with the reset-password CLI (see DEPLOY_UNRAID.md).",
|
||||
changePasswordTitle: "Change password",
|
||||
changePasswordCurrent: "Current password",
|
||||
changePasswordNext: "New password",
|
||||
changePasswordSave: "Update password",
|
||||
changePasswordDone: "Password updated.",
|
||||
|
||||
// Portfolio & Watchlist panels
|
||||
portfolioTitle: "Holdings",
|
||||
portfolioCaption: "Symbol, shares, and average cost.",
|
||||
|
||||
@@ -1010,6 +1010,8 @@ export const api = {
|
||||
signup: (email: string, password: string) => trpcMutate<{ userId: string }>("auth.signup", { email, password }),
|
||||
login: (email: string, password: string, totp?: string) => trpcMutate<{ userId: string }>("auth.login", { email, password, ...(totp ? { totp } : {}) }),
|
||||
logout: () => trpcMutate<{ ok: boolean }>("auth.logout", {}),
|
||||
changePassword: (current: string, next: string) =>
|
||||
trpcMutate<{ ok: boolean }>("auth.changePassword", { current, next }),
|
||||
me: () => trpcQuery<AuthUser | null>("auth.me"),
|
||||
enable2fa: () => trpcMutate<{ totpSecret: string; qrUrl: string; backupCodes: string[] }>("auth.enable2fa", {}),
|
||||
confirm2fa: (totp: string) => trpcMutate<{ ok: boolean }>("auth.confirm2fa", { totp }),
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* localStorage flag used to remember that a user has visited the welcome desk.
|
||||
* Wrapped in try/catch so a broken or disabled storage does not break page load.
|
||||
*/
|
||||
|
||||
export const WELCOME_SEEN_KEY = "iflow-welcome-seen";
|
||||
|
||||
function safeGet(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return localStorage.getItem(WELCOME_SEEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasSeenWelcome(): boolean {
|
||||
const v = safeGet();
|
||||
return v !== null && v !== "" && v !== "false";
|
||||
}
|
||||
|
||||
export function markWelcomeSeen(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(WELCOME_SEEN_KEY, "1");
|
||||
} catch {
|
||||
// storage full or private mode — non-fatal, welcome will simply re-appear next visit.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user