forked from Goutam/lynkeduppro-crm
Add LynkedUp roofing portal: full login + registration auth UI
- Split-screen dark/orange auth shell with animated scan showcase - Login state machine: social/email, passkey, password, OTP (email/SMS/WhatsApp), TOTP, push, try-another-way, forgot-password, terms gate, success - 4-step registration wizard: Account, Property, Verify, Address - US-default phone + address with /api/geo lookup (PIN auto-fill / search) - Roofing-themed content, Inter font, logo from public/image Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Icon, Avatar, GoogleMark, MicrosoftMark, AppleMark } from "./icons";
|
||||
import { buildOAuthUrl, passwordStrength, type LegalDoc } from "./data";
|
||||
|
||||
/* ---- spinner ---- */
|
||||
export function Spinner({ lg }: { lg?: boolean }) {
|
||||
return <span className={lg ? "spinner spinner-lg" : "spinner"} role="status" aria-label="Loading" />;
|
||||
}
|
||||
|
||||
/* ---- badge ---- */
|
||||
export function Badge({ tone = "gray", children }: { tone?: "blue" | "green" | "gray" | "amber"; children: React.ReactNode }) {
|
||||
return <span className={`badge badge-${tone}`}>{children}</span>;
|
||||
}
|
||||
|
||||
/* ---- back button ---- */
|
||||
export function StepBack({ onClick, label = "Back" }: { onClick: () => void; label?: string }) {
|
||||
return (
|
||||
<button className="step-back" onClick={onClick} type="button">
|
||||
<Icon name="chevL" size={16} /> {label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- account pill ---- */
|
||||
export function AccountPill({ initials, email, onChange }: { initials: string; email: string; onChange: () => void }) {
|
||||
return (
|
||||
<div className="account-pill">
|
||||
<Avatar initials={initials} size={34} />
|
||||
<span className="ap-email">{email}</span>
|
||||
<button className="link ap-change" onClick={onChange} type="button">Change</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- flash note ---- */
|
||||
export function FlashNote({ tone = "info", children }: { tone?: "info" | "warn" | "error" | "success"; children: React.ReactNode }) {
|
||||
const icon = tone === "error" || tone === "warn" ? "alert" : tone === "success" ? "check" : "info";
|
||||
return (
|
||||
<div className={`flash flash-${tone}`} role="status">
|
||||
<Icon name={icon} size={16} />
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- method row ---- */
|
||||
export function MethodRow({
|
||||
icon, title, sub, badge, onClick,
|
||||
}: { icon: string; title: string; sub?: string; badge?: string; onClick: () => void }) {
|
||||
return (
|
||||
<button className="method-row" onClick={onClick} type="button">
|
||||
<span className="mr-tile"><Icon name={icon} size={19} /></span>
|
||||
<span className="mr-text">
|
||||
<span className="mr-title">{title} {badge && <span className="mr-badge">{badge}</span>}</span>
|
||||
{sub && <span className="mr-sub">{sub}</span>}
|
||||
</span>
|
||||
<Icon name="chevR" size={18} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- remember device ---- */
|
||||
export function RememberDevice({ checked, onChange, note }: { checked: boolean; onChange: (v: boolean) => void; note?: string }) {
|
||||
return (
|
||||
<label className="remember-card">
|
||||
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||
<span className="col" style={{ gap: 2 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 13.5 }}>Remember this device for 90 days</span>
|
||||
{note && <span className="faint" style={{ fontSize: 12 }}>{note}</span>}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- OTP boxes ---- */
|
||||
export function OtpBoxes({
|
||||
length = 6, onComplete, error, onChange,
|
||||
}: { length?: number; onComplete?: (code: string) => void; error?: boolean; onChange?: (code: string) => void }) {
|
||||
const [vals, setVals] = useState<string[]>(Array(length).fill(""));
|
||||
const refs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
|
||||
function emit(next: string[]) {
|
||||
setVals(next);
|
||||
const code = next.join("");
|
||||
onChange?.(code);
|
||||
if (code.length === length && !next.includes("")) onComplete?.(code);
|
||||
}
|
||||
function setAt(i: number, v: string) {
|
||||
const d = v.replace(/\D/g, "").slice(-1);
|
||||
const next = [...vals]; next[i] = d; emit(next);
|
||||
if (d && i < length - 1) refs.current[i + 1]?.focus();
|
||||
}
|
||||
function onPaste(e: React.ClipboardEvent) {
|
||||
const text = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, length);
|
||||
if (!text) return;
|
||||
e.preventDefault();
|
||||
const next = Array(length).fill("");
|
||||
text.split("").forEach((c, i) => (next[i] = c));
|
||||
emit(next);
|
||||
refs.current[Math.min(text.length, length - 1)]?.focus();
|
||||
}
|
||||
return (
|
||||
<div className={`otp-boxes ${error ? "otp-error" : ""}`} onPaste={onPaste}>
|
||||
{vals.map((d, i) => (
|
||||
<input
|
||||
key={i}
|
||||
ref={(el) => { refs.current[i] = el; }}
|
||||
className="otp-box"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={1}
|
||||
value={d}
|
||||
autoFocus={i === 0}
|
||||
aria-label={`Digit ${i + 1}`}
|
||||
onChange={(e) => setAt(i, e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Backspace" && !vals[i] && i > 0) refs.current[i - 1]?.focus(); }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- resend link with cooldown ---- */
|
||||
export function ResendLink({ seconds = 30, onResend }: { seconds?: number; onResend?: () => void }) {
|
||||
const [left, setLeft] = useState(seconds);
|
||||
useEffect(() => {
|
||||
if (left <= 0) return;
|
||||
const t = setTimeout(() => setLeft((l) => l - 1), 1000);
|
||||
return () => clearTimeout(t);
|
||||
}, [left]);
|
||||
if (left > 0) return <span className="faint" style={{ fontSize: 13 }}>Resend code in {left}s</span>;
|
||||
return (
|
||||
<button className="link" type="button" onClick={() => { setLeft(seconds); onResend?.(); }}>
|
||||
Resend code
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- social buttons (real OAuth or demo mode) ---- */
|
||||
/** Returns true if it redirected to a real provider; false if demo mode. */
|
||||
export function startSocial(provider: "google" | "microsoft" | "apple"): boolean {
|
||||
const url = buildOAuthUrl(provider);
|
||||
if (url) { window.location.href = url; return true; }
|
||||
return false;
|
||||
}
|
||||
export function SocialButtons({ onPick, verb = "Continue" }: { onPick: (p: "google" | "microsoft" | "apple") => void; verb?: string }) {
|
||||
return (
|
||||
<div className="col gap-3">
|
||||
<button className="btn btn-oauth" type="button" onClick={() => onPick("google")}>
|
||||
<GoogleMark /> {verb} with Google
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- password strength meter ---- */
|
||||
export function PasswordStrength({ pw }: { pw: string }) {
|
||||
const s = passwordStrength(pw);
|
||||
if (!pw) return null;
|
||||
return (
|
||||
<div className="strength">
|
||||
<div className="strength-bars">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<span key={i} className={i < s.score ? `on s${s.score}` : ""} />
|
||||
))}
|
||||
</div>
|
||||
<div className="strength-label">Password strength: <b className={`s${s.score}-text`}>{s.label}</b></div>
|
||||
<ul className="checklist">
|
||||
{s.checks.map((c) => (
|
||||
<li key={c.label} className={c.ok ? "ok" : ""}>
|
||||
<Icon name={c.ok ? "check" : "x"} size={13} /> {c.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- legal modal (scroll-to-end to accept) ---- */
|
||||
export function LegalModal({ doc, onClose, onReviewed }: { doc: LegalDoc; onClose: () => void; onReviewed: () => void }) {
|
||||
const [reviewed, setReviewed] = useState(false);
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
// If the content fits without scrolling, there's nothing to scroll — mark reviewed.
|
||||
useEffect(() => {
|
||||
const el = bodyRef.current;
|
||||
if (el && el.scrollHeight <= el.clientHeight + 12) setReviewed(true);
|
||||
}, []);
|
||||
function onScroll(e: React.UIEvent<HTMLDivElement>) {
|
||||
const el = e.currentTarget;
|
||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 10 && !reviewed) setReviewed(true);
|
||||
}
|
||||
return (
|
||||
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label={doc.title}>
|
||||
<div className="modal anim-pop">
|
||||
<div className="modal-head">
|
||||
<div className="col" style={{ gap: 2 }}>
|
||||
<h3 style={{ fontSize: 17, fontWeight: 700 }}>{doc.title}</h3>
|
||||
<span className="faint" style={{ fontSize: 12 }}>{doc.updated}</span>
|
||||
</div>
|
||||
<button className="icon-btn" onClick={onClose} aria-label="Close"><Icon name="x" size={18} /></button>
|
||||
</div>
|
||||
<div className="modal-body" ref={bodyRef} onScroll={onScroll}>
|
||||
{doc.sections.map((s) => (
|
||||
<div key={s.h} style={{ marginBottom: 16 }}>
|
||||
<h4 style={{ fontSize: 14, fontWeight: 700, marginBottom: 6 }}>{s.h}</h4>
|
||||
<p style={{ fontSize: 13.5, color: "var(--muted)", lineHeight: 1.6 }}>{s.p}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="modal-foot">
|
||||
<span className={`review-note ${reviewed ? "done" : ""}`}>
|
||||
<Icon name={reviewed ? "check" : "info"} size={15} />
|
||||
{reviewed ? "Reviewed — you can now accept" : "Scroll to the end to mark as reviewed"}
|
||||
</span>
|
||||
<button className="btn btn-primary" style={{ width: "auto" }} disabled={!reviewed} onClick={() => { onReviewed(); onClose(); }}>
|
||||
I've read this
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- cookie banner ---- */
|
||||
export function CookieBanner() {
|
||||
const [show, setShow] = useState(false);
|
||||
useEffect(() => {
|
||||
try { if (!localStorage.getItem("lup_cookie")) setShow(true); } catch { setShow(true); }
|
||||
}, []);
|
||||
function choose(v: string) {
|
||||
try { localStorage.setItem("lup_cookie", v); } catch { /* ignore */ }
|
||||
setShow(false);
|
||||
}
|
||||
if (!show) return null;
|
||||
return (
|
||||
<div className="cookie" role="dialog" aria-label="Cookie consent">
|
||||
<div className="grow">
|
||||
<h4 style={{ fontSize: 14.5, fontWeight: 700 }}>We value your privacy</h4>
|
||||
<p style={{ fontSize: 12.5, color: "var(--muted)", margin: "5px 0 0", lineHeight: 1.5 }}>
|
||||
We use essential cookies to sign you in securely, and optional cookies for analytics. See our{" "}
|
||||
<button className="link" onClick={() => {}}>Cookie & Privacy Policy</button>.
|
||||
</p>
|
||||
</div>
|
||||
<div className="row gap-2 cookie-actions">
|
||||
<button className="btn btn-sm" style={{ width: "auto" }} onClick={() => choose("essential")}>Reject non-essential</button>
|
||||
<button className="btn btn-primary btn-sm" style={{ width: "auto" }} onClick={() => choose("all")}>Accept all</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user