Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 948abf75fd | |||
| 34cb44cfa8 |
+1
-1
@@ -9,7 +9,7 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@abe-kap/appshell-sdk": "^0.2.2",
|
||||
"@abe-kap/appshell-sdk": "^0.2.3",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.21.0",
|
||||
"next": "16.2.9",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react";
|
||||
import { PortalAside, PanelBrand } from "@/components/portal/parts";
|
||||
import { RegisterFlow } from "@/components/portal/register-flow";
|
||||
import { CookieBanner } from "@/components/portal/bits";
|
||||
import { isShellConfigured } from "@/lib/appshell";
|
||||
|
||||
/**
|
||||
* Post-OAuth onboarding — a first-time Google user completes their CRM profile
|
||||
* (everything except email, which Google already verified). Only reachable while
|
||||
* authenticated; unauthenticated visitors are sent back to sign in.
|
||||
*/
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter();
|
||||
const { status } = useAuth();
|
||||
const { ready } = useAppShell();
|
||||
|
||||
useEffect(() => {
|
||||
if (isShellConfigured() && ready && status === "unauthenticated") router.replace("/portal/login");
|
||||
}, [ready, status, router]);
|
||||
|
||||
return (
|
||||
<main className="portal-main">
|
||||
<span className="portal-grid" />
|
||||
<div className="portal-split anim-in">
|
||||
<PortalAside />
|
||||
<section className="portal-panel">
|
||||
<div style={{ width: "100%", maxWidth: 420 }}>
|
||||
<PanelBrand />
|
||||
<RegisterFlow mode="onboard" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<CookieBanner />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
export function LoginFlow() {
|
||||
const router = useRouter();
|
||||
const { login, loginWithOAuth, completeOAuthLogin } = useAuth();
|
||||
const { ready } = useAppShell();
|
||||
const { ready, sdk } = useAppShell();
|
||||
const [step, setStep] = useState<Step>("identify");
|
||||
const [email, setEmail] = useState("");
|
||||
const [account, setAccount] = useState<Account | null>(null);
|
||||
@@ -76,20 +76,23 @@ export function LoginFlow() {
|
||||
if (!ready) { setStep("connecting"); return; }
|
||||
setStep("connecting");
|
||||
completeOAuthLogin(code)
|
||||
.then((ace) => {
|
||||
if (ace) {
|
||||
// Clear the ?code=… from the URL, then enter the app.
|
||||
.then(async (ace) => {
|
||||
if (!ace) { replace("identify"); return; }
|
||||
// First-time Google user (no CRM profile yet) → complete onboarding; an
|
||||
// existing profile → straight to the dashboard.
|
||||
let registered = false;
|
||||
try {
|
||||
const st = await sdk.query<{ registered: boolean }>("crm.account.registrationStatus");
|
||||
registered = !!st?.registered;
|
||||
} catch { registered = false; }
|
||||
window.history.replaceState({}, "", "/portal/login");
|
||||
router.replace("/dashboard");
|
||||
} else {
|
||||
replace("identify");
|
||||
}
|
||||
router.replace(registered ? "/dashboard" : "/portal/onboarding");
|
||||
})
|
||||
.catch(() => {
|
||||
setFlash("Google sign-in didn't complete. Please try again.");
|
||||
replace("identify");
|
||||
});
|
||||
}, [ready, completeOAuthLogin, router]);
|
||||
}, [ready, completeOAuthLogin, router, sdk]);
|
||||
|
||||
/* ---- auth resolution ---- */
|
||||
function afterAuth(factor: "password" | "passkey" | "otp" | "totp" | "push" | "social") {
|
||||
@@ -144,7 +147,12 @@ export function LoginFlow() {
|
||||
/* =================================================================== */
|
||||
return (
|
||||
<div className="card anim-fade-up" key={step}>
|
||||
{step === "identify" && <Identify email={email} setEmail={setEmail} onSocial={onSocial} onEmail={identifyEmail} onPhone={startPhoneLogin} toRegister={() => router.push("/portal/register")} />}
|
||||
{step === "identify" && (
|
||||
<>
|
||||
{flash && <div style={{ marginBottom: 16 }}><FlashNote tone="error">{flash}</FlashNote></div>}
|
||||
<Identify email={email} setEmail={setEmail} onSocial={onSocial} onEmail={identifyEmail} onPhone={startPhoneLogin} toRegister={() => router.push("/portal/register")} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "connecting" && (
|
||||
<div className="interstitial">
|
||||
|
||||
@@ -19,9 +19,13 @@ type Addr = { line1?: string; line2?: string; city?: string; state?: string; pos
|
||||
const STEPS = ["Account", "Verify", "Address"];
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export function RegisterFlow() {
|
||||
export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboard" }) {
|
||||
// "onboard" = an already-authenticated OAuth (Google) user completing their CRM
|
||||
// profile: email is skipped (Google-verified), the auth account already exists so
|
||||
// we only SET a password + persist the profile, and the OTP-verify step is skipped.
|
||||
const onboard = mode === "onboard";
|
||||
const router = useRouter();
|
||||
const { register } = useAuth();
|
||||
const { register, setPassword, getUserEmail } = useAuth();
|
||||
const { sdk } = useAppShell();
|
||||
const [step, setStep] = useState(0);
|
||||
const [submitErr, setSubmitErr] = useState("");
|
||||
@@ -50,6 +54,15 @@ export function RegisterFlow() {
|
||||
const [emailVerified, setEmailVerified] = 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 isEmployee = relationship === "Employee";
|
||||
const country = countryCodes.find((c) => c.code === cc)!;
|
||||
@@ -76,8 +89,11 @@ export function RegisterFlow() {
|
||||
|
||||
if (isShellConfigured()) {
|
||||
setSubmitErr("");
|
||||
// 1) Auth account — appshell → Supabase (SSO users already have a session).
|
||||
if (!sso) {
|
||||
// 1) Auth account. Onboarding users are already authenticated via Google — set
|
||||
// a password so email+password login works too. Everyone else registers now.
|
||||
if (onboard) {
|
||||
try { if (pw) await setPassword(pw); } catch { /* non-fatal — the profile still saves */ }
|
||||
} else if (!sso) {
|
||||
try { await register(finalEmail, pw); }
|
||||
catch { setSubmitErr("We couldn't create that account. The email may already be registered."); return; }
|
||||
}
|
||||
@@ -98,7 +114,8 @@ export function RegisterFlow() {
|
||||
consentTerms: termsOk, consentPrivacy: privacyOk,
|
||||
});
|
||||
} catch {
|
||||
// Non-fatal: the auth account already exists; the domain profile can be filled in later.
|
||||
if (onboard) { setSubmitErr("Couldn't save your profile. Please try again."); return; }
|
||||
// register mode: non-fatal — the auth account exists; the profile can be filled in later.
|
||||
}
|
||||
}
|
||||
router.push("/dashboard");
|
||||
@@ -106,10 +123,11 @@ export function RegisterFlow() {
|
||||
|
||||
return (
|
||||
<div className="card anim-fade-up">
|
||||
<Stepper current={step} />
|
||||
<Stepper current={step} labels={STEP_LABELS} />
|
||||
|
||||
{step === 0 && (
|
||||
<StepAccount
|
||||
onboard={onboard}
|
||||
sso={sso} setSso={setSso} email={email} setEmail={setEmail}
|
||||
first={first} setFirst={setFirst} last={last} setLast={setLast}
|
||||
cc={cc} setCc={setCc} phone={phone} setPhone={setPhone} phoneOk={phoneOk} country={country}
|
||||
@@ -123,7 +141,7 @@ export function RegisterFlow() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
{step === 1 && !onboard && (
|
||||
<StepVerify
|
||||
emailValue={sso?.email || email} cc={cc} phone={phone} country={country}
|
||||
emailVerified={emailVerified} setEmailVerified={setEmailVerified}
|
||||
@@ -132,10 +150,10 @@ export function RegisterFlow() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
{((onboard && step === 1) || (!onboard && step === 2)) && (
|
||||
<>
|
||||
{submitErr && <div style={{ marginBottom: 14 }}><FlashNote tone="error">{submitErr}</FlashNote></div>}
|
||||
<StepAddress sameAs={mailingSame} setSameAs={setMailingSame} onRegAddr={setRegAddr} onMailAddr={setMailAddr} onBack={() => setStep(1)} onFinish={finish} />
|
||||
<StepAddress sameAs={mailingSame} setSameAs={setMailingSame} onRegAddr={setRegAddr} onMailAddr={setMailAddr} onBack={() => setStep(onboard ? 0 : 1)} onFinish={finish} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -153,9 +171,10 @@ function StepAccount(p: {
|
||||
alloeNo: string; setAlloeNo: (v: string) => void; alloeIdOk: boolean;
|
||||
alloeFirst: string; setAlloeFirst: (v: string) => void; alloeLast: string; setAlloeLast: (v: string) => void;
|
||||
termsOk: boolean; setTermsOk: (v: boolean) => void; privacyOk: boolean; setPrivacyOk: (v: boolean) => void;
|
||||
valid: boolean; onContinue: () => void; toLogin: () => void;
|
||||
valid: boolean; onContinue: () => void; toLogin: () => void; onboard?: boolean;
|
||||
}) {
|
||||
const [phase, setPhase] = useState<"sso" | "profile">("sso");
|
||||
// Onboarding (OAuth) starts on the profile step — email is already known + verified.
|
||||
const [phase, setPhase] = useState<"sso" | "profile">(p.onboard ? "profile" : "sso");
|
||||
const [modal, setModal] = useState<null | "terms" | "privacy">(null);
|
||||
|
||||
if (phase === "sso") {
|
||||
@@ -190,7 +209,7 @@ function StepAccount(p: {
|
||||
{p.sso ? (
|
||||
<div className="conn-banner conn-green"><Icon name="check" size={16} /> Connected with {cap(p.sso.provider)} · {p.sso.email}</div>
|
||||
) : (
|
||||
<div className="conn-banner conn-blue"><Icon name="mail" size={16} /> Creating account for {p.email}</div>
|
||||
<div className="conn-banner conn-blue"><Icon name="mail" size={16} /> {p.onboard ? "Signed in as" : "Creating account for"} {p.email}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -289,7 +308,7 @@ function StepAccount(p: {
|
||||
|
||||
{!p.valid && <p className="hint-line">Fill all required fields and accept both documents to continue.</p>}
|
||||
|
||||
<button className="btn btn-primary" style={{ marginTop: 14 }} disabled={!p.valid} onClick={p.onContinue}>Create Account & Verify <Icon name="arrowR" size={16} /></button>
|
||||
<button className="btn btn-primary" style={{ marginTop: 14 }} disabled={!p.valid} onClick={p.onContinue}>{p.onboard ? "Continue" : "Create Account & Verify"} <Icon name="arrowR" size={16} /></button>
|
||||
|
||||
{modal === "terms" && <LegalModal doc={TERMS} onClose={() => setModal(null)} onReviewed={() => { reviewed.terms = true; setModal(null); }} />}
|
||||
{modal === "privacy" && <LegalModal doc={PRIVACY} onClose={() => setModal(null)} onReviewed={() => { reviewed.privacy = true; setModal(null); }} />}
|
||||
@@ -581,10 +600,10 @@ function AddressBlock({ idPrefix, onChange }: { idPrefix: string; onChange?: (a:
|
||||
}
|
||||
|
||||
/* ====================== stepper ====================== */
|
||||
function Stepper({ current }: { current: number }) {
|
||||
function Stepper({ current, labels = STEPS }: { current: number; labels?: string[] }) {
|
||||
return (
|
||||
<div className="steps">
|
||||
{STEPS.map((label, i) => {
|
||||
{labels.map((label, i) => {
|
||||
const done = i < current, on = i === current;
|
||||
return (
|
||||
<div key={label} style={{ display: "contents" }}>
|
||||
|
||||
Reference in New Issue
Block a user