Files
lynkeduppro-crm/src/components/portal/bits.tsx
T
tanweer919 74caae0710 feat(auth): Google-only social; sign-up email/password only; phone login entry
- 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.
2026-07-11 20:48:47 +05:30

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&apos;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 &amp; 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>
);
}