"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 ;
}
/* ---- badge ---- */
export function Badge({ tone = "gray", children }: { tone?: "blue" | "green" | "gray" | "amber"; children: React.ReactNode }) {
return {children};
}
/* ---- back button ---- */
export function StepBack({ onClick, label = "Back" }: { onClick: () => void; label?: string }) {
return (
);
}
/* ---- account pill ---- */
export function AccountPill({ initials, email, onChange }: { initials: string; email: string; onChange: () => void }) {
return (
);
}
/* ---- 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 (
{children}
);
}
/* ---- method row ---- */
export function MethodRow({
icon, title, sub, badge, onClick,
}: { icon: string; title: string; sub?: string; badge?: string; onClick: () => void }) {
return (
);
}
/* ---- remember device ---- */
export function RememberDevice({ checked, onChange, note }: { checked: boolean; onChange: (v: boolean) => void; note?: string }) {
return (
);
}
/* ---- 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(Array(length).fill(""));
const refs = useRef>([]);
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 (
{vals.map((d, i) => (
{ 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(); }}
/>
))}
);
}
/* ---- 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 Resend code in {left}s;
return (
);
}
/* ---- 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 (
);
}
/* ---- password strength meter ---- */
export function PasswordStrength({ pw }: { pw: string }) {
const s = passwordStrength(pw);
if (!pw) return null;
return (
{[0, 1, 2, 3].map((i) => (
))}
Password strength: {s.label}
{s.checks.map((c) => (
-
{c.label}
))}
);
}
/* ---- 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(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) {
const el = e.currentTarget;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 10 && !reviewed) setReviewed(true);
}
return (
{doc.title}
{doc.updated}
{doc.sections.map((s) => (
))}
{reviewed ? "Reviewed — you can now accept" : "Scroll to the end to mark as reviewed"}
);
}
/* ---- 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 (
We value your privacy
We use essential cookies to sign you in securely, and optional cookies for analytics. See our{" "}
.
);
}