forked from Goutam/lynkeduppro-crm
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e43c35af4d | |||
| c05aa0641c |
@@ -41,4 +41,3 @@ yarn-error.log*
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
.vercel
|
.vercel
|
||||||
.env*.local
|
|
||||||
|
|||||||
Generated
+20
-780
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@abe-kap/appshell-sdk": "^0.2.6",
|
"@abe-kap/appshell-sdk": "^0.2.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.21.0",
|
"lucide-react": "^1.21.0",
|
||||||
"next": "16.2.9",
|
"next": "16.2.9",
|
||||||
|
|||||||
@@ -106,11 +106,6 @@
|
|||||||
.sb-user .av { width: 30px; height: 30px; border-radius: 8px; object-fit: cover; flex: 0 0 30px; }
|
.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 .nm { font-size: 12.5px; font-weight: 600; }
|
||||||
.sb-user .rl { font-size: 11px; color: var(--faint); }
|
.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 ---- */
|
/* ---- main ---- */
|
||||||
.dash-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
.dash-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } 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).
|
|
||||||
*
|
|
||||||
* This screen is ONLY a step in the OAuth → onboarding handoff: the login page
|
|
||||||
* stashes the verified email in `sessionStorage` right before routing here. Any
|
|
||||||
* other way in — a typed URL, a fresh tab, a lost session — has no handoff hint
|
|
||||||
* (and possibly no session), so we bounce back to sign in rather than showing an
|
|
||||||
* empty form.
|
|
||||||
*/
|
|
||||||
export default function OnboardingPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const { status } = useAuth();
|
|
||||||
const { ready } = useAppShell();
|
|
||||||
const [allowed, setAllowed] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isShellConfigured()) { setAllowed(true); return; } // local dev without the Shell
|
|
||||||
if (!ready) return; // wait for session restore
|
|
||||||
let hasHandoff = false;
|
|
||||||
try { hasHandoff = !!sessionStorage.getItem("onboard_email"); } catch { /* ignore */ }
|
|
||||||
if (status === "unauthenticated" || !hasHandoff) {
|
|
||||||
router.replace("/portal/login");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setAllowed(true);
|
|
||||||
}, [ready, status, router]);
|
|
||||||
|
|
||||||
if (!allowed) return <main className="portal-main"><span className="portal-grid" /></main>;
|
|
||||||
|
|
||||||
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 { useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react";
|
import { useAuth } from "@abe-kap/appshell-sdk/react";
|
||||||
import { isShellConfigured } from "@/lib/appshell";
|
import { isShellConfigured } from "@/lib/appshell";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,21 +14,16 @@ import { isShellConfigured } from "@/lib/appshell";
|
|||||||
export function AuthGate({ children }: { children: React.ReactNode }) {
|
export function AuthGate({ children }: { children: React.ReactNode }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { status } = useAuth();
|
const { status } = useAuth();
|
||||||
const { ready } = useAppShell();
|
|
||||||
const shell = isShellConfigured();
|
const shell = isShellConfigured();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only bounce to login once the SDK has finished booting AND restore() has
|
if (shell && status === "unauthenticated") router.replace("/portal/login");
|
||||||
// resolved to unauthenticated. Redirecting while boot is still in flight would
|
}, [shell, status, router]);
|
||||||
// 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 && (!ready || status !== "authenticated")) {
|
if (shell && status !== "authenticated") {
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: "60vh", display: "grid", placeItems: "center", color: "var(--muted, #888)" }}>
|
<div style={{ minHeight: "60vh", display: "grid", placeItems: "center", color: "var(--muted, #888)" }}>
|
||||||
{ready && status === "unauthenticated" ? "Redirecting to sign in…" : "Loading your workspace…"}
|
{status === "loading" ? "Loading your workspace…" : "Redirecting to sign in…"}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { ChevronsUpDown } from "lucide-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 { Icon } from "./ui";
|
||||||
import { user } from "./account-data";
|
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 NavItem = { key: string; label: string; icon: string; subtitle?: string };
|
||||||
export type NavGroup = { title: string; items: NavItem[] };
|
export type NavGroup = { title: string; items: NavItem[] };
|
||||||
|
|
||||||
@@ -68,22 +61,6 @@ export const NAV_GROUPS: NavGroup[] = [
|
|||||||
export const NAV_ITEMS: NavItem[] = NAV_GROUPS.flatMap((g) => g.items);
|
export const NAV_ITEMS: NavItem[] = NAV_GROUPS.flatMap((g) => g.items);
|
||||||
|
|
||||||
export function Sidebar({ active, onSelect }: { active: string; onSelect: (k: string) => void }) {
|
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 (
|
return (
|
||||||
<aside className="dash-sidebar">
|
<aside className="dash-sidebar">
|
||||||
<div className="dash-brand">
|
<div className="dash-brand">
|
||||||
@@ -105,20 +82,12 @@ export function Sidebar({ active, onSelect }: { active: string; onSelect: (k: st
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="sb-foot" style={{ position: "relative" }}>
|
<div className="sb-foot">
|
||||||
{menuOpen && (
|
<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 className="tm-menu-scrim" onClick={() => setMenuOpen(false)} />
|
<div style={{ flex: 1, textAlign: "left" }}>
|
||||||
<div className="sb-user-menu" role="menu">
|
<div className="nm">{user.name}</div>
|
||||||
<button className="danger" onClick={signOut}><LogOut size={15} /> Sign out</button>
|
<div className="rl">{user.role}</div>
|
||||||
</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>
|
</div>
|
||||||
<ChevronsUpDown size={15} />
|
<ChevronsUpDown size={15} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,33 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { Sun, Moon, ChevronDown } from "lucide-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 { Icon } from "./ui";
|
||||||
import { user } from "./account-data";
|
import { user } from "./account-data";
|
||||||
|
import { useAuth } from "@abe-kap/appshell-sdk/react";
|
||||||
|
|
||||||
function initialsOf(name: string): string {
|
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 }) {
|
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
|
// When signed in through the Shell, show the real identity from the App Context
|
||||||
// Envelope; otherwise fall back to the static demo user.
|
// Envelope; otherwise fall back to the static demo user.
|
||||||
const router = useRouter();
|
const { user: me } = useAuth();
|
||||||
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 name = me?.displayName || user.name;
|
||||||
const initials = me ? initialsOf(me.displayName) : user.initials;
|
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 (
|
return (
|
||||||
<header className="dash-topbar">
|
<header className="dash-topbar">
|
||||||
<div className="dash-title">
|
<div className="dash-title">
|
||||||
@@ -43,24 +30,14 @@ export function Topbar({ theme, onToggle, title, subtitle }: { theme: "dark" | "
|
|||||||
<Icon name="bell" size={18} />
|
<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)" }} />
|
<span style={{ position: "absolute", top: 9, right: 10, width: 7, height: 7, borderRadius: 99, background: "var(--orange)", border: "2px solid var(--panel)" }} />
|
||||||
</button>
|
</button>
|
||||||
<div className="top-user-wrap" style={{ position: "relative" }}>
|
<button className="top-user">
|
||||||
<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>
|
||||||
<span className="av" style={{ background: user.avatarGradient, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{initials}</span>
|
<div style={{ textAlign: "left" }}>
|
||||||
<div style={{ textAlign: "left", minWidth: 0 }}>
|
<div className="nm">{name}</div>
|
||||||
<div className="nm">{name}</div>
|
<div className="rl">{user.role}</div>
|
||||||
<div className="rl" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: 150 }}>{secondary}</div>
|
</div>
|
||||||
</div>
|
<ChevronDown size={16} />
|
||||||
<ChevronDown size={16} />
|
</button>
|
||||||
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -145,13 +145,18 @@ export function startSocial(provider: "google" | "microsoft" | "apple"): boolean
|
|||||||
if (url) { window.location.href = url; return true; }
|
if (url) { window.location.href = url; return true; }
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
export function SocialButtons({ onPick, verb = "Continue" }: { onPick: (p: "google") => void; verb?: string }) {
|
export function SocialButtons({ onPick, verb = "Continue" }: { onPick: (p: "google" | "microsoft" | "apple") => void; verb?: string }) {
|
||||||
// Only Google is offered (Microsoft/Apple intentionally hidden).
|
|
||||||
return (
|
return (
|
||||||
<div className="col gap-3">
|
<div className="col gap-3">
|
||||||
<button className="btn btn-oauth" type="button" onClick={() => onPick("google")}>
|
<button className="btn btn-oauth" type="button" onClick={() => onPick("google")}>
|
||||||
<GoogleMark /> {verb} with Google
|
<GoogleMark /> {verb} with Google
|
||||||
</button>
|
</button>
|
||||||
|
<button className="btn btn-oauth" type="button" onClick={() => onPick("microsoft")}>
|
||||||
|
<MicrosoftMark /> {verb} with Microsoft / Outlook
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-oauth" type="button" onClick={() => onPick("apple")}>
|
||||||
|
<AppleMark /> {verb} with Apple
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
PasswordStrength,
|
PasswordStrength,
|
||||||
} from "./bits";
|
} from "./bits";
|
||||||
import { lookupAccount, maskEmail, type Account } from "./data";
|
import { lookupAccount, maskEmail, type Account } from "./data";
|
||||||
import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react";
|
import { useAuth } from "@abe-kap/appshell-sdk/react";
|
||||||
import { isShellConfigured } from "@/lib/appshell";
|
import { isShellConfigured } from "@/lib/appshell";
|
||||||
|
|
||||||
// Map the portal's social button ids to Supabase OAuth provider ids.
|
// Map the portal's social button ids to Supabase OAuth provider ids.
|
||||||
@@ -24,26 +24,13 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|||||||
|
|
||||||
export function LoginFlow() {
|
export function LoginFlow() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { login, loginWithOAuth, completeOAuthLogin, getUserEmail } = useAuth();
|
const { login, loginWithOAuth, completeOAuthLogin } = useAuth();
|
||||||
const { ready, sdk } = useAppShell();
|
|
||||||
const [step, setStep] = useState<Step>("identify");
|
const [step, setStep] = useState<Step>("identify");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [account, setAccount] = useState<Account | null>(null);
|
const [account, setAccount] = useState<Account | null>(null);
|
||||||
const [provider, setProvider] = useState<string>("");
|
const [provider, setProvider] = useState<string>("");
|
||||||
const [remember, setRemember] = useState(false);
|
const [remember, setRemember] = useState(false);
|
||||||
const [flash, setFlash] = useState<string>("");
|
const [flash, setFlash] = useState<string>("");
|
||||||
const [otpChannel, setOtpChannel] = useState<"email" | "sms">("email");
|
|
||||||
|
|
||||||
// Minimal account stand-in for passwordless entry points (Shell mode).
|
|
||||||
function blankAccount(): Account {
|
|
||||||
return { firstName: "", name: "", initials: "", hasPasskey: false, hasTotp: false, hasPush: false, maskedEmail: maskEmail(email || ""), maskedPhone: "", maskedWa: "" };
|
|
||||||
}
|
|
||||||
// Direct "sign in with phone" → the one-time-code screen, SMS preselected.
|
|
||||||
function startPhoneLogin() {
|
|
||||||
setAccount(blankAccount());
|
|
||||||
setOtpChannel("sms");
|
|
||||||
replace("otp");
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- history hash sync ---- */
|
/* ---- history hash sync ---- */
|
||||||
function push(s: Step) {
|
function push(s: Step) {
|
||||||
@@ -71,31 +58,11 @@ export function LoginFlow() {
|
|||||||
if (!isShellConfigured()) return;
|
if (!isShellConfigured()) return;
|
||||||
const code = new URLSearchParams(window.location.search).get("code");
|
const code = new URLSearchParams(window.location.search).get("code");
|
||||||
if (!code) return;
|
if (!code) return;
|
||||||
// Wait until the SDK has booted — completeOAuthLogin no-ops (returns null) if
|
|
||||||
// sdk.auth isn't ready yet, and this effect only re-runs when `ready` flips.
|
|
||||||
if (!ready) { setStep("connecting"); return; }
|
|
||||||
setStep("connecting");
|
setStep("connecting");
|
||||||
completeOAuthLogin(code)
|
completeOAuthLogin(code)
|
||||||
.then(async (ace) => {
|
.then((ace) => { if (ace) router.replace("/dashboard"); else replace("identify"); })
|
||||||
if (!ace) { replace("identify"); return; }
|
.catch(() => { setFlash("Sign-in with that provider didn't complete. Try again."); replace("identify"); });
|
||||||
// First-time Google user (no CRM profile yet) → complete onboarding; an
|
}, [completeOAuthLogin, router]);
|
||||||
// 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("Google sign-in didn't complete. Please try again.");
|
|
||||||
replace("identify");
|
|
||||||
});
|
|
||||||
}, [ready, completeOAuthLogin, router, sdk, getUserEmail]);
|
|
||||||
|
|
||||||
/* ---- auth resolution ---- */
|
/* ---- auth resolution ---- */
|
||||||
function afterAuth(factor: "password" | "passkey" | "otp" | "totp" | "push" | "social") {
|
function afterAuth(factor: "password" | "passkey" | "otp" | "totp" | "push" | "social") {
|
||||||
@@ -133,7 +100,6 @@ export function LoginFlow() {
|
|||||||
hasPasskey: false, hasTotp: false, hasPush: false,
|
hasPasskey: false, hasTotp: false, hasPush: false,
|
||||||
maskedEmail: maskEmail(email), maskedPhone: "", maskedWa: "",
|
maskedEmail: maskEmail(email), maskedPhone: "", maskedWa: "",
|
||||||
});
|
});
|
||||||
setOtpChannel("email");
|
|
||||||
replace("password");
|
replace("password");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -150,18 +116,14 @@ export function LoginFlow() {
|
|||||||
/* =================================================================== */
|
/* =================================================================== */
|
||||||
return (
|
return (
|
||||||
<div className="card anim-fade-up" key={step}>
|
<div className="card anim-fade-up" key={step}>
|
||||||
{step === "identify" && (
|
{step === "identify" && <Identify email={email} setEmail={setEmail} onSocial={onSocial} onEmail={identifyEmail} toRegister={() => router.push("/portal/register")} />}
|
||||||
<>
|
|
||||||
{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" && (
|
{step === "connecting" && (
|
||||||
<div className="interstitial">
|
<div className="interstitial">
|
||||||
<Spinner lg />
|
<Spinner lg />
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ fontSize: 20 }}>{provider ? `Connecting to ${cap(provider)}…` : "Looking up your account…"}</h1>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -200,11 +162,11 @@ export function LoginFlow() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{step === "password" && account && (
|
{step === "password" && account && (
|
||||||
<Password account={account} email={email} login={login} onAuthenticated={() => router.replace("/dashboard")} flash={flash} onBack={back} onForgot={() => push("fp_confirm")} onOtp={() => { setOtpChannel("email"); replace("otp"); }} onLocked={() => setFlash("This account is temporarily locked.")} onOk={() => afterAuth("password")} />
|
<Password account={account} email={email} login={login} onAuthenticated={() => router.replace("/dashboard")} flash={flash} onBack={back} onForgot={() => push("fp_confirm")} onOtp={() => replace("otp")} onLocked={() => setFlash("This account is temporarily locked.")} onOk={() => afterAuth("password")} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === "otp" && account && (
|
{step === "otp" && account && (
|
||||||
<OtpVerify account={account} email={email} initialChannel={otpChannel} remember={remember} setRemember={setRemember} onBack={back} onVerified={() => afterAuth("otp")} onAuthenticated={() => router.replace("/dashboard")} />
|
<OtpVerify account={account} email={email} remember={remember} setRemember={setRemember} onBack={back} onVerified={() => afterAuth("otp")} onAuthenticated={() => router.replace("/dashboard")} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === "another" && account && (
|
{step === "another" && account && (
|
||||||
@@ -268,9 +230,9 @@ export function LoginFlow() {
|
|||||||
|
|
||||||
/* ============================ screens ============================ */
|
/* ============================ screens ============================ */
|
||||||
|
|
||||||
function Identify({ email, setEmail, onSocial, onEmail, onPhone, toRegister }: {
|
function Identify({ email, setEmail, onSocial, onEmail, toRegister }: {
|
||||||
email: string; setEmail: (v: string) => void;
|
email: string; setEmail: (v: string) => void;
|
||||||
onSocial: (p: "google") => void; onEmail: () => void; onPhone: () => void; toRegister: () => void;
|
onSocial: (p: "google" | "microsoft" | "apple") => void; onEmail: () => void; toRegister: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [showEmail, setShowEmail] = useState(false);
|
const [showEmail, setShowEmail] = useState(false);
|
||||||
const valid = EMAIL_RE.test(email);
|
const valid = EMAIL_RE.test(email);
|
||||||
@@ -301,9 +263,6 @@ function Identify({ email, setEmail, onSocial, onEmail, onPhone, toRegister }: {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
{isShellConfigured() && (
|
|
||||||
<button className="link" style={{ marginTop: 14, display: "block" }} onClick={onPhone}><Icon name="sms" size={15} /> Sign in with a phone number instead</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="foot-note">New homeowner or contractor? <button className="link" onClick={toRegister}>Register here</button></p>
|
<p className="foot-note">New homeowner or contractor? <button className="link" onClick={toRegister}>Register here</button></p>
|
||||||
@@ -312,6 +271,7 @@ function Identify({ email, setEmail, onSocial, onEmail, onPhone, toRegister }: {
|
|||||||
<span><Icon name="shield" size={13} /> Licensed & Insured</span>
|
<span><Icon name="shield" size={13} /> Licensed & Insured</span>
|
||||||
<span><Icon name="check" size={13} /> Drone-Powered</span>
|
<span><Icon name="check" size={13} /> Drone-Powered</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="demo-hint">Demo: any email signs in; an email starting with “new” shows “not found”.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -346,6 +306,7 @@ function Passkey({ account, onBack, onPassword, onAnother, onSuccess, onFail }:
|
|||||||
<button className="link" onClick={onPassword}>Use your password instead</button>
|
<button className="link" onClick={onPassword}>Use your password instead</button>
|
||||||
<button className="link" onClick={onAnother}>Try another way</button>
|
<button className="link" onClick={onAnother}>Try another way</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="demo-hint">Demo: passkey always succeeds here.</p>
|
||||||
<button hidden onClick={onFail} />
|
<button hidden onClick={onFail} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -396,19 +357,20 @@ function Password({ account, email, login, onAuthenticated, flash, onBack, onFor
|
|||||||
</form>
|
</form>
|
||||||
<div className="row between" style={{ marginTop: 16 }}>
|
<div className="row between" style={{ marginTop: 16 }}>
|
||||||
<button className="link" onClick={onForgot}>Forgot password?</button>
|
<button className="link" onClick={onForgot}>Forgot password?</button>
|
||||||
<button className="link" onClick={onOtp}>Sign in with a one-time code</button>
|
<button className="link" onClick={onOtp}>Sign in using email OTP</button>
|
||||||
</div>
|
</div>
|
||||||
|
{!isShellConfigured() && <p className="demo-hint">Demo: any password works; type “wrong” to see the lockout. Password always needs 2-step next.</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function OtpVerify({ account, email, initialChannel = "email", remember, setRemember, onBack, onVerified, onAuthenticated }: {
|
function OtpVerify({ account, email, remember, setRemember, onBack, onVerified, onAuthenticated }: {
|
||||||
account: Account; email: string; initialChannel?: "email" | "sms"; remember: boolean; setRemember: (v: boolean) => void; onBack: () => void; onVerified: () => void; onAuthenticated: () => void;
|
account: Account; email: string; remember: boolean; setRemember: (v: boolean) => void; onBack: () => void; onVerified: () => void; onAuthenticated: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { sendEmailOtp, verifyEmailOtp, sendPhoneOtp, verifyPhoneOtp } = useAuth();
|
const { sendEmailOtp, verifyEmailOtp, sendPhoneOtp, verifyPhoneOtp } = useAuth();
|
||||||
const shell = isShellConfigured();
|
const shell = isShellConfigured();
|
||||||
// In Shell mode only email + SMS are real Supabase OTP channels; hide WhatsApp.
|
// In Shell mode only email + SMS are real Supabase OTP channels; hide WhatsApp.
|
||||||
const [channel, setChannel] = useState<"email" | "sms" | "wa">(initialChannel);
|
const [channel, setChannel] = useState<"email" | "sms" | "wa">("email");
|
||||||
const [phone, setPhone] = useState("");
|
const [phone, setPhone] = useState("");
|
||||||
const [sent, setSent] = useState(false);
|
const [sent, setSent] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -478,6 +440,7 @@ function OtpVerify({ account, email, initialChannel = "email", remember, setReme
|
|||||||
<span />
|
<span />
|
||||||
</div>
|
</div>
|
||||||
<RememberDevice checked={remember} onChange={setRemember} note="2-step is still required at each sign-in." />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -532,6 +495,7 @@ function Totp({ remember, setRemember, onBack, onVerified }: { remember: boolean
|
|||||||
{setupKey ? "Enter a 6-digit code instead" : "Enter setup key instead"}
|
{setupKey ? "Enter a 6-digit code instead" : "Enter setup key instead"}
|
||||||
</button>
|
</button>
|
||||||
<RememberDevice checked={remember} onChange={setRemember} />
|
<RememberDevice checked={remember} onChange={setRemember} />
|
||||||
|
<p className="demo-hint">Demo: any 6 digits verify; “000000” fails.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -550,6 +514,7 @@ 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>
|
<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>
|
<div className="interstitial">{approved ? <Icon name="check" size={30} /> : <Spinner lg />}</div>
|
||||||
{!approved && <button className="btn" onClick={onCancel}>Cancel</button>}
|
{!approved && <button className="btn" onClick={onCancel}>Cancel</button>}
|
||||||
|
<p className="demo-hint">Demo: auto-approves after ~3 seconds.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -633,6 +598,7 @@ function ForgotCode({ onBack, onOk }: { onBack: () => void; onOk: () => void })
|
|||||||
<div style={{ marginTop: 18 }}><OtpBoxes onComplete={(c) => (c === "000000" ? setError(true) : onOk())} error={error} /></div>
|
<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>}
|
{error && <div style={{ marginTop: 12 }}><FlashNote tone="error">Incorrect code.</FlashNote></div>}
|
||||||
<div style={{ marginTop: 14 }}><ResendLink seconds={30} /></div>
|
<div style={{ marginTop: 14 }}><ResendLink seconds={30} /></div>
|
||||||
|
<p className="demo-hint">Demo: any 6 digits work; “000000” fails.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { Icon, Avatar } from "./icons";
|
import { Icon, Avatar } from "./icons";
|
||||||
import {
|
import {
|
||||||
StepBack, FlashNote, Badge, OtpBoxes, ResendLink, RememberDevice,
|
StepBack, FlashNote, Badge, OtpBoxes, ResendLink, RememberDevice,
|
||||||
PasswordStrength, LegalModal,
|
SocialButtons, startSocial, PasswordStrength, LegalModal,
|
||||||
} from "./bits";
|
} from "./bits";
|
||||||
import {
|
import {
|
||||||
countryCodes, relationshipOptions, addressCountries,
|
countryCodes, relationshipOptions, addressCountries,
|
||||||
@@ -19,20 +19,12 @@ type Addr = { line1?: string; line2?: string; city?: string; state?: string; pos
|
|||||||
const STEPS = ["Account", "Verify", "Address"];
|
const STEPS = ["Account", "Verify", "Address"];
|
||||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboard" }) {
|
export function RegisterFlow() {
|
||||||
// "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 router = useRouter();
|
||||||
const { register, setPassword, getUserEmail, addPhone, verifyPhone } = useAuth();
|
const { register } = useAuth();
|
||||||
const { sdk } = useAppShell();
|
const { sdk } = useAppShell();
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
const [submitErr, setSubmitErr] = useState("");
|
const [submitErr, setSubmitErr] = useState("");
|
||||||
// Verifying a phone via Supabase needs a live session. Onboarding already has one
|
|
||||||
// (Google OAuth); registration creates the account when leaving the Account step,
|
|
||||||
// then attaches + verifies the phone on it. `creating` guards the Account button.
|
|
||||||
const [creating, setCreating] = useState(false);
|
|
||||||
|
|
||||||
// address (lifted from StepAddress/AddressBlock so finish() can persist it)
|
// address (lifted from StepAddress/AddressBlock so finish() can persist it)
|
||||||
const [regAddr, setRegAddr] = useState<Addr>({});
|
const [regAddr, setRegAddr] = useState<Addr>({});
|
||||||
@@ -54,20 +46,10 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
|
|||||||
const [termsOk, setTermsOk] = useState(false);
|
const [termsOk, setTermsOk] = useState(false);
|
||||||
const [privacyOk, setPrivacyOk] = useState(false);
|
const [privacyOk, setPrivacyOk] = useState(false);
|
||||||
|
|
||||||
// verify step (email is trusted without an OTP — see verifyValid below)
|
// verify step
|
||||||
|
const [emailVerified, setEmailVerified] = useState(false);
|
||||||
const [phoneVerified, setPhoneVerified] = 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", "Verify", "Address"] : STEPS;
|
|
||||||
|
|
||||||
const isSelf = relationship === "Customer" || relationship === "Owner";
|
const isSelf = relationship === "Customer" || relationship === "Owner";
|
||||||
const isEmployee = relationship === "Employee";
|
const isEmployee = relationship === "Employee";
|
||||||
const country = countryCodes.find((c) => c.code === cc)!;
|
const country = countryCodes.find((c) => c.code === cc)!;
|
||||||
@@ -79,26 +61,7 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
|
|||||||
first.trim() && last.trim() && EMAIL_RE.test(sso?.email || email) && phoneOk && pwOk &&
|
first.trim() && last.trim() && EMAIL_RE.test(sso?.email || email) && phoneOk && pwOk &&
|
||||||
termsOk && privacyOk && (isSelf || (alloeIdOk && (isEmployee || (alloeFirst.trim() && alloeLast.trim()))));
|
termsOk && privacyOk && (isSelf || (alloeIdOk && (isEmployee || (alloeFirst.trim() && alloeLast.trim()))));
|
||||||
|
|
||||||
// Email is already trusted in both flows (registration: Supabase auto-confirms on
|
const step2Valid = emailVerified && phoneVerified;
|
||||||
// signup; onboarding: Google-verified), so the Verify step only gates on the phone.
|
|
||||||
const verifyValid = phoneVerified;
|
|
||||||
|
|
||||||
// Registration: create the auth account when leaving the Account step, so the phone
|
|
||||||
// can be attached + verified against a live session at the Verify step. Onboarding
|
|
||||||
// is already authenticated, so it just advances.
|
|
||||||
async function leaveAccountStep() {
|
|
||||||
if (onboard || !isShellConfigured() || sso) { setStep(1); return; }
|
|
||||||
setSubmitErr("");
|
|
||||||
setCreating(true);
|
|
||||||
try {
|
|
||||||
await register(email, pw);
|
|
||||||
setStep(1);
|
|
||||||
} catch {
|
|
||||||
setSubmitErr("We couldn't create that account. The email may already be registered.");
|
|
||||||
} finally {
|
|
||||||
setCreating(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function finish() {
|
async function finish() {
|
||||||
const finalEmail = sso?.email || email;
|
const finalEmail = sso?.email || email;
|
||||||
@@ -113,12 +76,10 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
|
|||||||
|
|
||||||
if (isShellConfigured()) {
|
if (isShellConfigured()) {
|
||||||
setSubmitErr("");
|
setSubmitErr("");
|
||||||
// 1) Auth account already exists at this point — registration created it when
|
// 1) Auth account — appshell → Supabase (SSO users already have a session).
|
||||||
// leaving the Account step; onboarding users signed in via Google. Onboarding
|
if (!sso) {
|
||||||
// additionally sets a password so email+password login works too (the phone was
|
try { await register(finalEmail, pw); }
|
||||||
// just verified against the same live session).
|
catch { setSubmitErr("We couldn't create that account. The email may already be registered."); return; }
|
||||||
if (onboard && pw) {
|
|
||||||
try { await setPassword(pw); } catch { /* non-fatal — the profile still saves */ }
|
|
||||||
}
|
}
|
||||||
// 2) Persist the full CRM registration payload to be-crm (name, phone, persona,
|
// 2) Persist the full CRM registration payload to be-crm (name, phone, persona,
|
||||||
// allottee, both addresses, consent). Non-fatal: the auth account exists either way.
|
// allottee, both addresses, consent). Non-fatal: the auth account exists either way.
|
||||||
@@ -136,44 +97,38 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
|
|||||||
mailingSameAsRegistered: mailingSame,
|
mailingSameAsRegistered: mailingSame,
|
||||||
consentTerms: termsOk, consentPrivacy: privacyOk,
|
consentTerms: termsOk, consentPrivacy: privacyOk,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (e) {
|
||||||
if (onboard) { setSubmitErr("Couldn't save your profile. Please try again."); return; }
|
console.warn("crm.account.register failed (continuing):", e);
|
||||||
// register mode: non-fatal — the auth account exists; the profile can be filled in later.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Consume the OAuth→onboarding handoff so a stale hint can't re-open onboarding.
|
|
||||||
try { sessionStorage.removeItem("onboard_email"); } catch { /* ignore */ }
|
|
||||||
router.push("/dashboard");
|
router.push("/dashboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card anim-fade-up">
|
<div className="card anim-fade-up">
|
||||||
<Stepper current={step} labels={STEP_LABELS} />
|
<Stepper current={step} />
|
||||||
|
|
||||||
{step === 0 && (
|
{step === 0 && (
|
||||||
<>
|
<StepAccount
|
||||||
{submitErr && <div style={{ marginBottom: 14 }}><FlashNote tone="error">{submitErr}</FlashNote></div>}
|
sso={sso} setSso={setSso} email={email} setEmail={setEmail}
|
||||||
<StepAccount
|
first={first} setFirst={setFirst} last={last} setLast={setLast}
|
||||||
onboard={onboard} creating={creating}
|
cc={cc} setCc={setCc} phone={phone} setPhone={setPhone} phoneOk={phoneOk} country={country}
|
||||||
sso={sso} setSso={setSso} email={email} setEmail={setEmail}
|
pw={pw} setPw={setPw}
|
||||||
first={first} setFirst={setFirst} last={last} setLast={setLast}
|
relationship={relationship} setRelationship={setRelationship} isSelf={isSelf}
|
||||||
cc={cc} setCc={setCc} phone={phone} setPhone={setPhone} phoneOk={phoneOk} country={country}
|
alloeNo={alloeNo} setAlloeNo={setAlloeNo} alloeIdOk={alloeIdOk}
|
||||||
pw={pw} setPw={setPw}
|
alloeFirst={alloeFirst} setAlloeFirst={setAlloeFirst} alloeLast={alloeLast} setAlloeLast={setAlloeLast}
|
||||||
relationship={relationship} setRelationship={setRelationship} isSelf={isSelf}
|
termsOk={termsOk} setTermsOk={setTermsOk} privacyOk={privacyOk} setPrivacyOk={setPrivacyOk}
|
||||||
alloeNo={alloeNo} setAlloeNo={setAlloeNo} alloeIdOk={alloeIdOk}
|
valid={!!step0Valid}
|
||||||
alloeFirst={alloeFirst} setAlloeFirst={setAlloeFirst} alloeLast={alloeLast} setAlloeLast={setAlloeLast}
|
onContinue={() => setStep(1)} toLogin={() => router.push("/portal/login")}
|
||||||
termsOk={termsOk} setTermsOk={setTermsOk} privacyOk={privacyOk} setPrivacyOk={setPrivacyOk}
|
/>
|
||||||
valid={!!step0Valid}
|
|
||||||
onContinue={leaveAccountStep} toLogin={() => router.push("/portal/login")}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<StepVerify
|
<StepVerify
|
||||||
cc={cc} phone={phone} country={country}
|
emailValue={sso?.email || email} cc={cc} phone={phone} country={country}
|
||||||
|
emailVerified={emailVerified} setEmailVerified={setEmailVerified}
|
||||||
phoneVerified={phoneVerified} setPhoneVerified={setPhoneVerified}
|
phoneVerified={phoneVerified} setPhoneVerified={setPhoneVerified}
|
||||||
valid={verifyValid} onBack={() => setStep(0)} onContinue={() => setStep(2)}
|
valid={step2Valid} onBack={() => setStep(0)} onContinue={() => setStep(2)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -198,19 +153,28 @@ function StepAccount(p: {
|
|||||||
alloeNo: string; setAlloeNo: (v: string) => void; alloeIdOk: boolean;
|
alloeNo: string; setAlloeNo: (v: string) => void; alloeIdOk: boolean;
|
||||||
alloeFirst: string; setAlloeFirst: (v: string) => void; alloeLast: string; setAlloeLast: (v: string) => void;
|
alloeFirst: string; setAlloeFirst: (v: string) => void; alloeLast: string; setAlloeLast: (v: string) => void;
|
||||||
termsOk: boolean; setTermsOk: (v: boolean) => void; privacyOk: boolean; setPrivacyOk: (v: boolean) => void;
|
termsOk: boolean; setTermsOk: (v: boolean) => void; privacyOk: boolean; setPrivacyOk: (v: boolean) => void;
|
||||||
valid: boolean; onContinue: () => void; toLogin: () => void; onboard?: boolean; creating?: boolean;
|
valid: boolean; onContinue: () => void; toLogin: () => void;
|
||||||
}) {
|
}) {
|
||||||
// Onboarding (OAuth) starts on the profile step — email is already known + verified.
|
const [phase, setPhase] = useState<"sso" | "profile">("sso");
|
||||||
const [phase, setPhase] = useState<"sso" | "profile">(p.onboard ? "profile" : "sso");
|
|
||||||
const [modal, setModal] = useState<null | "terms" | "privacy">(null);
|
const [modal, setModal] = useState<null | "terms" | "privacy">(null);
|
||||||
|
|
||||||
|
function pickSso(provider: "google" | "microsoft" | "apple") {
|
||||||
|
if (startSocial(provider)) return;
|
||||||
|
const mockEmail = `you@${provider === "microsoft" ? "outlook.com" : provider + ".com"}`;
|
||||||
|
p.setSso({ provider, email: mockEmail });
|
||||||
|
p.setEmail(mockEmail);
|
||||||
|
setPhase("profile");
|
||||||
|
}
|
||||||
|
|
||||||
if (phase === "sso") {
|
if (phase === "sso") {
|
||||||
const valid = EMAIL_RE.test(p.email);
|
const valid = EMAIL_RE.test(p.email);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>Create your account</h1>
|
<h1>Create your account</h1>
|
||||||
<p className="sub">Enter your email to get started.</p>
|
<p className="sub">Sign up with a provider or your email to get started.</p>
|
||||||
<div style={{ marginTop: 20 }}>
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<SocialButtons onPick={pickSso} verb="Sign up" />
|
||||||
|
<div className="divider">or continue with email</div>
|
||||||
<form onSubmit={(e) => { e.preventDefault(); if (valid) { p.setSso(null); setPhase("profile"); } }}>
|
<form onSubmit={(e) => { e.preventDefault(); if (valid) { p.setSso(null); setPhase("profile"); } }}>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="label">Email address</label>
|
<label className="label">Email address</label>
|
||||||
@@ -232,13 +196,12 @@ function StepAccount(p: {
|
|||||||
<h1>Complete your profile</h1>
|
<h1>Complete your profile</h1>
|
||||||
<p className="sub">Tell us a bit about you to set up your account.</p>
|
<p className="sub">Tell us a bit about you to set up your account.</p>
|
||||||
|
|
||||||
<div className="field" style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
<label className="label">Email address</label>
|
{p.sso ? (
|
||||||
<div className="input-wrap">
|
<div className="conn-banner conn-green"><Icon name="check" size={16} /> Connected with {cap(p.sso.provider)} · {p.sso.email}</div>
|
||||||
<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 className="conn-banner conn-blue"><Icon name="mail" size={16} /> Creating account for {p.email}</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div className="row gap-3" style={{ marginTop: 18, alignItems: "center" }}>
|
<div className="row gap-3" style={{ marginTop: 18, alignItems: "center" }}>
|
||||||
@@ -336,9 +299,7 @@ function StepAccount(p: {
|
|||||||
|
|
||||||
{!p.valid && <p className="hint-line">Fill all required fields and accept both documents to continue.</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 || p.creating} onClick={p.onContinue}>
|
<button className="btn btn-primary" style={{ marginTop: 14 }} disabled={!p.valid} onClick={p.onContinue}>Create Account & Verify <Icon name="arrowR" size={16} /></button>
|
||||||
{p.creating ? "Creating account…" : p.onboard ? "Continue" : "Create Account & Verify"} {!p.creating && <Icon name="arrowR" size={16} />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{modal === "terms" && <LegalModal doc={TERMS} onClose={() => setModal(null)} onReviewed={() => { reviewed.terms = true; setModal(null); }} />}
|
{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); }} />}
|
{modal === "privacy" && <LegalModal doc={PRIVACY} onClose={() => setModal(null)} onReviewed={() => { reviewed.privacy = true; setModal(null); }} />}
|
||||||
@@ -348,13 +309,10 @@ function StepAccount(p: {
|
|||||||
// module-level reviewed flags (per mount lifetime) — enables the checkboxes after a doc is read
|
// module-level reviewed flags (per mount lifetime) — enables the checkboxes after a doc is read
|
||||||
const reviewed = { terms: false, privacy: false };
|
const reviewed = { terms: false, privacy: false };
|
||||||
|
|
||||||
/* ====================== STEP 1 — VERIFY PHONE ======================
|
/* ====================== STEP 1 — VERIFY ====================== */
|
||||||
Email is already trusted (registration: Supabase auto-confirms on signup;
|
|
||||||
onboarding: Google-verified), so this step only verifies the phone via a real
|
|
||||||
SMS OTP: addPhone() → Twilio texts a code → verifyPhone() confirms it. Both
|
|
||||||
run against the live Supabase session established in the previous step. */
|
|
||||||
function StepVerify(p: {
|
function StepVerify(p: {
|
||||||
cc: string; phone: string; country: typeof countryCodes[number];
|
emailValue: string; cc: string; phone: string; country: typeof countryCodes[number];
|
||||||
|
emailVerified: boolean; setEmailVerified: (v: boolean) => void;
|
||||||
phoneVerified: boolean; setPhoneVerified: (v: boolean) => void;
|
phoneVerified: boolean; setPhoneVerified: (v: boolean) => void;
|
||||||
valid: boolean; onBack: () => void; onContinue: () => void;
|
valid: boolean; onBack: () => void; onContinue: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -362,11 +320,12 @@ function StepVerify(p: {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<StepBack onClick={p.onBack} />
|
<StepBack onClick={p.onBack} />
|
||||||
<h1>Verify your phone</h1>
|
<h1>Verify email & phone</h1>
|
||||||
<p className="sub">We'll text a one-time code to confirm your number. Your email is already verified.</p>
|
<p className="sub">Confirm both so we can secure your account.</p>
|
||||||
|
|
||||||
<div className="col gap-4" style={{ marginTop: 16 }}>
|
<div className="col gap-4" style={{ marginTop: 16 }}>
|
||||||
<PhoneVerify cc={p.cc} country={p.country} initialPhone={p.phone} verified={p.phoneVerified} onVerified={() => p.setPhoneVerified(true)} />
|
<VerifyChannel kind="email" initial={p.emailValue} country={p.country} cc={p.cc} initialPhone={p.phone} verified={p.emailVerified} onVerified={() => p.setEmailVerified(true)} />
|
||||||
|
<VerifyChannel kind="phone" initial={p.emailValue} country={p.country} cc={p.cc} initialPhone={p.phone} verified={p.phoneVerified} onVerified={() => p.setPhoneVerified(true)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: 14 }}><RememberDevice checked={remember} onChange={setRemember} /></div>
|
<div style={{ marginTop: 14 }}><RememberDevice checked={remember} onChange={setRemember} /></div>
|
||||||
@@ -375,47 +334,40 @@ function StepVerify(p: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type SendState = "idle" | "sending" | "sent" | "invalid_mobile" | "send_error";
|
type SendState = "idle" | "sending" | "ok" | "invalid_email" | "mailbox_full" | "bounce_risk" | "invalid_mobile";
|
||||||
function PhoneVerify({ cc, country, initialPhone, verified, onVerified }: {
|
function VerifyChannel({ kind, initial, country, cc, initialPhone, verified, onVerified }: {
|
||||||
cc: string; country: typeof countryCodes[number]; initialPhone: string;
|
kind: "email" | "phone"; initial: string; country: typeof countryCodes[number]; cc: string; initialPhone: string;
|
||||||
verified: boolean; onVerified: () => void;
|
verified: boolean; onVerified: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { addPhone, verifyPhone } = useAuth();
|
const [value, setValue] = useState(kind === "email" ? initial : initialPhone);
|
||||||
const [value, setValue] = useState(initialPhone);
|
const [via, setVia] = useState<"primary" | "wa">("primary");
|
||||||
const [state, setState] = useState<SendState>("idle");
|
const [state, setState] = useState<SendState>("idle");
|
||||||
const [otpError, setOtpError] = useState("");
|
const [otpError, setOtpError] = useState(false);
|
||||||
const e164 = `${cc}${value.replace(/\D/g, "")}`;
|
const primaryLabel = kind === "email" ? "Email" : "SMS";
|
||||||
|
|
||||||
async function send() {
|
function send() {
|
||||||
if (value.replace(/\D/g, "").length !== country.digits) { setState("invalid_mobile"); return; }
|
|
||||||
setState("sending");
|
setState("sending");
|
||||||
setOtpError("");
|
setTimeout(() => {
|
||||||
try {
|
if (kind === "email") {
|
||||||
await addPhone(e164); // Supabase → Twilio sends the SMS OTP
|
if (!EMAIL_RE.test(value)) return setState("invalid_email");
|
||||||
setState("sent");
|
if (value.includes("full")) return setState("mailbox_full");
|
||||||
} catch {
|
if (value.includes("bo")) return setState("bounce_risk");
|
||||||
setState("send_error");
|
return setState("ok");
|
||||||
}
|
} else {
|
||||||
}
|
if (value.replace(/\D/g, "").length !== country.digits) return setState("invalid_mobile");
|
||||||
|
return setState("ok");
|
||||||
async function submit(code: string) {
|
}
|
||||||
setOtpError("");
|
}, 700);
|
||||||
try {
|
|
||||||
await verifyPhone(e164, code); // confirm the OTP (phone_change)
|
|
||||||
onVerified();
|
|
||||||
} catch {
|
|
||||||
setOtpError("That code didn't match. Check the SMS and try again.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (verified) {
|
if (verified) {
|
||||||
return (
|
return (
|
||||||
<div className="vchannel verified">
|
<div className="vchannel verified">
|
||||||
<div className="row between">
|
<div className="row between">
|
||||||
<span className="row gap-2" style={{ fontWeight: 600, fontSize: 14 }}><Icon name="phone" size={16} /> Mobile</span>
|
<span className="row gap-2" style={{ fontWeight: 600, fontSize: 14 }}><Icon name={kind === "email" ? "mail" : "phone"} size={16} /> {kind === "email" ? "Email" : "Mobile"}</span>
|
||||||
<Badge tone="green"><Icon name="check" size={12} /> Verified</Badge>
|
<Badge tone="green"><Icon name="check" size={12} /> Verified</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="faint" style={{ fontSize: 12.5, marginTop: 8 }}>{cc} {value}</p>
|
<p className="faint" style={{ fontSize: 12.5, marginTop: 8 }}>{kind === "email" ? value : `${cc} ${value}`}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -423,36 +375,46 @@ function PhoneVerify({ cc, country, initialPhone, verified, onVerified }: {
|
|||||||
return (
|
return (
|
||||||
<div className="vchannel">
|
<div className="vchannel">
|
||||||
<div className="row between" style={{ marginBottom: 12 }}>
|
<div className="row between" style={{ marginBottom: 12 }}>
|
||||||
<span className="row gap-2" style={{ fontWeight: 600, fontSize: 14 }}><Icon name="phone" size={16} /> Mobile number</span>
|
<span className="row gap-2" style={{ fontWeight: 600, fontSize: 14 }}><Icon name={kind === "email" ? "mail" : "phone"} size={16} /> {kind === "email" ? "Email address" : "Mobile number"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="phone-row">
|
{kind === "email" ? (
|
||||||
<span className="input cc-select" style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 7 }}>
|
<input className="input" value={value} onChange={(e) => { setValue(e.target.value); setState("idle"); }} placeholder="you@example.com" />
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
) : (
|
||||||
<img className="cc-flag-img static" src={flagUrl(country.flag)} alt={country.name} width={22} height={16} />
|
<div className="phone-row">
|
||||||
{cc}
|
<span className="input cc-select" style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 7 }}>
|
||||||
</span>
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<input className="input grow" inputMode="numeric" value={value} disabled={state === "sent"} onChange={(e) => { setValue(e.target.value.replace(/\D/g, "")); setState("idle"); }} placeholder={country.example} />
|
<img className="cc-flag-img static" src={flagUrl(country.flag)} alt={country.name} width={22} height={16} />
|
||||||
</div>
|
{cc}
|
||||||
|
</span>
|
||||||
|
<input className="input grow" inputMode="numeric" value={value} onChange={(e) => { setValue(e.target.value.replace(/\D/g, "")); setState("idle"); }} placeholder={country.example} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="row between" style={{ marginTop: 12 }}>
|
<div className="row between" style={{ marginTop: 12 }}>
|
||||||
<span className="faint" style={{ fontSize: 12 }}>Standard SMS rates may apply.</span>
|
<div className="seg">
|
||||||
<button className="btn btn-sm" style={{ width: "auto" }} disabled={state === "sending" || state === "sent"} onClick={send}>
|
<button className={via === "primary" ? "on" : ""} onClick={() => setVia("primary")}>{primaryLabel}</button>
|
||||||
{state === "sending" ? "Sending…" : state === "sent" ? "Sent" : "Send code"}
|
<button className={via === "wa" ? "on" : ""} onClick={() => setVia("wa")}><Icon name="whatsapp" size={14} /> WhatsApp</button>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-sm" style={{ width: "auto" }} disabled={state === "sending" || state === "ok"} onClick={send}>
|
||||||
|
{state === "sending" ? "Sending…" : "Send code"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{state === "sent" && <p className="faint" style={{ fontSize: 12, marginTop: 10 }}>Code sent to {cc} {value} by SMS.</p>}
|
{state === "ok" && <p className="faint" style={{ fontSize: 12, marginTop: 10 }}>OTP sent via {via === "wa" ? "WhatsApp" : primaryLabel}.</p>}
|
||||||
|
{state === "invalid_email" && <div style={{ marginTop: 10 }}><FlashNote tone="error">That email address looks invalid.</FlashNote></div>}
|
||||||
|
{state === "mailbox_full" && <div style={{ marginTop: 10 }}><FlashNote tone="warn">This mailbox appears full — try another email.</FlashNote></div>}
|
||||||
|
{state === "bounce_risk" && <div style={{ marginTop: 10 }}><FlashNote tone="warn">High bounce risk for this address.</FlashNote></div>}
|
||||||
{state === "invalid_mobile" && <div style={{ marginTop: 10 }}><FlashNote tone="error">Enter a valid {country.digits}-digit mobile number.</FlashNote></div>}
|
{state === "invalid_mobile" && <div style={{ marginTop: 10 }}><FlashNote tone="error">Enter a valid {country.digits}-digit mobile number.</FlashNote></div>}
|
||||||
{state === "send_error" && <div style={{ marginTop: 10 }}><FlashNote tone="error">Couldn't send the code. Check the number and try again.</FlashNote></div>}
|
|
||||||
|
|
||||||
{state === "sent" && (
|
{state === "ok" && (
|
||||||
<div style={{ marginTop: 14 }}>
|
<div style={{ marginTop: 14 }}>
|
||||||
<OtpBoxes onComplete={submit} error={!!otpError} />
|
<OtpBoxes onComplete={(c) => { if (c === "000000") setOtpError(true); else { setOtpError(false); onVerified(); } }} error={otpError} />
|
||||||
{otpError && <div style={{ marginTop: 10 }}><FlashNote tone="error">{otpError}</FlashNote></div>}
|
{otpError && <div style={{ marginTop: 10 }}><FlashNote tone="error">Incorrect code.</FlashNote></div>}
|
||||||
<div style={{ marginTop: 12 }}><ResendLink seconds={60} onResend={send} /></div>
|
<div style={{ marginTop: 12 }}><ResendLink seconds={60} /></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<p className="demo-hint">Demo: any 6 digits verify; “000000” fails. Email with “full”/“bo” shows send errors.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -630,10 +592,10 @@ function AddressBlock({ idPrefix, onChange }: { idPrefix: string; onChange?: (a:
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ====================== stepper ====================== */
|
/* ====================== stepper ====================== */
|
||||||
function Stepper({ current, labels = STEPS }: { current: number; labels?: string[] }) {
|
function Stepper({ current }: { current: number }) {
|
||||||
return (
|
return (
|
||||||
<div className="steps">
|
<div className="steps">
|
||||||
{labels.map((label, i) => {
|
{STEPS.map((label, i) => {
|
||||||
const done = i < current, on = i === current;
|
const done = i < current, on = i === current;
|
||||||
return (
|
return (
|
||||||
<div key={label} style={{ display: "contents" }}>
|
<div key={label} style={{ display: "contents" }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user