Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e43c35af4d | |||
| c05aa0641c |
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@abe-kap/appshell-sdk": "^0.2.3",
|
"@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,40 +0,0 @@
|
|||||||
"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 { 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.
|
||||||
@@ -25,25 +25,12 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|||||||
export function LoginFlow() {
|
export function LoginFlow() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { login, loginWithOAuth, completeOAuthLogin } = 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,28 +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");
|
|
||||||
router.replace(registered ? "/dashboard" : "/portal/onboarding");
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setFlash("Google sign-in didn't complete. Please try again.");
|
|
||||||
replace("identify");
|
|
||||||
});
|
|
||||||
}, [ready, completeOAuthLogin, router, sdk]);
|
|
||||||
|
|
||||||
/* ---- auth resolution ---- */
|
/* ---- auth resolution ---- */
|
||||||
function afterAuth(factor: "password" | "passkey" | "otp" | "totp" | "push" | "social") {
|
function afterAuth(factor: "password" | "passkey" | "otp" | "totp" | "push" | "social") {
|
||||||
@@ -130,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;
|
||||||
}
|
}
|
||||||
@@ -147,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>
|
||||||
)}
|
)}
|
||||||
@@ -197,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 && (
|
||||||
@@ -265,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);
|
||||||
@@ -298,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>
|
||||||
@@ -309,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -343,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>
|
||||||
);
|
);
|
||||||
@@ -393,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);
|
||||||
@@ -475,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -529,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -547,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -630,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,13 +19,9 @@ 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 } = 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("");
|
||||||
@@ -54,15 +50,6 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
|
|||||||
const [emailVerified, setEmailVerified] = useState(false);
|
const [emailVerified, setEmailVerified] = useState(false);
|
||||||
const [phoneVerified, setPhoneVerified] = useState(false);
|
const [phoneVerified, setPhoneVerified] = useState(false);
|
||||||
|
|
||||||
// Onboarding: prefill the verified email from the Google session (the ACE omits it).
|
|
||||||
useEffect(() => {
|
|
||||||
if (!onboard) return;
|
|
||||||
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 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)!;
|
||||||
@@ -89,11 +76,8 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
|
|||||||
|
|
||||||
if (isShellConfigured()) {
|
if (isShellConfigured()) {
|
||||||
setSubmitErr("");
|
setSubmitErr("");
|
||||||
// 1) Auth account. Onboarding users are already authenticated via Google — set
|
// 1) Auth account — appshell → Supabase (SSO users already have a session).
|
||||||
// a password so email+password login works too. Everyone else registers now.
|
if (!sso) {
|
||||||
if (onboard) {
|
|
||||||
try { if (pw) await setPassword(pw); } catch { /* non-fatal — the profile still saves */ }
|
|
||||||
} else if (!sso) {
|
|
||||||
try { await register(finalEmail, pw); }
|
try { await register(finalEmail, pw); }
|
||||||
catch { setSubmitErr("We couldn't create that account. The email may already be registered."); return; }
|
catch { setSubmitErr("We couldn't create that account. The email may already be registered."); return; }
|
||||||
}
|
}
|
||||||
@@ -113,9 +97,8 @@ 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.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
router.push("/dashboard");
|
router.push("/dashboard");
|
||||||
@@ -123,11 +106,10 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
|
|||||||
|
|
||||||
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
|
<StepAccount
|
||||||
onboard={onboard}
|
|
||||||
sso={sso} setSso={setSso} email={email} setEmail={setEmail}
|
sso={sso} setSso={setSso} email={email} setEmail={setEmail}
|
||||||
first={first} setFirst={setFirst} last={last} setLast={setLast}
|
first={first} setFirst={setFirst} last={last} setLast={setLast}
|
||||||
cc={cc} setCc={setCc} phone={phone} setPhone={setPhone} phoneOk={phoneOk} country={country}
|
cc={cc} setCc={setCc} phone={phone} setPhone={setPhone} phoneOk={phoneOk} country={country}
|
||||||
@@ -141,7 +123,7 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 1 && !onboard && (
|
{step === 1 && (
|
||||||
<StepVerify
|
<StepVerify
|
||||||
emailValue={sso?.email || email} cc={cc} phone={phone} country={country}
|
emailValue={sso?.email || email} cc={cc} phone={phone} country={country}
|
||||||
emailVerified={emailVerified} setEmailVerified={setEmailVerified}
|
emailVerified={emailVerified} setEmailVerified={setEmailVerified}
|
||||||
@@ -150,10 +132,10 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{((onboard && step === 1) || (!onboard && step === 2)) && (
|
{step === 2 && (
|
||||||
<>
|
<>
|
||||||
{submitErr && <div style={{ marginBottom: 14 }}><FlashNote tone="error">{submitErr}</FlashNote></div>}
|
{submitErr && <div style={{ marginBottom: 14 }}><FlashNote tone="error">{submitErr}</FlashNote></div>}
|
||||||
<StepAddress sameAs={mailingSame} setSameAs={setMailingSame} onRegAddr={setRegAddr} onMailAddr={setMailAddr} onBack={() => setStep(onboard ? 0 : 1)} onFinish={finish} />
|
<StepAddress sameAs={mailingSame} setSameAs={setMailingSame} onRegAddr={setRegAddr} onMailAddr={setMailAddr} onBack={() => setStep(1)} onFinish={finish} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -171,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;
|
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>
|
||||||
@@ -209,7 +200,7 @@ function StepAccount(p: {
|
|||||||
{p.sso ? (
|
{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-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} /> {p.onboard ? "Signed in as" : "Creating account for"} {p.email}</div>
|
<div className="conn-banner conn-blue"><Icon name="mail" size={16} /> Creating account for {p.email}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -308,7 +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} onClick={p.onContinue}>{p.onboard ? "Continue" : "Create Account & Verify"} <Icon name="arrowR" size={16} /></button>
|
<button className="btn btn-primary" style={{ marginTop: 14 }} disabled={!p.valid} onClick={p.onContinue}>Create Account & Verify <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); }} />}
|
||||||
@@ -423,6 +414,7 @@ function VerifyChannel({ kind, initial, country, cc, initialPhone, verified, onV
|
|||||||
<div style={{ marginTop: 12 }}><ResendLink seconds={60} /></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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -600,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