forked from Goutam/lynkeduppro-crm
Compare commits
10 Commits
6e1a895e7e
...
08ef85869f
| Author | SHA1 | Date | |
|---|---|---|---|
| 08ef85869f | |||
| 948abf75fd | |||
| 34cb44cfa8 | |||
| f38fa80bde | |||
| 86af77c92b | |||
| 27e7bdf8f5 | |||
| 64be4b9b3f | |||
| 5186497c50 | |||
| 0e8a207640 | |||
| 7570fe7be3 |
@@ -41,3 +41,4 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
.vercel
|
||||
.env*.local
|
||||
|
||||
Generated
+780
-20
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -9,7 +9,7 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@abe-kap/appshell-sdk": "^0.2.0",
|
||||
"@abe-kap/appshell-sdk": "^0.2.4",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.21.0",
|
||||
"next": "16.2.9",
|
||||
|
||||
@@ -106,6 +106,11 @@
|
||||
.sb-user .av { width: 30px; height: 30px; border-radius: 8px; object-fit: cover; flex: 0 0 30px; }
|
||||
.sb-user .nm { font-size: 12.5px; font-weight: 600; }
|
||||
.sb-user .rl { font-size: 11px; color: var(--faint); }
|
||||
.sb-user .nm, .sb-user .rl { max-width: 130px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.sb-user-menu { position: absolute; bottom: 56px; left: 12px; right: 12px; z-index: 30; padding: 6px; border-radius: 12px; border: 1px solid var(--border-2); background: var(--panel); box-shadow: var(--shadow), 0 8px 30px -12px rgba(0,0,0,0.55); animation: ds-rise 0.14s ease; }
|
||||
.sb-user-menu button { display: flex; align-items: center; gap: 9px; width: 100%; padding: 9px 10px; border: 0; border-radius: 9px; background: none; color: var(--text-2); font-family: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; text-align: left; }
|
||||
.sb-user-menu button:hover { background: var(--panel-2); }
|
||||
.sb-user-menu button.danger { color: var(--red, #ef4444); }
|
||||
|
||||
/* ---- main ---- */
|
||||
.dash-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react";
|
||||
import { PortalAside, PanelBrand } from "@/components/portal/parts";
|
||||
import { RegisterFlow } from "@/components/portal/register-flow";
|
||||
import { CookieBanner } from "@/components/portal/bits";
|
||||
import { isShellConfigured } from "@/lib/appshell";
|
||||
|
||||
/**
|
||||
* Post-OAuth onboarding — a first-time Google user completes their CRM profile
|
||||
* (everything except email, which Google already verified). Only reachable while
|
||||
* authenticated; unauthenticated visitors are sent back to sign in.
|
||||
*/
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter();
|
||||
const { status } = useAuth();
|
||||
const { ready } = useAppShell();
|
||||
|
||||
useEffect(() => {
|
||||
if (isShellConfigured() && ready && status === "unauthenticated") router.replace("/portal/login");
|
||||
}, [ready, status, router]);
|
||||
|
||||
return (
|
||||
<main className="portal-main">
|
||||
<span className="portal-grid" />
|
||||
<div className="portal-split anim-in">
|
||||
<PortalAside />
|
||||
<section className="portal-panel">
|
||||
<div style={{ width: "100%", maxWidth: 420 }}>
|
||||
<PanelBrand />
|
||||
<RegisterFlow mode="onboard" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<CookieBanner />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@abe-kap/appshell-sdk/react";
|
||||
import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react";
|
||||
import { isShellConfigured } from "@/lib/appshell";
|
||||
|
||||
/**
|
||||
@@ -14,16 +14,21 @@ import { isShellConfigured } from "@/lib/appshell";
|
||||
export function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const { status } = useAuth();
|
||||
const { ready } = useAppShell();
|
||||
const shell = isShellConfigured();
|
||||
|
||||
useEffect(() => {
|
||||
if (shell && status === "unauthenticated") router.replace("/portal/login");
|
||||
}, [shell, status, router]);
|
||||
// Only bounce to login once the SDK has finished booting AND restore() has
|
||||
// resolved to unauthenticated. Redirecting while boot is still in flight would
|
||||
// drop a perfectly valid session on reload (the status is transiently not-yet
|
||||
// "authenticated" during boot).
|
||||
if (shell && ready && status === "unauthenticated") router.replace("/portal/login");
|
||||
}, [shell, ready, status, router]);
|
||||
|
||||
if (shell && status !== "authenticated") {
|
||||
if (shell && (!ready || status !== "authenticated")) {
|
||||
return (
|
||||
<div style={{ minHeight: "60vh", display: "grid", placeItems: "center", color: "var(--muted, #888)" }}>
|
||||
{status === "loading" ? "Loading your workspace…" : "Redirecting to sign in…"}
|
||||
{ready && status === "unauthenticated" ? "Redirecting to sign in…" : "Loading your workspace…"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronsUpDown } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ChevronsUpDown, LogOut } from "lucide-react";
|
||||
import { useAuth } from "@abe-kap/appshell-sdk/react";
|
||||
import { Icon } from "./ui";
|
||||
import { user } from "./account-data";
|
||||
|
||||
function initialsOf(name: string): string {
|
||||
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
|
||||
}
|
||||
|
||||
export type NavItem = { key: string; label: string; icon: string; subtitle?: string };
|
||||
export type NavGroup = { title: string; items: NavItem[] };
|
||||
|
||||
@@ -61,6 +68,22 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
export const NAV_ITEMS: NavItem[] = NAV_GROUPS.flatMap((g) => g.items);
|
||||
|
||||
export function Sidebar({ active, onSelect }: { active: string; onSelect: (k: string) => void }) {
|
||||
const router = useRouter();
|
||||
const { user: me, logout, context } = useAuth();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
// Real signed-in identity from the App Context Envelope; fall back to the static
|
||||
// demo user only when the Shell isn't wired.
|
||||
const roleLabel = context?.scope?.role ? context.scope.role.charAt(0).toUpperCase() + context.scope.role.slice(1) : "";
|
||||
const name = me?.displayName || user.name;
|
||||
const initials = me ? initialsOf(me.displayName) : user.initials;
|
||||
const secondary = me?.email || roleLabel || user.role;
|
||||
|
||||
async function signOut() {
|
||||
setMenuOpen(false);
|
||||
try { await logout(); } catch { /* ignore — proceed to portal either way */ }
|
||||
router.replace("/portal/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="dash-sidebar">
|
||||
<div className="dash-brand">
|
||||
@@ -82,12 +105,20 @@ export function Sidebar({ active, onSelect }: { active: string; onSelect: (k: st
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="sb-foot">
|
||||
<button className="sb-user">
|
||||
<span className="av" style={{ background: user.avatarGradient, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{user.initials}</span>
|
||||
<div style={{ flex: 1, textAlign: "left" }}>
|
||||
<div className="nm">{user.name}</div>
|
||||
<div className="rl">{user.role}</div>
|
||||
<div className="sb-foot" style={{ position: "relative" }}>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="tm-menu-scrim" onClick={() => setMenuOpen(false)} />
|
||||
<div className="sb-user-menu" role="menu">
|
||||
<button className="danger" onClick={signOut}><LogOut size={15} /> Sign out</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<button className="sb-user" onClick={() => setMenuOpen((o) => !o)} aria-haspopup="menu" aria-expanded={menuOpen}>
|
||||
<span className="av" style={{ background: user.avatarGradient, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{initials}</span>
|
||||
<div style={{ flex: 1, textAlign: "left", minWidth: 0 }}>
|
||||
<div className="nm">{name}</div>
|
||||
<div className="rl" style={{ overflow: "hidden", textOverflow: "ellipsis" }}>{secondary}</div>
|
||||
</div>
|
||||
<ChevronsUpDown size={15} />
|
||||
</button>
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { Sun, Moon, ChevronDown } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Sun, Moon, ChevronDown, LogOut } from "lucide-react";
|
||||
import { useAuth } from "@abe-kap/appshell-sdk/react";
|
||||
import { Icon } from "./ui";
|
||||
import { user } from "./account-data";
|
||||
import { useAuth } from "@abe-kap/appshell-sdk/react";
|
||||
|
||||
function initialsOf(name: string): string {
|
||||
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase();
|
||||
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
|
||||
}
|
||||
|
||||
export function Topbar({ theme, onToggle, title, subtitle }: { theme: "dark" | "light"; onToggle: () => void; title: string; subtitle: string }) {
|
||||
// When signed in through the Shell, show the real identity from the App Context
|
||||
// Envelope; otherwise fall back to the static demo user.
|
||||
const { user: me } = useAuth();
|
||||
const router = useRouter();
|
||||
const { user: me, logout, context } = useAuth();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const roleLabel = context?.scope?.role ? context.scope.role.charAt(0).toUpperCase() + context.scope.role.slice(1) : "";
|
||||
const name = me?.displayName || user.name;
|
||||
const initials = me ? initialsOf(me.displayName) : user.initials;
|
||||
const secondary = me?.email || roleLabel || user.role;
|
||||
|
||||
async function signOut() {
|
||||
setMenuOpen(false);
|
||||
try { await logout(); } catch { /* ignore */ }
|
||||
router.replace("/portal/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="dash-topbar">
|
||||
<div className="dash-title">
|
||||
@@ -30,14 +43,24 @@ export function Topbar({ theme, onToggle, title, subtitle }: { theme: "dark" | "
|
||||
<Icon name="bell" size={18} />
|
||||
<span style={{ position: "absolute", top: 9, right: 10, width: 7, height: 7, borderRadius: 99, background: "var(--orange)", border: "2px solid var(--panel)" }} />
|
||||
</button>
|
||||
<button className="top-user">
|
||||
<span className="av" style={{ background: user.avatarGradient, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{initials}</span>
|
||||
<div style={{ textAlign: "left" }}>
|
||||
<div className="nm">{name}</div>
|
||||
<div className="rl">{user.role}</div>
|
||||
</div>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
<div className="top-user-wrap" style={{ position: "relative" }}>
|
||||
<button className="top-user" onClick={() => setMenuOpen((o) => !o)} aria-haspopup="menu" aria-expanded={menuOpen}>
|
||||
<span className="av" style={{ background: user.avatarGradient, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{initials}</span>
|
||||
<div style={{ textAlign: "left", minWidth: 0 }}>
|
||||
<div className="nm">{name}</div>
|
||||
<div className="rl" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: 150 }}>{secondary}</div>
|
||||
</div>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="tm-menu-scrim" onClick={() => setMenuOpen(false)} />
|
||||
<div className="tm-menu-pop" role="menu">
|
||||
<button className="danger" onClick={signOut}><LogOut size={15} /> Sign out</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -24,8 +24,8 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export function LoginFlow() {
|
||||
const router = useRouter();
|
||||
const { login, loginWithOAuth, completeOAuthLogin } = useAuth();
|
||||
const { ready } = useAppShell();
|
||||
const { login, loginWithOAuth, completeOAuthLogin, getUserEmail } = useAuth();
|
||||
const { ready, sdk } = useAppShell();
|
||||
const [step, setStep] = useState<Step>("identify");
|
||||
const [email, setEmail] = useState("");
|
||||
const [account, setAccount] = useState<Account | null>(null);
|
||||
@@ -76,17 +76,26 @@ export function LoginFlow() {
|
||||
if (!ready) { setStep("connecting"); return; }
|
||||
setStep("connecting");
|
||||
completeOAuthLogin(code)
|
||||
.then((ace) => {
|
||||
if (ace) {
|
||||
// Clear the ?code=… from the URL, then enter the app.
|
||||
window.history.replaceState({}, "", "/portal/login");
|
||||
router.replace("/dashboard");
|
||||
} else {
|
||||
replace("identify");
|
||||
}
|
||||
.then(async (ace) => {
|
||||
if (!ace) { replace("identify"); return; }
|
||||
// First-time Google user (no CRM profile yet) → complete onboarding; an
|
||||
// existing profile → straight to the dashboard.
|
||||
let registered = false;
|
||||
try {
|
||||
const st = await sdk.query<{ registered: boolean }>("crm.account.registrationStatus");
|
||||
registered = !!st?.registered;
|
||||
} catch { registered = false; }
|
||||
window.history.replaceState({}, "", "/portal/login");
|
||||
if (registered) { router.replace("/dashboard"); return; }
|
||||
// Capture the verified email now (session is fresh) so onboarding prefills it.
|
||||
try { const em = await getUserEmail(); if (em) sessionStorage.setItem("onboard_email", em); } catch { /* ignore */ }
|
||||
router.replace("/portal/onboarding");
|
||||
})
|
||||
.catch(() => { setFlash("Sign-in with that provider didn't complete. Try again."); replace("identify"); });
|
||||
}, [ready, completeOAuthLogin, router]);
|
||||
.catch(() => {
|
||||
setFlash("Google sign-in didn't complete. Please try again.");
|
||||
replace("identify");
|
||||
});
|
||||
}, [ready, completeOAuthLogin, router, sdk, getUserEmail]);
|
||||
|
||||
/* ---- auth resolution ---- */
|
||||
function afterAuth(factor: "password" | "passkey" | "otp" | "totp" | "push" | "social") {
|
||||
@@ -141,14 +150,18 @@ export function LoginFlow() {
|
||||
/* =================================================================== */
|
||||
return (
|
||||
<div className="card anim-fade-up" key={step}>
|
||||
{step === "identify" && <Identify email={email} setEmail={setEmail} onSocial={onSocial} onEmail={identifyEmail} onPhone={startPhoneLogin} toRegister={() => router.push("/portal/register")} />}
|
||||
{step === "identify" && (
|
||||
<>
|
||||
{flash && <div style={{ marginBottom: 16 }}><FlashNote tone="error">{flash}</FlashNote></div>}
|
||||
<Identify email={email} setEmail={setEmail} onSocial={onSocial} onEmail={identifyEmail} onPhone={startPhoneLogin} toRegister={() => router.push("/portal/register")} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "connecting" && (
|
||||
<div className="interstitial">
|
||||
<Spinner lg />
|
||||
<div>
|
||||
<h1 style={{ fontSize: 20 }}>{provider ? `Connecting to ${cap(provider)}…` : "Looking up your account…"}</h1>
|
||||
{provider && <p className="sub" style={{ marginTop: 6 }}>Demo mode — no provider keys configured.</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -299,7 +312,6 @@ function Identify({ email, setEmail, onSocial, onEmail, onPhone, toRegister }: {
|
||||
<span><Icon name="shield" size={13} /> Licensed & Insured</span>
|
||||
<span><Icon name="check" size={13} /> Drone-Powered</span>
|
||||
</div>
|
||||
<p className="demo-hint">Demo: any email signs in; an email starting with “new” shows “not found”.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -334,7 +346,6 @@ function Passkey({ account, onBack, onPassword, onAnother, onSuccess, onFail }:
|
||||
<button className="link" onClick={onPassword}>Use your password instead</button>
|
||||
<button className="link" onClick={onAnother}>Try another way</button>
|
||||
</div>
|
||||
<p className="demo-hint">Demo: passkey always succeeds here.</p>
|
||||
<button hidden onClick={onFail} />
|
||||
</div>
|
||||
);
|
||||
@@ -387,7 +398,6 @@ function Password({ account, email, login, onAuthenticated, flash, onBack, onFor
|
||||
<button className="link" onClick={onForgot}>Forgot password?</button>
|
||||
<button className="link" onClick={onOtp}>Sign in with a one-time code</button>
|
||||
</div>
|
||||
{!isShellConfigured() && <p className="demo-hint">Demo: any password works; type “wrong” to see the lockout. Password always needs 2-step next.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -468,7 +478,6 @@ function OtpVerify({ account, email, initialChannel = "email", remember, setReme
|
||||
<span />
|
||||
</div>
|
||||
<RememberDevice checked={remember} onChange={setRemember} note="2-step is still required at each sign-in." />
|
||||
{!shell && <p className="demo-hint">Demo: any 6 digits verify; “000000” shows an error.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -523,7 +532,6 @@ function Totp({ remember, setRemember, onBack, onVerified }: { remember: boolean
|
||||
{setupKey ? "Enter a 6-digit code instead" : "Enter setup key instead"}
|
||||
</button>
|
||||
<RememberDevice checked={remember} onChange={setRemember} />
|
||||
<p className="demo-hint">Demo: any 6 digits verify; “000000” fails.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -542,7 +550,6 @@ function PushApprove({ onBack, onApproved, onCancel }: { onBack: () => void; onA
|
||||
<p className="sub" style={{ textAlign: "center" }}>{approved ? "Your sign-in was approved." : "We sent a notification to your mobile app. Approve it to continue."}</p>
|
||||
<div className="interstitial">{approved ? <Icon name="check" size={30} /> : <Spinner lg />}</div>
|
||||
{!approved && <button className="btn" onClick={onCancel}>Cancel</button>}
|
||||
<p className="demo-hint">Demo: auto-approves after ~3 seconds.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -626,7 +633,6 @@ function ForgotCode({ onBack, onOk }: { onBack: () => void; onOk: () => void })
|
||||
<div style={{ marginTop: 18 }}><OtpBoxes onComplete={(c) => (c === "000000" ? setError(true) : onOk())} error={error} /></div>
|
||||
{error && <div style={{ marginTop: 12 }}><FlashNote tone="error">Incorrect code.</FlashNote></div>}
|
||||
<div style={{ marginTop: 14 }}><ResendLink seconds={30} /></div>
|
||||
<p className="demo-hint">Demo: any 6 digits work; “000000” fails.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,9 +19,13 @@ type Addr = { line1?: string; line2?: string; city?: string; state?: string; pos
|
||||
const STEPS = ["Account", "Verify", "Address"];
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export function RegisterFlow() {
|
||||
export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboard" }) {
|
||||
// "onboard" = an already-authenticated OAuth (Google) user completing their CRM
|
||||
// profile: email is skipped (Google-verified), the auth account already exists so
|
||||
// we only SET a password + persist the profile, and the OTP-verify step is skipped.
|
||||
const onboard = mode === "onboard";
|
||||
const router = useRouter();
|
||||
const { register } = useAuth();
|
||||
const { register, setPassword, getUserEmail } = useAuth();
|
||||
const { sdk } = useAppShell();
|
||||
const [step, setStep] = useState(0);
|
||||
const [submitErr, setSubmitErr] = useState("");
|
||||
@@ -50,6 +54,17 @@ export function RegisterFlow() {
|
||||
const [emailVerified, setEmailVerified] = useState(false);
|
||||
const [phoneVerified, setPhoneVerified] = useState(false);
|
||||
|
||||
// Onboarding: prefill the verified email — from the session-storage hint the login
|
||||
// page stashed at OAuth time, then confirmed via the live Supabase session.
|
||||
useEffect(() => {
|
||||
if (!onboard) return;
|
||||
try { const cached = sessionStorage.getItem("onboard_email"); if (cached) setEmail(cached); } catch { /* ignore */ }
|
||||
getUserEmail().then((e) => { if (e) setEmail(e); }).catch(() => { /* ignore */ });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [onboard]);
|
||||
|
||||
const STEP_LABELS = onboard ? ["Profile", "Address"] : STEPS;
|
||||
|
||||
const isSelf = relationship === "Customer" || relationship === "Owner";
|
||||
const isEmployee = relationship === "Employee";
|
||||
const country = countryCodes.find((c) => c.code === cc)!;
|
||||
@@ -76,8 +91,11 @@ export function RegisterFlow() {
|
||||
|
||||
if (isShellConfigured()) {
|
||||
setSubmitErr("");
|
||||
// 1) Auth account — appshell → Supabase (SSO users already have a session).
|
||||
if (!sso) {
|
||||
// 1) Auth account. Onboarding users are already authenticated via Google — set
|
||||
// a password so email+password login works too. Everyone else registers now.
|
||||
if (onboard) {
|
||||
try { if (pw) await setPassword(pw); } catch { /* non-fatal — the profile still saves */ }
|
||||
} else if (!sso) {
|
||||
try { await register(finalEmail, pw); }
|
||||
catch { setSubmitErr("We couldn't create that account. The email may already be registered."); return; }
|
||||
}
|
||||
@@ -97,8 +115,9 @@ export function RegisterFlow() {
|
||||
mailingSameAsRegistered: mailingSame,
|
||||
consentTerms: termsOk, consentPrivacy: privacyOk,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("crm.account.register failed (continuing):", e);
|
||||
} catch {
|
||||
if (onboard) { setSubmitErr("Couldn't save your profile. Please try again."); return; }
|
||||
// register mode: non-fatal — the auth account exists; the profile can be filled in later.
|
||||
}
|
||||
}
|
||||
router.push("/dashboard");
|
||||
@@ -106,10 +125,11 @@ export function RegisterFlow() {
|
||||
|
||||
return (
|
||||
<div className="card anim-fade-up">
|
||||
<Stepper current={step} />
|
||||
<Stepper current={step} labels={STEP_LABELS} />
|
||||
|
||||
{step === 0 && (
|
||||
<StepAccount
|
||||
onboard={onboard}
|
||||
sso={sso} setSso={setSso} email={email} setEmail={setEmail}
|
||||
first={first} setFirst={setFirst} last={last} setLast={setLast}
|
||||
cc={cc} setCc={setCc} phone={phone} setPhone={setPhone} phoneOk={phoneOk} country={country}
|
||||
@@ -123,7 +143,7 @@ export function RegisterFlow() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
{step === 1 && !onboard && (
|
||||
<StepVerify
|
||||
emailValue={sso?.email || email} cc={cc} phone={phone} country={country}
|
||||
emailVerified={emailVerified} setEmailVerified={setEmailVerified}
|
||||
@@ -132,10 +152,10 @@ export function RegisterFlow() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
{((onboard && step === 1) || (!onboard && step === 2)) && (
|
||||
<>
|
||||
{submitErr && <div style={{ marginBottom: 14 }}><FlashNote tone="error">{submitErr}</FlashNote></div>}
|
||||
<StepAddress sameAs={mailingSame} setSameAs={setMailingSame} onRegAddr={setRegAddr} onMailAddr={setMailAddr} onBack={() => setStep(1)} onFinish={finish} />
|
||||
<StepAddress sameAs={mailingSame} setSameAs={setMailingSame} onRegAddr={setRegAddr} onMailAddr={setMailAddr} onBack={() => setStep(onboard ? 0 : 1)} onFinish={finish} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -153,9 +173,10 @@ function StepAccount(p: {
|
||||
alloeNo: string; setAlloeNo: (v: string) => void; alloeIdOk: boolean;
|
||||
alloeFirst: string; setAlloeFirst: (v: string) => void; alloeLast: string; setAlloeLast: (v: string) => void;
|
||||
termsOk: boolean; setTermsOk: (v: boolean) => void; privacyOk: boolean; setPrivacyOk: (v: boolean) => void;
|
||||
valid: boolean; onContinue: () => void; toLogin: () => void;
|
||||
valid: boolean; onContinue: () => void; toLogin: () => void; onboard?: boolean;
|
||||
}) {
|
||||
const [phase, setPhase] = useState<"sso" | "profile">("sso");
|
||||
// Onboarding (OAuth) starts on the profile step — email is already known + verified.
|
||||
const [phase, setPhase] = useState<"sso" | "profile">(p.onboard ? "profile" : "sso");
|
||||
const [modal, setModal] = useState<null | "terms" | "privacy">(null);
|
||||
|
||||
if (phase === "sso") {
|
||||
@@ -186,12 +207,13 @@ function StepAccount(p: {
|
||||
<h1>Complete your profile</h1>
|
||||
<p className="sub">Tell us a bit about you to set up your account.</p>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
{p.sso ? (
|
||||
<div className="conn-banner conn-green"><Icon name="check" size={16} /> Connected with {cap(p.sso.provider)} · {p.sso.email}</div>
|
||||
) : (
|
||||
<div className="conn-banner conn-blue"><Icon name="mail" size={16} /> Creating account for {p.email}</div>
|
||||
)}
|
||||
<div className="field" style={{ marginTop: 16 }}>
|
||||
<label className="label">Email address</label>
|
||||
<div className="input-wrap">
|
||||
<span className="input-ico"><Icon name="mail" size={17} /></span>
|
||||
<input className="input" type="email" value={p.email} disabled readOnly aria-label="Email address" />
|
||||
</div>
|
||||
<span className="faint" style={{ fontSize: 12 }}>{p.onboard ? "Verified with Google — this can't be changed." : "The email you're registering with."}</span>
|
||||
</div>
|
||||
|
||||
<div className="row gap-3" style={{ marginTop: 18, alignItems: "center" }}>
|
||||
@@ -289,7 +311,7 @@ function StepAccount(p: {
|
||||
|
||||
{!p.valid && <p className="hint-line">Fill all required fields and accept both documents to continue.</p>}
|
||||
|
||||
<button className="btn btn-primary" style={{ marginTop: 14 }} disabled={!p.valid} onClick={p.onContinue}>Create Account & Verify <Icon name="arrowR" size={16} /></button>
|
||||
<button className="btn btn-primary" style={{ marginTop: 14 }} disabled={!p.valid} onClick={p.onContinue}>{p.onboard ? "Continue" : "Create Account & Verify"} <Icon name="arrowR" size={16} /></button>
|
||||
|
||||
{modal === "terms" && <LegalModal doc={TERMS} onClose={() => setModal(null)} onReviewed={() => { reviewed.terms = true; setModal(null); }} />}
|
||||
{modal === "privacy" && <LegalModal doc={PRIVACY} onClose={() => setModal(null)} onReviewed={() => { reviewed.privacy = true; setModal(null); }} />}
|
||||
@@ -404,7 +426,6 @@ function VerifyChannel({ kind, initial, country, cc, initialPhone, verified, onV
|
||||
<div style={{ marginTop: 12 }}><ResendLink seconds={60} /></div>
|
||||
</div>
|
||||
)}
|
||||
<p className="demo-hint">Demo: any 6 digits verify; “000000” fails. Email with “full”/“bo” shows send errors.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -582,10 +603,10 @@ function AddressBlock({ idPrefix, onChange }: { idPrefix: string; onChange?: (a:
|
||||
}
|
||||
|
||||
/* ====================== stepper ====================== */
|
||||
function Stepper({ current }: { current: number }) {
|
||||
function Stepper({ current, labels = STEPS }: { current: number; labels?: string[] }) {
|
||||
return (
|
||||
<div className="steps">
|
||||
{STEPS.map((label, i) => {
|
||||
{labels.map((label, i) => {
|
||||
const done = i < current, on = i === current;
|
||||
return (
|
||||
<div key={label} style={{ display: "contents" }}>
|
||||
|
||||
Reference in New Issue
Block a user