forked from Goutam/lynkeduppro-crm
74caae0710
- SocialButtons: only Google (Microsoft/Apple hidden). - Sign-up: remove all social (email/password only). - Login: keep Google + email/password (single step) + email/SMS OTP; add a direct 'Sign in with a phone number' entry (SMS OTP), and rename the OTP link to 'Sign in with a one-time code'. All Supabase-supported; no mandatory 2FA.
254 lines
9.8 KiB
TypeScript
254 lines
9.8 KiB
TypeScript
"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") => void; verb?: string }) {
|
|
// Only Google is offered (Microsoft/Apple intentionally hidden).
|
|
return (
|
|
<div className="col gap-3">
|
|
<button className="btn btn-oauth" type="button" onClick={() => onPick("google")}>
|
|
<GoogleMark /> {verb} with Google
|
|
</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>
|
|
);
|
|
}
|