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:
2026-06-27 18:36:08 +05:30
parent 50b120e184
commit 8c5df83d63
13 changed files with 2520 additions and 21 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 909 KiB

+63
View File
@@ -0,0 +1,63 @@
import { NextResponse } from "next/server";
// Keyless geo BFF for the registration address step.
// - GET /api/geo?pin=201310 → { ok, city, state, localities[] }
// - GET /api/geo?addr=foo&country=us → { ok, suggestions[] }
// All data is mocked so the demo works without any external key.
const PINS: Record<string, { city: string; state: string; localities: string[] }> = {
"201310": { city: "Greater Noida", state: "Uttar Pradesh", localities: ["Sector 18", "Pari Chowk", "Knowledge Park", "Alpha 1"] },
"201301": { city: "Noida", state: "Uttar Pradesh", localities: ["Sector 1", "Sector 15", "Sector 18", "Sector 62"] },
"110001": { city: "New Delhi", state: "Delhi", localities: ["Connaught Place", "Barakhamba", "Janpath"] },
"400001": { city: "Mumbai", state: "Maharashtra", localities: ["Fort", "Colaba", "Churchgate"] },
"560001": { city: "Bengaluru", state: "Karnataka", localities: ["MG Road", "Cubbon Park", "Shivajinagar"] },
};
function pinFallback(pin: string) {
// derive a plausible state from the first digit for unknown PINs
const map: Record<string, { city: string; state: string }> = {
"1": { city: "Delhi NCR", state: "Delhi" },
"2": { city: "Lucknow", state: "Uttar Pradesh" },
"3": { city: "Jaipur", state: "Rajasthan" },
"4": { city: "Mumbai", state: "Maharashtra" },
"5": { city: "Hyderabad", state: "Telangana" },
"6": { city: "Chennai", state: "Tamil Nadu" },
"7": { city: "Kolkata", state: "West Bengal" },
};
const hit = map[pin[0]] ?? { city: "City", state: "State" };
return { ...hit, localities: ["Central", "North", "South", "East", "West"] };
}
export function GET(request: Request) {
const { searchParams } = new URL(request.url);
const pin = searchParams.get("pin");
const addr = searchParams.get("addr");
const country = searchParams.get("country") ?? "";
if (pin) {
if (!/^\d{6}$/.test(pin)) return NextResponse.json({ ok: false, error: "invalid_pin" });
const data = PINS[pin] ?? pinFallback(pin);
return NextResponse.json({ ok: true, ...data });
}
if (addr) {
const q = addr.trim();
if (q.length < 3) return NextResponse.json({ ok: true, suggestions: [] });
const cities: Record<string, { city: string; state: string }[]> = {
us: [{ city: "New York", state: "New York" }, { city: "Newark", state: "New Jersey" }, { city: "San Francisco", state: "California" }],
gb: [{ city: "London", state: "" }, { city: "Manchester", state: "" }, { city: "Birmingham", state: "" }],
ae: [{ city: "Dubai", state: "Dubai" }, { city: "Abu Dhabi", state: "Abu Dhabi" }],
};
const pool = cities[country.toLowerCase()] ?? cities.us;
const suggestions = pool.map((c, i) => ({
line1: `${q} ${10 + i * 7}, ${c.city}`,
line2: i === 0 ? "Downtown" : "Suburb",
city: c.city,
state: c.state,
postal: country.toLowerCase() === "us" ? `${10001 + i}` : `${q.slice(0, 2).toUpperCase()}${i}A 1BB`,
}));
return NextResponse.json({ ok: true, suggestions });
}
return NextResponse.json({ ok: false, error: "missing_query" });
}
+3 -21
View File
@@ -1,24 +1,6 @@
import Link from "next/link";
import { siteConfig } from "@/config/site";
import { redirect } from "next/navigation";
// Login is the default first page — send visitors straight to the portal.
export default function Home() {
return (
<main className="flex-1 flex flex-col items-center justify-center text-center px-6 py-24">
<span className="text-sm font-medium text-neutral-500">
{siteConfig.shortName}
</span>
<h1 className="mt-3 text-4xl sm:text-5xl font-bold tracking-tight">
{siteConfig.name}
</h1>
<p className="mt-4 max-w-xl text-neutral-500">
{siteConfig.description}
</p>
<Link
href="/dashboard"
className="mt-8 inline-flex items-center rounded-full bg-neutral-900 text-white dark:bg-white dark:text-neutral-900 px-6 py-3 text-sm font-medium hover:opacity-90 transition-opacity"
>
Go to Dashboard
</Link>
</main>
);
redirect("/portal/login");
}
+26
View File
@@ -0,0 +1,26 @@
import type { Metadata } from "next";
import "@/components/portal/portal.css";
export const metadata: Metadata = {
title: "Lynkedup Pro Portal",
description:
"Official Lynkedup Pro portal — manage your workspace, payments and documents.",
};
export default function PortalLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<div className="portal-root">{children}</div>
</>
);
}
+22
View File
@@ -0,0 +1,22 @@
"use client";
import { PortalAside, PanelBrand } from "@/components/portal/parts";
import { LoginFlow } from "@/components/portal/login-flow";
import { CookieBanner } from "@/components/portal/bits";
export default function LoginPage() {
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 />
<LoginFlow />
</div>
</section>
</div>
<CookieBanner />
</main>
);
}
+22
View File
@@ -0,0 +1,22 @@
"use client";
import { PortalAside, PanelBrand } from "@/components/portal/parts";
import { RegisterFlow } from "@/components/portal/register-flow";
import { CookieBanner } from "@/components/portal/bits";
export default function RegisterPage() {
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 />
</div>
</section>
</div>
<CookieBanner />
</main>
);
}
+258
View File
@@ -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&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>
);
}
+179
View File
@@ -0,0 +1,179 @@
// ============================================================
// Seed / mock data + helpers for the Lynkedup Pro auth demo.
// All lookups are client-side mocks so the journey is clickable.
// ============================================================
export type Account = {
firstName: string;
name: string;
initials: string;
hasPasskey: boolean;
hasTotp: boolean;
hasPush: boolean;
maskedEmail: string;
maskedPhone: string;
maskedWa: string;
};
/* ---- mask helpers ---- */
export function maskEmail(email: string): string {
const [user = "", domain = ""] = email.split("@");
if (!domain) return email;
if (user.length <= 2) return `${user[0] ?? ""}••@${domain}`;
return `${user[0]}${"•".repeat(Math.max(4, user.length - 2))}${user[user.length - 1]}@${domain}`;
}
export const MASKED_PHONE = "(•••) •••-••12";
export const MASKED_WA = "+91 ••••• ••012";
/* ---- demo account "on file" ---- */
export const DEMO_USER = {
name: "Rajesh Kumar Sharma",
firstName: "Rajesh",
lastName: "Sharma",
initials: "RS",
allotmentNo: "YEA-654321",
sector: "Sector 18",
pocket: "Pocket B",
plot: "181",
category: "Residential",
size: "300 sq.m",
maskedMobile: MASKED_PHONE,
email: "rajesh.sharma@example.com",
relationship: "Self",
isAllottee: true,
};
/**
* Look up an "on file" account by email. Returns null for emails that contain
* "new", "unknown" or "notfound" (so the NOT-FOUND screen is reachable).
*/
export function lookupAccount(email: string): Account | null {
const e = email.trim().toLowerCase();
if (!e || /(\bnew\b|unknown|notfound|new@)/.test(e) || e.startsWith("new")) return null;
return {
firstName: DEMO_USER.firstName,
name: DEMO_USER.name,
initials: DEMO_USER.initials,
hasPasskey: true,
hasTotp: true,
hasPush: true,
maskedEmail: maskEmail(email),
maskedPhone: MASKED_PHONE,
maskedWa: MASKED_WA,
};
}
/* ---- country codes ---- */
export type CountryCode = { code: string; flag: string; name: string; digits: number; example: string };
export const countryCodes: CountryCode[] = [
{ code: "+1", flag: "🇺🇸", name: "United States", digits: 10, example: "201 555 0123" },
{ code: "+91", flag: "🇮🇳", name: "India", digits: 10, example: "98765 43210" },
{ code: "+44", flag: "🇬🇧", name: "United Kingdom", digits: 10, example: "7400 123456" },
{ code: "+971", flag: "🇦🇪", name: "UAE", digits: 9, example: "50 123 4567" },
{ code: "+65", flag: "🇸🇬", name: "Singapore", digits: 8, example: "8123 4567" },
{ code: "+61", flag: "🇦🇺", name: "Australia", digits: 9, example: "412 345 678" },
];
export const relationshipOptions = [
"Homeowner",
"Contractor",
"Property Manager",
"Tenant",
"Authorized Representative",
];
export const sectors = [
"Sector 17A",
"Sector 18",
"Sector 20",
"Sector 22D",
"Sector 22E",
"Sector 24A",
];
export const pockets = ["Pocket A", "Pocket B", "Pocket C", "Pocket D"];
/* ---- address countries ---- */
export type AddrCountry = {
code: string;
name: string;
lookup: "pincode" | "search" | "none";
postalLabel: string;
postalLen: number;
states: string[];
};
export const addressCountries: AddrCountry[] = [
{ code: "US", name: "United States", lookup: "search", postalLabel: "ZIP code", postalLen: 5, states: ["California", "New York", "Texas", "Washington", "New Jersey", "Florida"] },
{
code: "IN", name: "India", lookup: "pincode", postalLabel: "PIN code", postalLen: 6,
states: ["Uttar Pradesh", "Delhi", "Haryana", "Maharashtra", "Karnataka", "Rajasthan"],
},
{ code: "GB", name: "United Kingdom", lookup: "search", postalLabel: "Postcode", postalLen: 7, states: [] },
{ code: "AE", name: "United Arab Emirates", lookup: "none", postalLabel: "PO Box", postalLen: 6, states: ["Dubai", "Abu Dhabi", "Sharjah"] },
];
/* ---- legal documents (own copy) ---- */
export type LegalDoc = { title: string; updated: string; sections: { h: string; p: string }[] };
export const TERMS: LegalDoc = {
title: "Terms of Use",
updated: "Last updated: June 2026",
sections: [
{ h: "1. Authorised use", p: "This portal is provided for property owners and their authorised representatives to manage roof inspections, estimates and records. You agree to access only accounts and records you are entitled to." },
{ h: "2. Accuracy of records", p: "You agree to provide accurate, current and complete information during registration and to keep it updated. Records are matched against authority data." },
{ h: "3. Data & privacy", p: "Your data is processed in line with our Privacy Policy. We collect only what is needed to verify identity and provide portal services." },
{ h: "4. Security", p: "You are responsible for keeping your credentials, one-time codes and passkeys secure. Notify us immediately of any unauthorised access." },
{ h: "5. Acceptable conduct", p: "You must not misuse the portal, attempt to bypass verification, or access it through automated means without permission." },
],
};
export const PRIVACY: LegalDoc = {
title: "Privacy Policy",
updated: "Last updated: June 2026",
sections: [
{ h: "1. What we collect", p: "Identity details, contact information, property identifiers and device/security signals required to protect your account." },
{ h: "2. How we use it", p: "To verify your identity, prevent fraud, provide portal features and meet legal obligations." },
{ h: "3. Sharing", p: "We share data only with the authority and processors acting on our behalf, under appropriate safeguards." },
{ h: "4. Your rights", p: "You may request access, correction or deletion of your data, subject to record-retention requirements." },
{ h: "5. Cookies", p: "We use essential cookies for security and sign-in, and optional cookies (with consent) for analytics." },
],
};
/* ---- password strength ---- */
export type Strength = { score: number; label: "Weak" | "Fair" | "Good" | "Strong"; checks: { ok: boolean; label: string }[] };
export function passwordStrength(pw: string): Strength {
const checks = [
{ ok: pw.length >= 8, label: "At least 8 characters" },
{ ok: /[a-z]/.test(pw) && /[A-Z]/.test(pw), label: "Upper & lower case" },
{ ok: /\d/.test(pw), label: "A number" },
{ ok: /[^A-Za-z0-9]/.test(pw), label: "A special character" },
];
const passed = checks.filter((c) => c.ok).length;
const weakList = ["1234", "abcd", "password", "qwerty", "0000"];
const isWeak = weakList.some((w) => pw.toLowerCase().includes(w));
const score = isWeak ? Math.min(passed, 1) : passed;
const label = score <= 1 ? "Weak" : score === 2 ? "Fair" : score === 3 ? "Good" : "Strong";
return { score, label, checks };
}
/* ---- oauth ---- */
type Provider = "google" | "microsoft" | "apple";
export function buildOAuthUrl(provider: Provider): string | null {
if (typeof window === "undefined") return null;
const map = {
google: { id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID, auth: "https://accounts.google.com/o/oauth2/v2/auth", scope: "openid email profile" },
microsoft: { id: process.env.NEXT_PUBLIC_MS_CLIENT_ID, auth: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", scope: "openid email profile" },
apple: { id: process.env.NEXT_PUBLIC_APPLE_CLIENT_ID, auth: "https://appleid.apple.com/auth/authorize", scope: "name email" },
} as const;
const cfg = map[provider];
if (!cfg.id) return null;
const rand = () => (crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2));
const params = new URLSearchParams({
client_id: cfg.id,
redirect_uri: `${window.location.origin}/portal/login`,
response_type: "code",
scope: cfg.scope,
state: rand(),
nonce: rand(),
});
return `${cfg.auth}?${params.toString()}`;
}
+112
View File
@@ -0,0 +1,112 @@
// Brand marks for OAuth buttons + a line-icon set + Avatar for the portal.
export function GoogleMark({ className = "oauth-ico" }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden>
<path fill="#4285F4" d="M23 12.27c0-.79-.07-1.54-.2-2.27H12v4.51h6.16a5.27 5.27 0 0 1-2.28 3.46v2.88h3.69C21.7 18.86 23 15.86 23 12.27Z" />
<path fill="#34A853" d="M12 23.5c3.08 0 5.66-1.02 7.55-2.76l-3.69-2.88c-1.02.69-2.33 1.1-3.86 1.1-2.97 0-5.49-2-6.39-4.7H1.79v2.97A11.5 11.5 0 0 0 12 23.5Z" />
<path fill="#FBBC05" d="M5.61 14.26a6.9 6.9 0 0 1 0-4.52V6.77H1.79a11.5 11.5 0 0 0 0 10.46l3.82-2.97Z" />
<path fill="#EA4335" d="M12 4.96c1.68 0 3.18.58 4.37 1.71l3.27-3.27C17.66 1.54 15.08.5 12 .5A11.5 11.5 0 0 0 1.79 6.77l3.82 2.97c.9-2.7 3.42-4.78 6.39-4.78Z" />
</svg>
);
}
export function MicrosoftMark({ className = "oauth-ico" }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden>
<path fill="#F25022" d="M2 2h9.2v9.2H2z" />
<path fill="#7FBA00" d="M12.8 2H22v9.2h-9.2z" />
<path fill="#00A4EF" d="M2 12.8h9.2V22H2z" />
<path fill="#FFB900" d="M12.8 12.8H22V22h-9.2z" />
</svg>
);
}
export function AppleMark({ className = "oauth-ico" }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="#f5f5f7" aria-hidden>
<path d="M17.05 12.54c-.03-2.6 2.13-3.85 2.22-3.91-1.21-1.77-3.09-2.02-3.76-2.05-1.6-.16-3.12.94-3.93.94-.81 0-2.06-.92-3.39-.9-1.74.03-3.35 1.01-4.25 2.57-1.81 3.14-.46 7.79 1.3 10.34.86 1.25 1.88 2.65 3.22 2.6 1.29-.05 1.78-.83 3.34-.83 1.56 0 2 .83 3.37.81 1.39-.03 2.27-1.27 3.12-2.53.98-1.45 1.39-2.85 1.41-2.93-.03-.01-2.71-1.04-2.74-4.13ZM14.6 4.86c.71-.86 1.19-2.06 1.06-3.26-1.02.04-2.26.68-3 1.54-.66.76-1.24 1.98-1.08 3.15 1.14.09 2.31-.58 3.02-1.43Z" />
</svg>
);
}
const LINE: Record<string, React.ReactNode> = {
shield: <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z" />,
lock: <path d="M5 11h14v9H5zM8 11V8a4 4 0 0 1 8 0v3" />,
globe: <path d="M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18ZM3 12h18M12 3c2.5 2.5 2.5 15 0 18M12 3c-2.5 2.5-2.5 15 0 18" />,
check: <path d="m20 6-11 11-5-5" />,
arrowR: <path d="M5 12h14M13 6l6 6-6 6" />,
chevL: <path d="m15 18-6-6 6-6" />,
chevR: <path d="m9 6 6 6-6 6" />,
mail: <path d="M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm0 1 8 6 8-6" />,
phone: <path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3 19.5 19.5 0 0 1-6-6 19.8 19.8 0 0 1-3-8.6A2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1.9.3 1.8.6 2.6a2 2 0 0 1-.5 2.1L8 9.6a16 16 0 0 0 6 6l1.2-1.2a2 2 0 0 1 2.1-.5c.8.3 1.7.5 2.6.6a2 2 0 0 1 1.7 2Z" />,
user: <path d="M20 21a8 8 0 1 0-16 0M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z" />,
key: <path d="m21 2-2 2m-3.5 3.5L11 12m4.5-4.5 2.5 2.5M9 13a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm0 0 2-2" />,
eye: <path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12Z M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" />,
eyeOff: <path d="M3 3l18 18M10.6 10.6a3 3 0 0 0 4.2 4.2M9.9 4.7A10.9 10.9 0 0 1 12 4.5c6 0 10 7 10 7a18 18 0 0 1-3.2 3.9M6.1 6.1A18 18 0 0 0 2 11.5s4 7 10 7a10.9 10.9 0 0 0 3.5-.6" />,
alert: <path d="M12 9v4m0 4h.01M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" />,
info: <path d="M12 16v-4M12 8h.01M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z" />,
x: <path d="M18 6 6 18M6 6l12 12" />,
smartphone: <path d="M7 2h10a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm4 18h2" />,
totp: <path d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h3v3h-3zM20 17v3M17 20h3" />,
whatsapp: <path d="M3 21l1.7-5A8 8 0 1 1 8 19.3L3 21Zm6-12c-.3 0-.6.1-.8.4-.3.4-.9 1-.9 2.2s.9 2.5 1 2.7c.2.2 1.8 2.9 4.5 3.9 2.2.8 2.7.7 3.2.6.6-.1 1.6-.7 1.8-1.3.2-.6.2-1.2.2-1.3-.1-.1-.3-.2-.6-.4-.3-.2-1.7-.8-1.9-.9-.3-.1-.5-.1-.7.1l-.6.8c-.1.2-.3.2-.5.1-.3-.1-1.1-.4-2-1.2-.7-.6-1.2-1.4-1.3-1.7-.1-.2 0-.4.1-.5l.4-.5c.1-.2.2-.3.3-.5 0-.2 0-.4-.1-.5L9.8 9.3C9.6 9 9.4 9 9 9Z" />,
sms: <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10ZM8 9h8M8 13h5" />,
mapPin: <path d="M12 21s7-5.5 7-11a7 7 0 1 0-14 0c0 5.5 7 11 7 11ZM12 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z" />,
home: <path d="m3 10 9-7 9 7v10a1 1 0 0 1-1 1h-5v-7H9v7H4a1 1 0 0 1-1-1V10Z" />,
edit: <path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5Z" />,
camera: <path d="M4 8h3l1.5-2h7L17 8h3a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1Zm8 3a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z" />,
copy: <path d="M9 9h10a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V10a1 1 0 0 1 1-1ZM5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1" />,
clock: <path d="M12 7v5l3 2M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z" />,
sparkle: <path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M18 6l-2.5 2.5M8.5 15.5 6 18" />,
building: <path d="M3 21h18M5 21V5a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v16M9 8h.01M12 8h.01M9 12h.01M12 12h.01M9 16h.01M12 16h.01" />,
search: <path d="m21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z" />,
};
export function Icon({ name, size = 18, strokeWidth = 1.7 }: { name: string; size?: number; strokeWidth?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
{LINE[name] ?? LINE.info}
</svg>
);
}
export function Avatar({ initials, src, size = 40 }: { initials: string; src?: string; size?: number }) {
if (src) {
// eslint-disable-next-line @next/next/no-img-element
return <img src={src} alt="" width={size} height={size} style={{ borderRadius: size * 0.32, objectFit: "cover" }} />;
}
return (
<span
style={{
width: size, height: size, flex: `0 0 ${size}px`,
borderRadius: size * 0.32,
display: "grid", placeItems: "center",
background: "linear-gradient(135deg, var(--primary), var(--primary-2))",
color: "#fff", fontWeight: 800, fontSize: size * 0.36,
}}
>
{initials}
</span>
);
}
export function BrandLogo({ size = 44 }: { size?: number }) {
return (
<span className="brand-logo" style={{ width: size, height: size, borderRadius: size * 0.27 }}>
<svg width={size * 0.5} height={size * 0.5} viewBox="0 0 24 24" fill="none">
<path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="#fff" strokeWidth="1.6" strokeLinejoin="round" />
<path d="M12 7v10M7.5 9.5v5M16.5 9.5v5" stroke="#fff" strokeWidth="1.4" strokeLinecap="round" />
</svg>
</span>
);
}
+542
View File
@@ -0,0 +1,542 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Icon, Avatar } from "./icons";
import {
Spinner, StepBack, AccountPill, FlashNote, MethodRow,
RememberDevice, OtpBoxes, ResendLink, SocialButtons, startSocial,
PasswordStrength,
} from "./bits";
import { lookupAccount, type Account } from "./data";
type Step =
| "identify" | "connecting" | "notfound" | "primary" | "passkey" | "password"
| "otp" | "another" | "totp" | "push" | "error" | "terms" | "success" | "recovery"
| "fp_confirm" | "fp_code" | "fp_reset";
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function LoginFlow() {
const router = useRouter();
const [step, setStep] = useState<Step>("identify");
const [email, setEmail] = useState("");
const [account, setAccount] = useState<Account | null>(null);
const [provider, setProvider] = useState<string>("");
const [remember, setRemember] = useState(false);
const [flash, setFlash] = useState<string>("");
/* ---- history hash sync ---- */
function push(s: Step) {
setStep(s);
try { window.history.pushState({ s }, "", `#${s}`); } catch { /* ignore */ }
}
function replace(s: Step) {
setStep(s);
try { window.history.replaceState({ s }, "", `#${s}`); } catch { /* ignore */ }
}
const back = () => window.history.back();
function switchAccount() { setAccount(null); setEmail(""); setFlash(""); replace("identify"); }
useEffect(() => {
const onPop = () => {
const h = window.location.hash.replace("#", "") as Step;
setStep(h || "identify");
};
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
/* ---- auth resolution ---- */
function afterAuth(factor: "password" | "passkey" | "otp" | "totp" | "push" | "social") {
if (factor === "password") { setFlash(""); push("otp"); return; }
let accepted = false;
try { accepted = !!localStorage.getItem("lup_terms"); } catch { /* ignore */ }
push(accepted ? "success" : "terms");
}
function onSocial(p: "google" | "microsoft" | "apple") {
const real = startSocial(p);
if (real) return; // redirected away
setProvider(p);
push("connecting");
setTimeout(() => afterAuth("social"), 1300);
}
function identifyEmail() {
if (!EMAIL_RE.test(email)) return;
push("connecting");
setProvider("");
setTimeout(() => {
const acc = lookupAccount(email);
if (!acc) { replace("notfound"); return; }
setAccount(acc);
replace("primary");
}, 650);
}
/* =================================================================== */
return (
<div className="card anim-fade-up" key={step}>
{step === "identify" && <Identify email={email} setEmail={setEmail} onSocial={onSocial} onEmail={identifyEmail} toRegister={() => router.push("/portal/register")} />}
{step === "connecting" && (
<div className="interstitial">
<Spinner lg />
<div>
<h1 style={{ fontSize: 20 }}>{provider ? `Connecting to ${cap(provider)}` : "Looking up your account…"}</h1>
{provider && <p className="sub" style={{ marginTop: 6 }}>Demo mode no provider keys configured.</p>}
</div>
</div>
)}
{step === "notfound" && (
<div>
<div className="conn-banner conn-error-like" style={{ background: "rgba(239,68,68,.1)", border: "1px solid rgba(239,68,68,.35)", color: "#fca5a5", marginBottom: 18 }}>
<Icon name="alert" size={18} /> No account found for <b>&nbsp;{email}</b>
</div>
<h1>We couldn&apos;t find that account</h1>
<p className="sub">No account is on file for this email. Try another email or register a new account.</p>
<div className="col gap-3" style={{ marginTop: 22 }}>
<button className="btn" onClick={() => replace("identify")}>Try a different email</button>
<button className="btn btn-primary" onClick={() => router.push("/portal/register")}>Register a new account <Icon name="arrowR" size={16} /></button>
</div>
</div>
)}
{step === "primary" && account && (
<div>
<h1>Hi {account.firstName}</h1>
<p className="sub">Choose how you want to sign in.</p>
<div style={{ margin: "18px 0" }}>
<AccountPill initials={account.initials} email={email} onChange={() => replace("identify")} />
</div>
<div className="col gap-3">
{account.hasPasskey && <MethodRow icon="key" title="Use your passkey" sub="Fingerprint, face or screen lock" badge="Fastest" onClick={() => push("passkey")} />}
<MethodRow icon="lock" title="Enter your password" sub="Sign in with your password" onClick={() => push("password")} />
<MethodRow icon="sparkle" title="Try another way" sub="Authenticator, push, OTP…" onClick={() => push("another")} />
</div>
</div>
)}
{step === "passkey" && account && (
<Passkey account={account} onBack={back} onPassword={() => replace("password")} onAnother={() => replace("another")} onSuccess={() => afterAuth("passkey")} onFail={() => { setFlash("Passkey was cancelled or timed out."); replace("error"); }} />
)}
{step === "password" && account && (
<Password account={account} flash={flash} onBack={back} onForgot={() => push("fp_confirm")} onOtp={() => replace("otp")} onLocked={() => setFlash("This account is temporarily locked.")} onOk={() => afterAuth("password")} />
)}
{step === "otp" && account && (
<OtpVerify account={account} remember={remember} setRemember={setRemember} onBack={back} onVerified={() => afterAuth("otp")} />
)}
{step === "another" && account && (
<AnotherWay account={account} onBack={back} go={(s) => push(s)} />
)}
{step === "totp" && account && (
<Totp remember={remember} setRemember={setRemember} onBack={back} onVerified={() => afterAuth("totp")} />
)}
{step === "push" && (
<PushApprove onBack={back} onApproved={() => afterAuth("push")} onCancel={() => { setFlash("Sign-in request cancelled."); replace("another"); }} />
)}
{step === "error" && (
<div>
<CenterIcon name="shield" tone="amber" />
<h1 style={{ textAlign: "center" }}>Couldn&apos;t sign you in</h1>
<p className="sub" style={{ textAlign: "center" }}>{flash || "Something interrupted the sign-in."}</p>
<ul className="tips">
<li>Make sure you&apos;re using the correct device or account.</li>
<li>Check your internet connection and try again.</li>
<li>You can switch to another verification method.</li>
</ul>
<div className="col gap-3" style={{ marginTop: 18 }}>
<button className="btn btn-primary" onClick={() => (account ? replace("primary") : replace("identify"))}>Try again</button>
<button className="btn" onClick={switchAccount}>Use a different account</button>
</div>
</div>
)}
{step === "terms" && <TermsGate onAccept={() => { try { localStorage.setItem("lup_terms", "1"); } catch { /* ignore */ } replace("success"); }} />}
{step === "success" && (
<Success account={account} email={email} remember={remember} onContinue={() => router.push("/dashboard")} />
)}
{step === "recovery" && (
<div>
<StepBack onClick={back} />
<CenterIcon name="info" tone="blue" />
<h1 style={{ textAlign: "center" }}>Account recovery</h1>
<p className="sub" style={{ textAlign: "center" }}>None of the available methods worked. Our team can help verify your identity and restore access.</p>
<FlashNote tone="info">Contact support at <b>help@lynkeduppro.com</b> with your property or account details.</FlashNote>
<button className="btn" style={{ marginTop: 16 }} onClick={() => replace("identify")}>Back to sign in</button>
</div>
)}
{step === "fp_confirm" && account && (
<ForgotConfirm masked={account.maskedEmail} onBack={back} onSend={() => push("fp_code")} />
)}
{step === "fp_code" && (
<ForgotCode onBack={back} onOk={() => push("fp_reset")} />
)}
{step === "fp_reset" && (
<ForgotReset onDone={() => { setFlash("Password updated. Please sign in."); replace("password"); }} />
)}
</div>
);
}
/* ============================ screens ============================ */
function Identify({ email, setEmail, onSocial, onEmail, toRegister }: {
email: string; setEmail: (v: string) => void;
onSocial: (p: "google" | "microsoft" | "apple") => void; onEmail: () => void; toRegister: () => void;
}) {
const [showEmail, setShowEmail] = useState(false);
const valid = EMAIL_RE.test(email);
return (
<div>
<div className="kicker">Welcome back</div>
<h1>Sign in to LynkedUp</h1>
<p className="sub">Drone inspections, AI estimates and insurance-ready reports all in one place.</p>
<div style={{ marginTop: 22 }}>
<SocialButtons onPick={onSocial} />
<div className="divider">or continue with email</div>
{!showEmail ? (
<button className="btn" onClick={() => setShowEmail(true)}><Icon name="mail" size={18} /> Continue with email</button>
) : (
<form onSubmit={(e) => { e.preventDefault(); onEmail(); }}>
<div className="field">
<label className="label" htmlFor="lemail">Email address</label>
<div className="input-wrap">
<span className="input-ico"><Icon name="mail" size={17} /></span>
<input id="lemail" className="input" type="email" autoFocus value={email}
onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com" />
</div>
</div>
<button className="btn btn-primary" style={{ marginTop: 12 }} disabled={!valid}>
Continue <Icon name="arrowR" size={17} />
</button>
</form>
)}
</div>
<p className="foot-note">New homeowner or contractor? <button className="link" onClick={toRegister}>Register here</button></p>
<div className="secure-footer">
<span><Icon name="lock" size={13} /> 256-bit SSL</span>
<span><Icon name="shield" size={13} /> Licensed &amp; Insured</span>
<span><Icon name="check" size={13} /> Drone-Powered</span>
</div>
<p className="demo-hint">Demo: any email signs in; an email starting with new shows not found.</p>
</div>
);
}
function Passkey({ account, onBack, onPassword, onAnother, onSuccess, onFail }: {
account: Account; onBack: () => void; onPassword: () => void; onAnother: () => void; onSuccess: () => void; onFail: () => void;
}) {
const [waiting, setWaiting] = useState(false);
async function attempt() {
setWaiting(true);
try {
if (navigator.credentials && "get" in navigator.credentials) {
await navigator.credentials.get({
publicKey: { challenge: new Uint8Array(32), rpId: window.location.hostname, userVerification: "preferred", timeout: 8000 },
} as CredentialRequestOptions).catch(() => null);
}
} catch { /* ignore */ }
setTimeout(onSuccess, 900); // demo: succeed
}
return (
<div>
<StepBack onClick={onBack} />
<CenterIcon name="key" tone="amber" />
<h1 style={{ textAlign: "center" }}>Use your passkey</h1>
<p className="sub" style={{ textAlign: "center" }}>Verify it&apos;s you with your device&apos;s fingerprint, face or screen lock.</p>
{waiting ? (
<div className="interstitial"><Spinner lg /><p className="muted">Waiting for your device</p></div>
) : (
<button className="btn btn-primary" style={{ marginTop: 20 }} onClick={attempt}>Verify with passkey</button>
)}
<div className="col gap-2" style={{ marginTop: 16, alignItems: "center" }}>
<button className="link" onClick={onPassword}>Use your password instead</button>
<button className="link" onClick={onAnother}>Try another way</button>
</div>
<p className="demo-hint">Demo: passkey always succeeds here.</p>
<button hidden onClick={onFail} />
</div>
);
}
function Password({ account, flash, onBack, onForgot, onOtp, onLocked, onOk }: {
account: Account; flash: string; onBack: () => void; onForgot: () => void; onOtp: () => void; onLocked: () => void; onOk: () => void;
}) {
const [pw, setPw] = useState("");
const [show, setShow] = useState(false);
const [err, setErr] = useState("");
function submit(e: React.FormEvent) {
e.preventDefault();
if (pw.toLowerCase() === "wrong") { setErr("locked"); onLocked(); return; }
if (!pw) return;
onOk();
}
return (
<div>
<StepBack onClick={onBack} />
<h1>Enter your password</h1>
<p className="sub">Signing in as {account.maskedEmail}</p>
{flash && <div style={{ marginTop: 14 }}><FlashNote tone="info">{flash}</FlashNote></div>}
{err === "locked" && <div style={{ marginTop: 14 }}><FlashNote tone="error">Account temporarily locked after too many attempts. <button className="link" onClick={onOtp}>Get a one-time code instead</button>.</FlashNote></div>}
<form onSubmit={submit} style={{ marginTop: 16 }}>
<div className="field">
<label className="label" htmlFor="lpw">Password</label>
<div className="input-wrap">
<span className="input-ico"><Icon name="lock" size={17} /></span>
<input id="lpw" className="input" type={show ? "text" : "password"} autoFocus value={pw} onChange={(e) => setPw(e.target.value)} placeholder="••••••••" />
<button type="button" className="input-eye" onClick={() => setShow((s) => !s)} aria-label={show ? "Hide" : "Show"}>
<Icon name={show ? "eyeOff" : "eye"} size={18} />
</button>
</div>
</div>
<button className="btn btn-primary" style={{ marginTop: 14 }}>Sign in <Icon name="arrowR" size={16} /></button>
</form>
<div className="row between" style={{ marginTop: 16 }}>
<button className="link" onClick={onForgot}>Forgot password?</button>
<button className="link" onClick={onOtp}>Sign in using email OTP</button>
</div>
<p className="demo-hint">Demo: any password works; type wrong to see the lockout. Password always needs 2-step next.</p>
</div>
);
}
function OtpVerify({ account, remember, setRemember, onBack, onVerified }: {
account: Account; remember: boolean; setRemember: (v: boolean) => void; onBack: () => void; onVerified: () => void;
}) {
const [channel, setChannel] = useState<"email" | "sms" | "wa">("email");
const [error, setError] = useState(false);
const target = channel === "email" ? account.maskedEmail : channel === "sms" ? account.maskedPhone : account.maskedWa;
function complete(code: string) {
if (code === "000000") { setError(true); return; }
setError(false); onVerified();
}
return (
<div>
<StepBack onClick={onBack} />
<h1>2-step verification</h1>
<p className="sub">Enter the 6-digit code we sent you to finish signing in.</p>
<div className="seg" style={{ margin: "16px 0 14px" }}>
<button className={channel === "email" ? "on" : ""} onClick={() => { setChannel("email"); setError(false); }}><Icon name="mail" size={15} /> Email</button>
<button className={channel === "sms" ? "on" : ""} onClick={() => { setChannel("sms"); setError(false); }}><Icon name="sms" size={15} /> SMS</button>
<button className={channel === "wa" ? "on" : ""} onClick={() => { setChannel("wa"); setError(false); }}><Icon name="whatsapp" size={15} /> WhatsApp</button>
</div>
<p className="faint" style={{ fontSize: 12.5, marginBottom: 12 }}>Code sent to {target}</p>
<OtpBoxes onComplete={complete} error={error} />
{error && <div style={{ marginTop: 12 }}><FlashNote tone="error">Incorrect code. Please try again.</FlashNote></div>}
<div className="row between" style={{ margin: "14px 0" }}>
<ResendLink seconds={30} />
<span />
</div>
<RememberDevice checked={remember} onChange={setRemember} note="2-step is still required at each sign-in." />
<p className="demo-hint">Demo: any 6 digits verify; 000000 shows an error.</p>
</div>
);
}
function AnotherWay({ account, onBack, go }: { account: Account; onBack: () => void; go: (s: Step) => void }) {
const [more, setMore] = useState(false);
return (
<div>
<StepBack onClick={onBack} />
<h1>Try another way</h1>
<p className="sub">Choose a different way to verify it&apos;s you.</p>
<div className="col gap-3" style={{ marginTop: 18 }}>
{account.hasPasskey && <MethodRow icon="key" title="Use your passkey" sub="Fingerprint, face or screen lock" onClick={() => go("passkey")} />}
{account.hasPush && <MethodRow icon="smartphone" title="Approve sign-in with a mobile app" sub="Send a push to your phone" onClick={() => go("push")} />}
<MethodRow icon="lock" title="Enter your password" onClick={() => go("password")} />
{account.hasTotp && <MethodRow icon="totp" title="Get a code from your authenticator app" onClick={() => go("totp")} />}
<MethodRow icon="mail" title="Email a one-time code" sub={account.maskedEmail} onClick={() => go("otp")} />
{more && <>
<MethodRow icon="sms" title="Text a one-time code" sub={account.maskedPhone} onClick={() => go("otp")} />
<MethodRow icon="whatsapp" title="WhatsApp a one-time code" sub={account.maskedWa} onClick={() => go("otp")} />
</>}
</div>
{!more
? <button className="link" style={{ marginTop: 16 }} onClick={() => setMore(true)}>Show more options</button>
: <button className="link" style={{ marginTop: 16 }} onClick={() => go("recovery")}>None of these work </button>}
</div>
);
}
function Totp({ remember, setRemember, onBack, onVerified }: { remember: boolean; setRemember: (v: boolean) => void; onBack: () => void; onVerified: () => void }) {
const [setupKey, setSetupKey] = useState(false);
const [error, setError] = useState(false);
const [key, setKey] = useState("");
return (
<div>
<StepBack onClick={onBack} />
<h1>Authenticator code</h1>
<p className="sub">Open your authenticator app and enter the 6-digit code.</p>
<div style={{ marginTop: 18 }}>
{!setupKey ? (
<OtpBoxes onComplete={(c) => { if (c === "000000") setError(true); else onVerified(); }} error={error} />
) : (
<div className="field">
<label className="label">Setup key</label>
<input className="input" value={key} onChange={(e) => setKey(e.target.value)} placeholder="xxxx-xxxx-xxxx" />
<button className="btn btn-primary" style={{ marginTop: 12 }} onClick={() => onVerified()}>Verify</button>
</div>
)}
</div>
{error && <div style={{ marginTop: 12 }}><FlashNote tone="error">Incorrect code.</FlashNote></div>}
<button className="link" style={{ margin: "14px 0" }} onClick={() => setSetupKey((s) => !s)}>
{setupKey ? "Enter a 6-digit code instead" : "Enter setup key instead"}
</button>
<RememberDevice checked={remember} onChange={setRemember} />
<p className="demo-hint">Demo: any 6 digits verify; 000000 fails.</p>
</div>
);
}
function PushApprove({ onBack, onApproved, onCancel }: { onBack: () => void; onApproved: () => void; onCancel: () => void }) {
const [approved, setApproved] = useState(false);
useEffect(() => {
const t = setTimeout(() => { setApproved(true); setTimeout(onApproved, 900); }, 3200);
return () => clearTimeout(t);
}, [onApproved]);
return (
<div>
<StepBack onClick={onBack} />
<CenterIcon name="smartphone" tone={approved ? "green" : "amber"} />
<h1 style={{ textAlign: "center" }}>{approved ? "Approved — signing you in…" : "Check your phone"}</h1>
<p className="sub" style={{ textAlign: "center" }}>{approved ? "Your sign-in was approved." : "We sent a notification to your mobile app. Approve it to continue."}</p>
<div className="interstitial">{approved ? <Icon name="check" size={30} /> : <Spinner lg />}</div>
{!approved && <button className="btn" onClick={onCancel}>Cancel</button>}
<p className="demo-hint">Demo: auto-approves after ~3 seconds.</p>
</div>
);
}
function TermsGate({ onAccept }: { onAccept: () => void }) {
const [reviewed, setReviewed] = useState(false);
const [checked, setChecked] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
// If the terms fit without scrolling, enable the checkbox right away.
useEffect(() => {
const el = scrollRef.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 - 12) setReviewed(true);
}
return (
<div>
<h1>Before you continue</h1>
<p className="sub">Please review and accept the portal terms to finish signing in.</p>
<div className="terms-scroll" ref={scrollRef} onScroll={onScroll}>
{["Authorised use", "Accuracy of records", "Data & privacy", "Security", "Acceptable conduct"].map((h, i) => (
<div key={h} style={{ marginBottom: 14 }}>
<h4 style={{ fontSize: 14, fontWeight: 700, marginBottom: 5 }}>{i + 1}. {h}</h4>
<p style={{ fontSize: 13, color: "var(--muted)", lineHeight: 1.6 }}>
By using the Lynkedup Pro portal you agree to access only records you are entitled to, keep your information accurate, protect your credentials, and use the service lawfully. Verification is required to safeguard your account.
</p>
</div>
))}
<p className="faint" style={{ fontSize: 12 }}> End of terms </p>
</div>
<label className={`remember-card ${!reviewed ? "is-disabled" : ""}`} style={{ marginTop: 14 }}>
<input type="checkbox" disabled={!reviewed} checked={checked} onChange={(e) => setChecked(e.target.checked)} />
<span style={{ fontWeight: 600, fontSize: 13.5 }}>I accept the Terms of Use and Privacy Policy{!reviewed && <span className="faint" style={{ fontWeight: 500 }}> scroll to the end to enable</span>}</span>
</label>
<button className="btn btn-primary" style={{ marginTop: 16 }} disabled={!checked} onClick={onAccept}>I accept</button>
</div>
);
}
function Success({ account, email, remember, onContinue }: { account: Account | null; email: string; remember: boolean; onContinue: () => void }) {
useEffect(() => {
if (remember) { try { localStorage.setItem("lup_trusted", String(Date.now() + 90 * 864e5)); } catch { /* ignore */ } }
const t = setTimeout(onContinue, 2600);
return () => clearTimeout(t);
}, [remember, onContinue]);
return (
<div style={{ textAlign: "center" }}>
<CenterIcon name="check" tone="green" />
<h1>You&apos;re signed in</h1>
<p className="sub">Welcome back to the Lynkedup Pro portal.</p>
<div style={{ display: "inline-flex", alignItems: "center", gap: 10, margin: "18px 0", padding: "8px 14px", border: "1px solid var(--line-2)", borderRadius: 999 }}>
<Avatar initials={account?.initials ?? "LP"} size={28} />
<span style={{ fontSize: 13.5, fontWeight: 600 }}>{email || account?.name}</span>
</div>
{remember && <p className="faint" style={{ fontSize: 12.5 }}>We&apos;ll remember this device for 90 days.</p>}
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={onContinue}>Go to dashboard now <Icon name="arrowR" size={16} /></button>
</div>
);
}
/* ---- forgot password mini machine ---- */
function ForgotConfirm({ masked, onBack, onSend }: { masked: string; onBack: () => void; onSend: () => void }) {
return (
<div>
<StepBack onClick={onBack} />
<h1>Reset your password</h1>
<p className="sub">We&apos;ll send a verification code to {masked}.</p>
<button className="btn btn-primary" style={{ marginTop: 18 }} onClick={onSend}>Send code <Icon name="arrowR" size={16} /></button>
</div>
);
}
function ForgotCode({ onBack, onOk }: { onBack: () => void; onOk: () => void }) {
const [error, setError] = useState(false);
return (
<div>
<StepBack onClick={onBack} />
<h1>Enter the code</h1>
<p className="sub">Enter the 6-digit code we just sent.</p>
<div style={{ marginTop: 18 }}><OtpBoxes onComplete={(c) => (c === "000000" ? setError(true) : onOk())} error={error} /></div>
{error && <div style={{ marginTop: 12 }}><FlashNote tone="error">Incorrect code.</FlashNote></div>}
<div style={{ marginTop: 14 }}><ResendLink seconds={30} /></div>
<p className="demo-hint">Demo: any 6 digits work; 000000 fails.</p>
</div>
);
}
function ForgotReset({ onDone }: { onDone: () => void }) {
const [pw, setPw] = useState("");
const [confirm, setConfirm] = useState("");
const okMatch = pw.length > 0 && pw === confirm;
const strongEnough = pw.length >= 8;
return (
<div>
<h1>Create a new password</h1>
<p className="sub">Choose a strong password you haven&apos;t used before.</p>
<div className="field" style={{ marginTop: 16 }}>
<label className="label">New password</label>
<input className="input" type="password" value={pw} onChange={(e) => setPw(e.target.value)} placeholder="New password" />
</div>
<PasswordStrength pw={pw} />
<div className="field" style={{ marginTop: 14 }}>
<label className="label">Confirm password</label>
<input className="input" type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} placeholder="Re-enter password" />
</div>
{confirm && !okMatch && <p className="faint" style={{ fontSize: 12, color: "#fca5a5", marginTop: 6 }}>Passwords do not match.</p>}
<button className="btn btn-primary" style={{ marginTop: 16 }} disabled={!(okMatch && strongEnough)} onClick={onDone}>Update password</button>
</div>
);
}
/* ---- helpers ---- */
function CenterIcon({ name, tone }: { name: string; tone: "amber" | "green" | "blue" }) {
const bg = tone === "green" ? "rgba(52,211,153,.14)" : tone === "blue" ? "rgba(56,189,248,.14)" : "var(--primary-ghost)";
const fg = tone === "green" ? "#6ee7b7" : tone === "blue" ? "#7dd3fc" : "var(--primary-2)";
return (
<span style={{ width: 64, height: 64, margin: "4px auto 16px", display: "grid", placeItems: "center", borderRadius: 18, background: bg, color: fg }}>
<Icon name={name} size={28} />
</span>
);
}
function cap(s: string) { return s.charAt(0).toUpperCase() + s.slice(1); }
+104
View File
@@ -0,0 +1,104 @@
"use client";
import { GoogleMark, MicrosoftMark, AppleMark, Icon } from "./icons";
/** Left brand showcase panel for the split-screen auth layout. */
export function PortalAside() {
return (
<aside className="portal-aside">
<span className="scanline" />
<div className="aside-top">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img className="logo-img" src="/image/logo.png" alt="Lynkedup Pro Portal" />
<span className="eyebrow"><i className="pdot" /> Drone-powered</span>
</div>
<div>
<h2>
Roofing,
<br />
Revolutionized.
</h2>
<p className="lede">
AI-powered drone &amp; LiDAR roof inspections, instant estimates and
insurance-ready reports for homeowners and contractors.
</p>
<div className="stat-strip">
<div className="stat-chip"><b>5,847</b><span>Roofs scanned</span></div>
<div className="stat-chip"><b>15 min</b><span>Avg estimate</span></div>
<div className="stat-chip"><b>96%</b><span>Lead accuracy</span></div>
</div>
</div>
<div className="preview-card">
<div className="pc-row">
<span className="pc-av"></span>
<span className="pc-id">
<b>Roof scan #8842-A</b>
<span>LiDAR mapping · Verified</span>
</span>
<span className="pc-check"><Icon name="check" size={15} /></span>
</div>
<div className="pc-bar"><span style={{ width: "76%" }} /></div>
<div className="pc-meta"><span>Structure integrity</span><span>76%</span></div>
</div>
<div className="aside-trust">
<span><Icon name="lock" size={13} /> 256-bit SSL</span>
<span><Icon name="shield" size={13} /> Licensed &amp; Insured</span>
<span><Icon name="check" size={13} /> Drone-Powered</span>
</div>
</aside>
);
}
/** Compact logo shown above the form on small screens (aside is hidden). */
export function PanelBrand() {
return (
<div className="panel-brand">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/image/logo.png" alt="Lynkedup Pro Portal" />
</div>
);
}
/** The three OAuth provider buttons used on login + register. */
export function OAuthButtons({ onPick }: { onPick: (p: string) => void }) {
return (
<div className="col gap-3">
<button className="btn btn-oauth" onClick={() => onPick("Google")}>
<GoogleMark /> Continue with Google
</button>
<button className="btn btn-oauth" onClick={() => onPick("Microsoft")}>
<MicrosoftMark /> Continue with Microsoft
</button>
<button className="btn btn-oauth" onClick={() => onPick("Apple")}>
<AppleMark /> Continue with Apple
</button>
</div>
);
}
export function Divider({ label }: { label: string }) {
return <div className="divider">{label}</div>;
}
/** SSL / government / official-portal trust strip shown under the card. */
export function TrustBadges() {
return (
<div className="trust">
<span><Icon name="lock" size={14} /> 256-bit SSL</span>
<span><Icon name="shield" size={14} /> Licensed &amp; Insured</span>
<span><Icon name="check" size={14} /> Drone-Powered</span>
</div>
);
}
/** A small "← email@…" chip shown on inner steps. */
export function BackChip({ label, onBack }: { label: string; onBack: () => void }) {
return (
<button className="btn btn-sm" style={{ width: "auto", borderRadius: 999 }} onClick={onBack}>
<Icon name="chevL" size={15} /> {label}
</button>
);
}
+569
View File
@@ -0,0 +1,569 @@
/* =========================================================================
YEIDA Allottee Portal — dark + orange, split-screen creative theme
Scoped under .portal-root.
========================================================================= */
.portal-root {
--bg: #0b0c11;
--ink: #121319;
--surface: rgba(255, 255, 255, 0.04);
--surface-2: rgba(255, 255, 255, 0.07);
--line: rgba(255, 255, 255, 0.09);
--line-2: rgba(255, 255, 255, 0.15);
--text: #f3f4f8;
--muted: #a3a8b5;
--faint: #6c7280;
--primary: #f97316;
--primary-2: #fb923c;
--primary-ghost: rgba(249, 115, 22, 0.15);
--glow: rgba(249, 115, 22, 0.4);
--green: #34d399;
--radius: 26px;
--radius-sm: 13px;
--shadow: 0 50px 110px -34px rgba(0, 0, 0, 0.9);
--font-ui: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
--font-head: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
position: relative;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: var(--font-ui);
-webkit-font-smoothing: antialiased;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ---- animated background ---- */
.portal-root::before,
.portal-root::after {
content: "";
position: absolute;
border-radius: 50%;
filter: blur(110px);
opacity: 0.28;
z-index: 0;
pointer-events: none;
}
.portal-root::before {
width: 600px; height: 600px;
top: -260px; left: -180px;
background: radial-gradient(circle, #f97316, transparent 66%);
animation: float1 17s ease-in-out infinite;
}
.portal-root::after {
width: 540px; height: 540px;
bottom: -240px; right: -160px;
background: radial-gradient(circle, #33405e, transparent 66%);
animation: float2 21s ease-in-out infinite;
}
@keyframes float1 { 0%,100% { transform: translate(0,0); } 50% { transform: translate(60px, 50px); } }
@keyframes float2 { 0%,100% { transform: translate(0,0); } 50% { transform: translate(-50px, -40px); } }
.portal-grid {
position: absolute; inset: 0; z-index: 0;
background-image:
linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
background-size: 48px 48px;
mask-image: radial-gradient(circle at 50% 40%, #000 0%, transparent 78%);
pointer-events: none;
}
.portal-root *,
.portal-root *::before,
.portal-root *::after { box-sizing: border-box; }
.portal-root h1,
.portal-root h2,
.portal-root h3 { margin: 0; letter-spacing: -0.01em; }
/* ---- helpers ---- */
.row { display: flex; align-items: center; }
.col { display: flex; flex-direction: column; }
.between { justify-content: space-between; }
.grow { flex: 1; }
.gap-1 { gap: 4px; } .gap-2 { gap: 8px; } .gap-3 { gap: 12px; } .gap-4 { gap: 16px; }
.muted { color: var(--muted); }
.faint { color: var(--faint); }
/* ---- page wrap ---- */
.portal-main {
position: relative;
z-index: 1;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 36px 20px;
}
/* ---- split shell ---- */
.portal-split {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 1.08fr 1fr;
width: 100%;
max-width: 1200px;
min-height: 640px;
background: rgba(14, 11, 9, 0.66);
backdrop-filter: blur(22px) saturate(140%);
border: 1px solid var(--line);
border-radius: 30px;
overflow: hidden;
box-shadow: var(--shadow);
}
@media (max-width: 860px) {
.portal-split { grid-template-columns: 1fr; max-width: 500px; min-height: 0; }
.portal-aside { display: none !important; }
}
/* ---- brand showcase (left) ---- */
.portal-aside {
position: relative;
overflow: hidden;
padding: 48px 46px;
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: 26px;
background:
radial-gradient(460px 300px at 80% 4%, rgba(249, 115, 22, 0.18), transparent 60%),
linear-gradient(160deg, #141722, #171a25 46%, #1a1c28);
border-right: 1px solid var(--line);
}
/* decorative concentric rings */
.portal-aside::after {
content: "";
position: absolute;
right: -120px; bottom: -120px;
width: 360px; height: 360px;
border-radius: 50%;
background:
repeating-radial-gradient(circle, rgba(255, 255, 255, 0.05) 0 1px, transparent 1px 26px);
opacity: 0.7;
pointer-events: none;
}
.portal-aside .logo-img {
height: 58px; width: auto; object-fit: contain;
filter: drop-shadow(0 12px 26px rgba(255, 106, 0, 0.5));
align-self: flex-start;
}
.portal-aside h2 {
font-family: var(--font-head);
font-size: 35px;
line-height: 1.16;
font-weight: 800;
letter-spacing: -0.025em;
background: linear-gradient(118deg, #ffffff 55%, #ffd9b8 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
}
.portal-aside .lede { color: rgba(246, 242, 238, 0.78); font-size: 14.5px; line-height: 1.6; margin-top: 12px; }
.portal-aside .feat { display: flex; gap: 12px; align-items: flex-start; font-size: 14px; color: rgba(246, 242, 238, 0.9); }
.portal-aside .feat .tick {
width: 24px; height: 24px; flex: 0 0 24px;
border-radius: 8px; display: grid; place-items: center;
background: linear-gradient(135deg, var(--primary), var(--primary-2));
color: #2a1500;
}
.aside-trust { display: flex; gap: 16px; flex-wrap: wrap; margin-top: auto; padding-top: 8px; }
.aside-trust span { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: rgba(246, 242, 238, 0.6); font-weight: 600; }
/* ---- auth panel (right) ---- */
.portal-panel {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 48px 46px;
}
@media (max-width: 520px) { .portal-panel { padding: 34px 24px; } }
.panel-brand { display: none; text-align: center; margin-bottom: 22px; }
.panel-brand img { height: 50px; width: auto; filter: drop-shadow(0 10px 22px rgba(255, 106, 0, 0.5)); }
@media (max-width: 860px) { .panel-brand { display: block; } }
/* the flow renders a .card; flatten it inside the panel */
.portal-panel .card {
background: none;
border: 0;
box-shadow: none;
backdrop-filter: none;
padding: 0;
width: 100%;
max-width: 420px;
overflow: visible;
}
.portal-panel .card::before { display: none; }
.card h1 { font-size: 26px; font-weight: 700; }
.card .sub { color: var(--muted); font-size: 14.5px; margin-top: 9px; line-height: 1.55; }
/* ---- buttons ---- */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 10px;
width: 100%; height: 50px; padding: 0 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--line-2);
background: var(--surface);
color: var(--text);
font-family: inherit; font-size: 14.5px; font-weight: 600;
cursor: pointer;
transition: background 0.16s ease, border-color 0.16s ease, transform 0.06s ease, box-shadow 0.16s ease, filter 0.16s ease;
}
.btn:hover { background: var(--surface-2); }
.btn:active { transform: translateY(1px); }
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-primary {
background: linear-gradient(135deg, var(--primary), var(--primary-2));
border-color: transparent; color: #ffffff;
font-weight: 700;
box-shadow: 0 18px 40px -14px var(--glow);
}
.btn-primary:hover {
background: linear-gradient(135deg, var(--primary-2), var(--primary));
filter: brightness(1.08);
box-shadow: 0 20px 46px -14px var(--glow);
}
.btn-oauth { justify-content: flex-start; padding-left: 18px; background: rgba(255, 255, 255, 0.05); }
.btn-oauth:hover { background: rgba(255, 255, 255, 0.1); }
.btn-oauth .oauth-ico { width: 20px; height: 20px; flex: 0 0 20px; }
.btn-sm { height: 40px; font-size: 13.5px; width: auto; padding: 0 14px; }
/* ---- divider ---- */
.divider { display: flex; align-items: center; gap: 14px; color: var(--faint); font-size: 12.5px; font-weight: 500; margin: 20px 0; }
.divider::before, .divider::after { content: ""; height: 1px; flex: 1; background: var(--line); }
/* ---- inputs ---- */
.field { display: flex; flex-direction: column; gap: 7px; }
.label { font-size: 13px; font-weight: 600; color: var(--text); }
.input {
width: 100%; height: 50px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--line-2);
border-radius: var(--radius-sm);
color: var(--text);
font-family: inherit; font-size: 14.5px;
padding: 0 15px; outline: none;
transition: border-color 0.16s ease, box-shadow 0.16s ease, background 0.16s ease;
}
.input::placeholder { color: var(--faint); }
.input:focus { border-color: var(--primary); background: rgba(255, 106, 0, 0.08); box-shadow: 0 0 0 4px var(--primary-ghost); }
select.input { appearance: none; }
select.input option { background: var(--ink); color: var(--text); }
/* ---- registration steps ---- */
.steps { display: flex; align-items: center; gap: 0; margin: 2px 0 24px; }
.step { display: flex; align-items: center; gap: 9px; flex: 0 0 auto; }
.step-dot {
width: 28px; height: 28px; border-radius: 999px;
display: grid; place-items: center; font-size: 12.5px; font-weight: 700;
background: rgba(255, 255, 255, 0.06); color: var(--faint);
border: 1px solid var(--line-2);
}
.step.on .step-dot { background: linear-gradient(135deg, var(--primary), var(--primary-2)); color: #2a1500; border-color: transparent; box-shadow: 0 0 0 4px var(--primary-ghost); }
.step.done .step-dot { background: var(--green); color: #052; border-color: transparent; }
.step-label { font-size: 12.5px; font-weight: 600; color: var(--muted); }
.step.on .step-label { color: var(--text); }
.step-bar { flex: 1; height: 2px; background: var(--line-2); margin: 0 8px; min-width: 12px; }
.step-bar.fill { background: var(--green); }
@media (max-width: 520px) { .step-label { display: none; } }
/* ---- trust + links ---- */
.trust { display: flex; align-items: center; justify-content: center; gap: 18px; margin-top: 24px; flex-wrap: wrap; }
.trust span { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--faint); font-weight: 500; }
.link { color: var(--primary-2); font-weight: 600; cursor: pointer; text-decoration: none; background: none; border: 0; font-family: inherit; font-size: inherit; padding: 0; }
.link:hover { filter: brightness(1.12); text-decoration: underline; }
.foot-note { text-align: center; font-size: 13.5px; color: var(--muted); margin-top: 24px; }
/* ---- rotating gradient glow border on the shell ---- */
/* Animate the gradient ANGLE (not the element), so the rounded ring stays
aligned with the card edge while the glow sweeps around it. */
@property --pb-angle {
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
.portal-split::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1.5px;
background: conic-gradient(
from var(--pb-angle),
rgba(255, 255, 255, 0.05) 0deg,
rgba(249, 115, 22, 0.55) 44deg,
rgba(251, 146, 60, 0.28) 92deg,
rgba(255, 255, 255, 0.05) 150deg,
rgba(255, 255, 255, 0.05) 360deg
);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
animation: pbspin 6s linear infinite;
pointer-events: none;
z-index: 2;
}
@keyframes pbspin { to { --pb-angle: 360deg; } }
/* graceful fallback if @property is unsupported: a soft static gradient ring */
@supports not (background: conic-gradient(from var(--pb-angle), red, blue)) {
.portal-split::before { animation: none; background: linear-gradient(135deg, rgba(255, 106, 0, 0.7), transparent 60%); }
}
/* ---- eyebrow pill ---- */
.aside-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.eyebrow {
display: inline-flex; align-items: center; gap: 8px;
padding: 7px 13px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.14);
font-size: 11px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase;
color: rgba(246, 242, 238, 0.82);
white-space: nowrap;
}
.eyebrow .pdot {
width: 7px; height: 7px; border-radius: 999px;
background: var(--primary);
animation: pulse 2s ease-out infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(255, 106, 0, 0.6); }
70% { box-shadow: 0 0 0 7px rgba(255, 106, 0, 0); }
100% { box-shadow: 0 0 0 0 rgba(255, 106, 0, 0); }
}
/* ---- floating preview card in the showcase ---- */
.preview-card {
position: relative;
z-index: 1;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.03));
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 18px;
padding: 16px 18px;
backdrop-filter: blur(12px);
box-shadow: 0 24px 50px -22px rgba(0, 0, 0, 0.7);
animation: bob 6s ease-in-out infinite;
}
@keyframes bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-7px); } }
.pc-row { display: flex; align-items: center; gap: 12px; }
.pc-av {
width: 40px; height: 40px; border-radius: 12px; flex: 0 0 40px;
display: grid; place-items: center;
background: linear-gradient(135deg, var(--primary), var(--primary-2));
color: #fff; font-weight: 800; font-size: 14px;
}
.pc-id { display: flex; flex-direction: column; line-height: 1.25; }
.pc-id b { font-size: 14px; color: #fff; }
.pc-id span { font-size: 12px; color: rgba(246, 242, 238, 0.6); }
.pc-check {
margin-left: auto; width: 26px; height: 26px; border-radius: 999px;
display: grid; place-items: center; background: var(--green); color: #053; flex: 0 0 26px;
}
.pc-bar { height: 7px; border-radius: 999px; background: rgba(255, 255, 255, 0.12); overflow: hidden; margin: 14px 0 8px; }
.pc-bar > span { display: block; height: 100%; border-radius: 999px; background: linear-gradient(90deg, var(--primary), var(--primary-2)); }
.pc-meta { display: flex; justify-content: space-between; font-size: 12px; color: rgba(246, 242, 238, 0.7); font-weight: 600; }
/* ---- primary button sheen ---- */
.btn-primary { position: relative; overflow: hidden; }
.btn-primary::after {
content: "";
position: absolute; top: 0; left: -120%;
width: 60%; height: 100%;
background: linear-gradient(100deg, transparent, rgba(255, 255, 255, 0.35), transparent);
transform: skewX(-18deg);
transition: left 0.6s ease;
pointer-events: none;
}
.btn-primary:hover::after { left: 130%; }
/* ---- animation ---- */
.anim-in { animation: portalFade 0.55s cubic-bezier(0.2, 0.7, 0.2, 1) both; }
@keyframes portalFade { from { opacity: 0; transform: translateY(12px) scale(0.99); } to { opacity: 1; transform: none; } }
.anim-fade-up { animation: portalFade 0.45s ease both; }
.anim-fade-in { animation: fadeIn 0.4s ease both; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.anim-pop { animation: pop 0.28s cubic-bezier(0.2, 0.8, 0.2, 1) both; }
@keyframes pop { from { opacity: 0; transform: scale(0.96) translateY(6px); } to { opacity: 1; transform: none; } }
/* ---- kicker ---- */
.kicker { font-size: 12px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--primary-2); margin-bottom: 8px; }
/* ---- input with leading icon + eye toggle ---- */
.input-wrap { position: relative; }
.input-wrap > .input { padding-left: 42px; }
.input-ico { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); color: var(--faint); pointer-events: none; }
.input-eye { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); background: none; border: 0; color: var(--muted); cursor: pointer; display: grid; place-items: center; }
.input-eye:hover { color: var(--text); }
/* ---- spinner ---- */
.spinner { width: 18px; height: 18px; border-radius: 999px; border: 2px solid rgba(255, 255, 255, 0.25); border-top-color: var(--primary-2); display: inline-block; animation: spin 0.7s linear infinite; }
.spinner-lg { width: 34px; height: 34px; border-width: 3px; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ---- badge ---- */
.badge { display: inline-flex; align-items: center; gap: 5px; height: 22px; padding: 0 9px; border-radius: 999px; font-size: 11.5px; font-weight: 700; }
.badge-blue { background: rgba(56, 189, 248, 0.14); color: #bae6fd; }
.badge-green { background: rgba(52, 211, 153, 0.16); color: #a7f3d0; }
.badge-gray { background: rgba(255, 255, 255, 0.08); color: var(--muted); }
.badge-amber { background: var(--primary-ghost); color: #ffd9b3; }
/* ---- step back ---- */
.step-back { display: inline-flex; align-items: center; gap: 5px; background: none; border: 0; color: var(--muted); font-family: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer; padding: 4px 0; margin-bottom: 14px; }
.step-back:hover { color: var(--text); }
/* ---- account pill ---- */
.account-pill { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border: 1px solid var(--line-2); border-radius: 13px; background: rgba(255, 255, 255, 0.03); }
.ap-email { flex: 1; font-size: 13.5px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ap-change { font-size: 13px; }
/* ---- flash note ---- */
.flash { display: flex; gap: 10px; align-items: flex-start; padding: 11px 13px; border-radius: 11px; font-size: 13px; line-height: 1.45; border: 1px solid; }
.flash svg { flex: 0 0 auto; margin-top: 1px; }
.flash-info { background: rgba(56, 189, 248, 0.08); border-color: rgba(56, 189, 248, 0.3); color: #bae6fd; }
.flash-warn { background: var(--primary-ghost); border-color: rgba(255, 138, 58, 0.4); color: #ffd9b3; }
.flash-error { background: rgba(239, 68, 68, 0.1); border-color: rgba(239, 68, 68, 0.4); color: #fca5a5; }
.flash-success { background: rgba(52, 211, 153, 0.1); border-color: rgba(52, 211, 153, 0.4); color: #a7f3d0; }
/* ---- method row ---- */
.method-row { display: flex; align-items: center; gap: 14px; width: 100%; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--line-2); background: rgba(255, 255, 255, 0.03); cursor: pointer; text-align: left; font-family: inherit; color: var(--text); transition: background 0.15s ease, border-color 0.15s ease; }
.method-row:hover { background: var(--primary-ghost); border-color: rgba(255, 138, 58, 0.5); }
.mr-tile { width: 42px; height: 42px; flex: 0 0 42px; border-radius: 11px; display: grid; place-items: center; background: rgba(255, 255, 255, 0.06); color: var(--primary-2); }
.mr-text { flex: 1; display: flex; flex-direction: column; gap: 2px; }
.mr-title { font-size: 14.5px; font-weight: 650; display: flex; align-items: center; gap: 8px; }
.mr-badge { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: #ffd9b3; background: var(--primary-ghost); padding: 2px 7px; border-radius: 999px; }
.mr-sub { font-size: 12.5px; color: var(--muted); }
.method-row > svg:last-child { color: var(--faint); }
/* ---- remember device ---- */
.remember-card { display: flex; gap: 11px; align-items: flex-start; padding: 12px 14px; border: 1px solid var(--line-2); border-radius: 12px; background: rgba(255, 255, 255, 0.03); cursor: pointer; }
.remember-card input { margin-top: 2px; width: 16px; height: 16px; accent-color: var(--primary); }
/* ---- OTP ---- */
.otp-boxes { display: flex; gap: 9px; justify-content: space-between; }
.otp-box { width: 50px; height: 58px; text-align: center; font-size: 22px; font-weight: 700; background: rgba(255, 255, 255, 0.04); border: 1px solid var(--line-2); border-radius: 12px; color: var(--text); outline: none; transition: border-color 0.15s ease, box-shadow 0.15s ease; }
.otp-box:focus { border-color: var(--primary); box-shadow: 0 0 0 4px var(--primary-ghost); }
.otp-error .otp-box { border-color: rgba(239, 68, 68, 0.6); }
@media (max-width: 420px) { .otp-box { width: 42px; height: 52px; font-size: 19px; } }
/* ---- segmented control ---- */
.seg { display: inline-flex; padding: 4px; gap: 4px; background: rgba(255, 255, 255, 0.05); border: 1px solid var(--line); border-radius: 11px; }
.seg button { border: 0; background: transparent; color: var(--muted); font-family: inherit; font-weight: 600; font-size: 13px; padding: 7px 13px; border-radius: 8px; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
.seg button.on { background: var(--primary-ghost); color: #ffd9b3; }
/* ---- password strength ---- */
.strength { margin-top: 10px; }
.strength-bars { display: flex; gap: 5px; }
.strength-bars span { flex: 1; height: 5px; border-radius: 999px; background: rgba(255, 255, 255, 0.1); }
.strength-bars span.on { background: var(--primary); }
.strength-bars span.s1 { background: #ef4444; }
.strength-bars span.s2 { background: #f59e0b; }
.strength-bars span.s3 { background: #eab308; }
.strength-bars span.s4 { background: #34d399; }
.strength-label { font-size: 12.5px; color: var(--muted); margin-top: 7px; }
.s1-text { color: #fca5a5; } .s2-text { color: #fcd34d; } .s3-text { color: #fde047; } .s4-text { color: #6ee7b7; }
.checklist { list-style: none; padding: 0; margin: 10px 0 0; display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.checklist li { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--faint); }
.checklist li.ok { color: #6ee7b7; }
.checklist li svg { flex: 0 0 auto; }
/* ---- modal ---- */
.modal-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(4px); display: grid; place-items: center; z-index: 120; padding: 20px; }
.modal { width: 100%; max-width: 540px; max-height: 84vh; display: flex; flex-direction: column; background: var(--ink); border: 1px solid var(--line-2); border-radius: 20px; box-shadow: var(--shadow); overflow: hidden; }
.modal-head { display: flex; align-items: center; justify-content: space-between; padding: 18px 20px; border-bottom: 1px solid var(--line); }
.modal-body { padding: 20px; overflow-y: auto; }
.modal-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 20px; border-top: 1px solid var(--line); flex-wrap: wrap; }
.icon-btn { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 9px; border: 1px solid var(--line-2); background: transparent; color: var(--muted); cursor: pointer; }
.icon-btn:hover { background: rgba(255, 255, 255, 0.05); }
.review-note { display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px; color: var(--faint); }
.review-note.done { color: #6ee7b7; }
/* ---- cookie banner ---- */
.cookie { position: fixed; left: 16px; right: 16px; bottom: 16px; max-width: 760px; margin: 0 auto; display: flex; align-items: center; gap: 18px; background: var(--ink); border: 1px solid var(--line-2); border-radius: 16px; box-shadow: var(--shadow); padding: 16px 18px; z-index: 90; flex-wrap: wrap; }
@media (max-width: 560px) { .cookie-actions { width: 100%; } .cookie-actions .btn { flex: 1; } }
/* ---- connected banner / dashed block / verify channel ---- */
.conn-banner { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-radius: 12px; font-size: 13px; }
.conn-green { background: rgba(52, 211, 153, 0.1); border: 1px solid rgba(52, 211, 153, 0.35); color: #a7f3d0; }
.conn-blue { background: rgba(56, 189, 248, 0.1); border: 1px solid rgba(56, 189, 248, 0.35); color: #bae6fd; }
.dashed-block { border: 1px dashed var(--line-2); border-radius: 14px; padding: 16px; background: rgba(255, 255, 255, 0.02); }
.vchannel { border: 1px solid var(--line-2); border-radius: 16px; padding: 18px; }
.vchannel.verified { border-color: rgba(52, 211, 153, 0.5); background: rgba(52, 211, 153, 0.05); }
/* ---- interstitial ---- */
.interstitial { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 16px; padding: 30px 0; }
/* ---- avatar upload fab ---- */
.avatar-edit { position: relative; width: 72px; height: 72px; }
.avatar-edit .fab { position: absolute; right: -4px; bottom: -4px; width: 26px; height: 26px; border-radius: 999px; background: var(--primary); color: #fff; border: 2px solid var(--ink); display: grid; place-items: center; cursor: pointer; }
/* ---- country code select ---- */
.phone-row { display: flex; gap: 8px; }
.cc-select { width: 110px; flex: 0 0 110px; }
@media (max-width: 420px) { .cc-select { width: 96px; flex-basis: 96px; } }
/* ---- misc login/register bits ---- */
.tips { list-style: none; padding: 0; margin: 16px 0 0; display: flex; flex-direction: column; gap: 8px; }
.tips li { position: relative; padding-left: 18px; font-size: 13px; color: var(--muted); }
.tips li::before { content: ""; position: absolute; left: 4px; top: 8px; width: 5px; height: 5px; border-radius: 999px; background: var(--primary); }
.secure-footer { display: flex; justify-content: center; gap: 16px; flex-wrap: wrap; margin-top: 18px; }
.secure-footer span { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--faint); font-weight: 500; }
.demo-hint { font-size: 11.5px; color: var(--faint); text-align: center; margin-top: 16px; font-style: italic; }
.terms-scroll { max-height: 240px; overflow-y: auto; margin-top: 16px; padding: 16px; border: 1px solid var(--line-2); border-radius: 14px; background: rgba(255, 255, 255, 0.02); }
.remember-card.is-disabled { opacity: 0.55; }
.check-row { display: flex; gap: 11px; align-items: flex-start; font-size: 13.5px; color: var(--text); cursor: pointer; line-height: 1.45; }
.check-row input { margin-top: 2px; width: 16px; height: 16px; accent-color: var(--primary); flex: 0 0 16px; }
.hint-line { font-size: 12.5px; color: #fca5a5; margin-top: 10px; }
.addr-h { font-size: 14px; font-weight: 700; margin: 18px 0 10px; }
.addr-block { border: 1px solid var(--line); border-radius: 14px; padding: 16px; background: rgba(255, 255, 255, 0.02); }
.suggest { position: absolute; top: 100%; left: 0; right: 0; margin-top: 6px; background: var(--ink); border: 1px solid var(--line-2); border-radius: 12px; overflow: hidden; z-index: 20; box-shadow: var(--shadow); }
.suggest-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 11px 13px; background: none; border: 0; color: var(--text); font-family: inherit; font-size: 13.5px; text-align: left; cursor: pointer; }
.suggest-item:hover { background: var(--primary-ghost); }
/* ---- address step (creative) ---- */
.addr-section-head { display: flex; align-items: center; gap: 12px; margin: 22px 0 12px; }
.addr-tile { width: 38px; height: 38px; flex: 0 0 38px; border-radius: 11px; display: grid; place-items: center; }
.addr-tile-amber { background: var(--primary-ghost); color: var(--primary-2); }
.addr-tile-blue { background: rgba(56, 189, 248, 0.14); color: #7dd3fc; }
.country-field { position: relative; }
.country-flag { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); font-size: 18px; pointer-events: none; }
.addr-located { display: inline-flex; align-items: center; gap: 7px; margin-top: 12px; padding: 7px 12px; border-radius: 999px; background: rgba(52, 211, 153, 0.1); border: 1px solid rgba(52, 211, 153, 0.3); color: #a7f3d0; font-size: 12.5px; font-weight: 600; }
.si-text { display: flex; flex-direction: column; gap: 1px; }
.si-text b { font-size: 13.5px; font-weight: 600; }
.si-text > span { font-size: 12px; color: var(--muted); }
.suggest-item { align-items: flex-start; }
.suggest-item > svg { margin-top: 2px; color: var(--primary-2); }
.addr-block { position: relative; z-index: 0; }
.addr-block::before { content: ""; position: absolute; inset: 0; z-index: -1; border-radius: inherit; background-image: linear-gradient(rgba(255, 255, 255, 0.022) 1px, transparent 1px), linear-gradient(90deg, rgba(255, 255, 255, 0.022) 1px, transparent 1px); background-size: 22px 22px; mask-image: radial-gradient(circle at 85% 0%, #000, transparent 70%); pointer-events: none; }
.suggest { z-index: 40; }
/* ---- showcase: animated scan sweep + stat chips ---- */
.scanline {
position: absolute; left: 0; right: 0; top: 0; height: 150px; z-index: 0;
background: linear-gradient(to bottom, transparent, rgba(249, 115, 22, 0.13), transparent);
animation: scan 6.5s ease-in-out infinite;
pointer-events: none;
}
@keyframes scan { 0%, 100% { transform: translateY(-160px); } 50% { transform: translateY(620px); } }
.portal-aside > div { position: relative; z-index: 1; }
.stat-strip { display: flex; gap: 10px; margin-top: 24px; }
.stat-chip { flex: 1; padding: 12px; border-radius: 13px; background: rgba(255, 255, 255, 0.05); border: 1px solid var(--line); }
.stat-chip b { display: block; font-size: 19px; font-weight: 800; color: #fff; letter-spacing: -0.02em; }
.stat-chip span { font-size: 11px; color: rgba(246, 242, 238, 0.6); }
+620
View File
@@ -0,0 +1,620 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Icon, Avatar } from "./icons";
import {
StepBack, FlashNote, Badge, OtpBoxes, ResendLink, RememberDevice,
SocialButtons, startSocial, PasswordStrength, LegalModal,
} from "./bits";
import {
countryCodes, relationshipOptions, addressCountries,
TERMS, PRIVACY, passwordStrength, type AddrCountry,
} from "./data";
const STEPS = ["Account", "Property", "Verify", "Address"];
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function RegisterFlow() {
const router = useRouter();
const [step, setStep] = useState(0);
// shared form state
const [sso, setSso] = useState<{ provider: string; email: string } | null>(null);
const [email, setEmail] = useState("");
const [first, setFirst] = useState("");
const [last, setLast] = useState("");
const [cc, setCc] = useState("+1");
const [phone, setPhone] = useState("");
const [pw, setPw] = useState("");
const [relationship, setRelationship] = useState("Homeowner");
const [alloeNo, setAlloeNo] = useState("");
const [alloeFirst, setAlloeFirst] = useState("");
const [alloeLast, setAlloeLast] = useState("");
const [termsOk, setTermsOk] = useState(false);
const [privacyOk, setPrivacyOk] = useState(false);
// step 1
const [allotment, setAllotment] = useState("");
const [plotVerified, setPlotVerified] = useState(false);
// step 2
const [emailVerified, setEmailVerified] = useState(false);
const [phoneVerified, setPhoneVerified] = useState(false);
const isSelf = relationship === "Homeowner";
const country = countryCodes.find((c) => c.code === cc)!;
const phoneOk = phone.replace(/\D/g, "").length === country.digits;
const pwOk = sso ? true : passwordStrength(pw).score >= 3;
const alloeIdOk = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/.test(alloeNo);
const step0Valid =
first.trim() && last.trim() && EMAIL_RE.test(sso?.email || email) && phoneOk && pwOk &&
termsOk && privacyOk && (isSelf || (alloeIdOk && alloeFirst.trim() && alloeLast.trim()));
const step1Valid = /^LUP-\d{6}$/.test(allotment) || plotVerified;
const step2Valid = emailVerified && phoneVerified;
function finish() {
const profile = {
name: `${first} ${last}`.trim(),
initials: `${first[0] ?? ""}${last[0] ?? ""}`.toUpperCase(),
email: sso?.email || email,
isAllottee: isSelf,
allotteeNames: isSelf ? null : `${alloeFirst} ${alloeLast}`.trim(),
};
try { localStorage.setItem("lup_profile", JSON.stringify(profile)); } catch { /* ignore */ }
router.push("/dashboard");
}
return (
<div className="card anim-fade-up">
<Stepper current={step} />
{step === 0 && (
<StepAccount
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}
pw={pw} setPw={setPw}
relationship={relationship} setRelationship={setRelationship} isSelf={isSelf}
alloeNo={alloeNo} setAlloeNo={setAlloeNo} alloeIdOk={alloeIdOk}
alloeFirst={alloeFirst} setAlloeFirst={setAlloeFirst} alloeLast={alloeLast} setAlloeLast={setAlloeLast}
termsOk={termsOk} setTermsOk={setTermsOk} privacyOk={privacyOk} setPrivacyOk={setPrivacyOk}
valid={!!step0Valid}
onContinue={() => setStep(1)} toLogin={() => router.push("/portal/login")}
/>
)}
{step === 1 && (
<StepAllotment
allotment={allotment} setAllotment={setAllotment}
plotVerified={plotVerified} setPlotVerified={setPlotVerified}
valid={step1Valid} onBack={() => setStep(0)} onContinue={() => setStep(2)}
/>
)}
{step === 2 && (
<StepVerify
emailValue={sso?.email || email} cc={cc} phone={phone} country={country}
emailVerified={emailVerified} setEmailVerified={setEmailVerified}
phoneVerified={phoneVerified} setPhoneVerified={setPhoneVerified}
valid={step2Valid} onBack={() => setStep(1)} onContinue={() => setStep(3)}
/>
)}
{step === 3 && (
<StepAddress onBack={() => setStep(2)} onFinish={finish} />
)}
</div>
);
}
/* ====================== STEP 0 — ACCOUNT ====================== */
function StepAccount(p: {
sso: { provider: string; email: string } | null; setSso: (v: { provider: string; email: string } | null) => void;
email: string; setEmail: (v: string) => void;
first: string; setFirst: (v: string) => void; last: string; setLast: (v: string) => void;
cc: string; setCc: (v: string) => void; phone: string; setPhone: (v: string) => void; phoneOk: boolean; country: typeof countryCodes[number];
pw: string; setPw: (v: string) => void;
relationship: string; setRelationship: (v: string) => void; isSelf: boolean;
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;
}) {
const [phase, setPhase] = useState<"sso" | "profile">("sso");
const [modal, setModal] = useState<null | "terms" | "privacy">(null);
function pickSso(provider: "google" | "microsoft" | "apple") {
if (startSocial(provider)) return;
const mockEmail = `you@${provider === "microsoft" ? "outlook.com" : provider + ".com"}`;
p.setSso({ provider, email: mockEmail });
p.setEmail(mockEmail);
setPhase("profile");
}
if (phase === "sso") {
const valid = EMAIL_RE.test(p.email);
return (
<div>
<h1>Create your account</h1>
<p className="sub">Sign up with a provider or your email to get started.</p>
<div style={{ marginTop: 20 }}>
<SocialButtons onPick={pickSso} verb="Sign up" />
<div className="divider">or continue with email</div>
<form onSubmit={(e) => { e.preventDefault(); if (valid) { p.setSso(null); setPhase("profile"); } }}>
<div className="field">
<label className="label">Email address</label>
<div className="input-wrap">
<span className="input-ico"><Icon name="mail" size={17} /></span>
<input className="input" type="email" value={p.email} onChange={(e) => p.setEmail(e.target.value)} placeholder="you@example.com" autoFocus />
</div>
</div>
<button className="btn btn-primary" style={{ marginTop: 12 }} disabled={!valid}>Continue <Icon name="arrowR" size={16} /></button>
</form>
</div>
<p className="foot-note">Already registered? <button className="link" onClick={p.toLogin}>Sign in</button></p>
</div>
);
}
return (
<div>
<h1>Complete your profile</h1>
<p className="sub">Tell us a bit about you to set up your account.</p>
<div style={{ marginTop: 16 }}>
{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>
<div className="row gap-3" style={{ marginTop: 18, alignItems: "center" }}>
<div className="avatar-edit">
<Avatar initials={`${p.first[0] ?? "U"}${p.last[0] ?? ""}`.toUpperCase()} size={72} />
<span className="fab"><Icon name="camera" size={13} /></span>
</div>
<div className="col" style={{ gap: 2 }}>
<span style={{ fontWeight: 600, fontSize: 14 }}>Profile photo</span>
<span className="faint" style={{ fontSize: 12 }}>Optional now becomes mandatory later at KYC.</span>
</div>
</div>
<div className="row gap-3" style={{ marginTop: 16 }}>
<div className="field grow"><label className="label">First name</label><input className="input" value={p.first} onChange={(e) => p.setFirst(e.target.value)} placeholder="First name" /></div>
<div className="field grow"><label className="label">Last name</label><input className="input" value={p.last} onChange={(e) => p.setLast(e.target.value)} placeholder="Last name" /></div>
</div>
<div className="field" style={{ marginTop: 14 }}>
<label className="label">Mobile number</label>
<div className="phone-row">
<select className="input cc-select" value={p.cc} onChange={(e) => p.setCc(e.target.value)}>
{countryCodes.map((c) => <option key={c.code} value={c.code}>{c.flag} {c.code}</option>)}
</select>
<input className="input grow" inputMode="numeric" value={p.phone} onChange={(e) => p.setPhone(e.target.value.replace(/\D/g, ""))} placeholder={p.country.example} />
</div>
{p.phone && !p.phoneOk && <span style={{ fontSize: 12, color: "#fca5a5" }}>Enter a valid {p.country.digits}-digit number.</span>}
</div>
{p.sso ? (
<div style={{ marginTop: 14 }}><FlashNote tone="success">Signed up with {cap(p.sso.provider)} no password needed.</FlashNote></div>
) : (
<div className="field" style={{ marginTop: 14 }}>
<label className="label">Password</label>
<input className="input" type="password" value={p.pw} onChange={(e) => p.setPw(e.target.value)} placeholder="Create a strong password" />
<PasswordStrength pw={p.pw} />
</div>
)}
<div className="field" style={{ marginTop: 14 }}>
<label className="label">Your role</label>
<select className="input" value={p.relationship} onChange={(e) => p.setRelationship(e.target.value)}>
{relationshipOptions.map((r) => <option key={r}>{r}</option>)}
</select>
</div>
{p.isSelf ? (
<div style={{ marginTop: 12 }}><FlashNote tone="success">Homeowner account you own this property.</FlashNote></div>
) : (
<div style={{ marginTop: 12 }}>
<FlashNote tone="info">Registering on behalf of the property owner.</FlashNote>
<div className="dashed-block" style={{ marginTop: 12 }}>
<div className="row between" style={{ marginBottom: 12 }}>
<strong style={{ fontSize: 13.5 }}>Property Owner Details</strong>
{p.alloeNo && (p.alloeIdOk ? <Badge tone="green"><Icon name="check" size={12} /> Valid</Badge> : <Badge tone="gray">Checking</Badge>)}
</div>
<div className="field">
<label className="label">Owner / Property ID</label>
<input className="input" value={p.alloeNo} onChange={(e) => p.setAlloeNo(e.target.value.toUpperCase())} placeholder="Alphanumeric ID" />
</div>
<div className="row gap-3" style={{ marginTop: 12 }}>
<div className="field grow"><label className="label">Owner first name</label><input className="input" value={p.alloeFirst} onChange={(e) => p.setAlloeFirst(e.target.value)} /></div>
<div className="field grow"><label className="label">Owner last name</label><input className="input" value={p.alloeLast} onChange={(e) => p.setAlloeLast(e.target.value)} /></div>
</div>
</div>
</div>
)}
<div className="col gap-2" style={{ marginTop: 16 }}>
<label className="check-row">
<input type="checkbox" checked={p.termsOk} disabled={!reviewed.terms} onChange={(e) => p.setTermsOk(e.target.checked)} />
<span>I agree to the <button type="button" className="link" onClick={() => setModal("terms")}>Terms of Use</button>{!reviewed.terms && <span className="faint"> open &amp; scroll to enable</span>}</span>
</label>
<label className="check-row">
<input type="checkbox" checked={p.privacyOk} disabled={!reviewed.privacy} onChange={(e) => p.setPrivacyOk(e.target.checked)} />
<span>I agree to the <button type="button" className="link" onClick={() => setModal("privacy")}>Privacy Policy</button>{!reviewed.privacy && <span className="faint"> open &amp; scroll to enable</span>}</span>
</label>
</div>
{!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 &amp; 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); }} />}
</div>
);
}
// module-level reviewed flags (per mount lifetime) — enables the checkboxes after a doc is read
const reviewed = { terms: false, privacy: false };
/* ====================== STEP 1 — ALLOTMENT ====================== */
function StepAllotment(p: {
allotment: string; setAllotment: (v: string) => void;
plotVerified: boolean; setPlotVerified: (v: boolean) => void;
valid: boolean; onBack: () => void; onContinue: () => void;
}) {
const [mode, setMode] = useState<"have" | "forgot">("have");
const [houseNo, setHouseNo] = useState("");
const [street, setStreet] = useState("");
const [city, setCity] = useState("");
const [busy, setBusy] = useState(false);
const fmtOk = /^LUP-\d{6}$/.test(p.allotment);
function findPlot() {
if (!houseNo || !street || !city) return;
setBusy(true);
setTimeout(() => { setBusy(false); p.setPlotVerified(true); }, 800);
}
return (
<div>
<StepBack onClick={p.onBack} />
<h1>Identify the property</h1>
<p className="sub">Find the property by its job ID, or look it up by address.</p>
<div className="seg" style={{ margin: "16px 0" }}>
<button className={mode === "have" ? "on" : ""} onClick={() => setMode("have")}>I have a job ID</button>
<button className={mode === "forgot" ? "on" : ""} onClick={() => setMode("forgot")}>Find by address</button>
</div>
{mode === "have" ? (
<div className="field">
<label className="label">Property / Job ID</label>
<input className="input" value={p.allotment} onChange={(e) => p.setAllotment(e.target.value.toUpperCase())} placeholder="LUP-123456" />
<span className="faint" style={{ fontSize: 12 }}>Format: LUP- followed by 6 digits.</span>
</div>
) : (
<div>
<div className="row gap-3">
<div className="field" style={{ width: 120 }}><label className="label">House no.</label><input className="input" value={houseNo} onChange={(e) => setHouseNo(e.target.value)} placeholder="123" /></div>
<div className="field grow"><label className="label">Street</label><input className="input" value={street} onChange={(e) => setStreet(e.target.value)} placeholder="Street name" /></div>
</div>
<div className="field" style={{ marginTop: 12 }}><label className="label">City / ZIP</label><input className="input" value={city} onChange={(e) => setCity(e.target.value)} placeholder="City or ZIP code" /></div>
{!p.plotVerified ? (
<button className="btn" style={{ marginTop: 12 }} disabled={busy || !(houseNo && street && city)} onClick={findPlot}>
{busy ? "Searching…" : "Find my property"}
</button>
) : (
<div className="conn-banner conn-green" style={{ marginTop: 12 }}>
<Icon name="check" size={16} />
<span style={{ letterSpacing: 1 }}>LUP-****** · Verified · {houseNo} {street}, {city}</span>
</div>
)}
<p className="demo-hint">Sensitive details stay masked until verified (verify-to-reveal).</p>
</div>
)}
{mode === "have" && p.allotment && !fmtOk && <p className="hint-line">Enter a valid ID like LUP-123456.</p>}
<button className="btn btn-primary" style={{ marginTop: 16 }} disabled={!p.valid} onClick={p.onContinue}>Continue <Icon name="arrowR" size={16} /></button>
</div>
);
}
/* ====================== STEP 2 — VERIFY ====================== */
function StepVerify(p: {
emailValue: string; cc: string; phone: string; country: typeof countryCodes[number];
emailVerified: boolean; setEmailVerified: (v: boolean) => void;
phoneVerified: boolean; setPhoneVerified: (v: boolean) => void;
valid: boolean; onBack: () => void; onContinue: () => void;
}) {
const [remember, setRemember] = useState(false);
return (
<div>
<StepBack onClick={p.onBack} />
<h1>Verify email &amp; phone</h1>
<p className="sub">Confirm both so we can secure your account.</p>
<div className="col gap-4" style={{ marginTop: 16 }}>
<VerifyChannel kind="email" initial={p.emailValue} country={p.country} cc={p.cc} initialPhone={p.phone} verified={p.emailVerified} onVerified={() => p.setEmailVerified(true)} />
<VerifyChannel kind="phone" initial={p.emailValue} country={p.country} cc={p.cc} initialPhone={p.phone} verified={p.phoneVerified} onVerified={() => p.setPhoneVerified(true)} />
</div>
<div style={{ marginTop: 14 }}><RememberDevice checked={remember} onChange={setRemember} /></div>
<button className="btn btn-primary" style={{ marginTop: 16 }} disabled={!p.valid} onClick={p.onContinue}>Continue <Icon name="arrowR" size={16} /></button>
</div>
);
}
type SendState = "idle" | "sending" | "ok" | "invalid_email" | "mailbox_full" | "bounce_risk" | "invalid_mobile";
function VerifyChannel({ kind, initial, country, cc, initialPhone, verified, onVerified }: {
kind: "email" | "phone"; initial: string; country: typeof countryCodes[number]; cc: string; initialPhone: string;
verified: boolean; onVerified: () => void;
}) {
const [value, setValue] = useState(kind === "email" ? initial : initialPhone);
const [via, setVia] = useState<"primary" | "wa">("primary");
const [state, setState] = useState<SendState>("idle");
const [otpError, setOtpError] = useState(false);
const primaryLabel = kind === "email" ? "Email" : "SMS";
function send() {
setState("sending");
setTimeout(() => {
if (kind === "email") {
if (!EMAIL_RE.test(value)) return setState("invalid_email");
if (value.includes("full")) return setState("mailbox_full");
if (value.includes("bo")) return setState("bounce_risk");
return setState("ok");
} else {
if (value.replace(/\D/g, "").length !== country.digits) return setState("invalid_mobile");
return setState("ok");
}
}, 700);
}
if (verified) {
return (
<div className="vchannel verified">
<div className="row between">
<span className="row gap-2" style={{ fontWeight: 600, fontSize: 14 }}><Icon name={kind === "email" ? "mail" : "phone"} size={16} /> {kind === "email" ? "Email" : "Mobile"}</span>
<Badge tone="green"><Icon name="check" size={12} /> Verified</Badge>
</div>
<p className="faint" style={{ fontSize: 12.5, marginTop: 8 }}>{kind === "email" ? value : `${cc} ${value}`}</p>
</div>
);
}
return (
<div className="vchannel">
<div className="row between" style={{ marginBottom: 12 }}>
<span className="row gap-2" style={{ fontWeight: 600, fontSize: 14 }}><Icon name={kind === "email" ? "mail" : "phone"} size={16} /> {kind === "email" ? "Email address" : "Mobile number"}</span>
</div>
{kind === "email" ? (
<input className="input" value={value} onChange={(e) => { setValue(e.target.value); setState("idle"); }} placeholder="you@example.com" />
) : (
<div className="phone-row">
<span className="input cc-select" style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>{country.flag} {cc}</span>
<input className="input grow" inputMode="numeric" value={value} onChange={(e) => { setValue(e.target.value.replace(/\D/g, "")); setState("idle"); }} placeholder={country.example} />
</div>
)}
<div className="row between" style={{ marginTop: 12 }}>
<div className="seg">
<button className={via === "primary" ? "on" : ""} onClick={() => setVia("primary")}>{primaryLabel}</button>
<button className={via === "wa" ? "on" : ""} onClick={() => setVia("wa")}><Icon name="whatsapp" size={14} /> WhatsApp</button>
</div>
<button className="btn btn-sm" style={{ width: "auto" }} disabled={state === "sending" || state === "ok"} onClick={send}>
{state === "sending" ? "Sending…" : "Send code"}
</button>
</div>
{state === "ok" && <p className="faint" style={{ fontSize: 12, marginTop: 10 }}>OTP sent via {via === "wa" ? "WhatsApp" : primaryLabel}.</p>}
{state === "invalid_email" && <div style={{ marginTop: 10 }}><FlashNote tone="error">That email address looks invalid.</FlashNote></div>}
{state === "mailbox_full" && <div style={{ marginTop: 10 }}><FlashNote tone="warn">This mailbox appears full try another email.</FlashNote></div>}
{state === "bounce_risk" && <div style={{ marginTop: 10 }}><FlashNote tone="warn">High bounce risk for this address.</FlashNote></div>}
{state === "invalid_mobile" && <div style={{ marginTop: 10 }}><FlashNote tone="error">Enter a valid {country.digits}-digit mobile number.</FlashNote></div>}
{state === "ok" && (
<div style={{ marginTop: 14 }}>
<OtpBoxes onComplete={(c) => { if (c === "000000") setOtpError(true); else { setOtpError(false); onVerified(); } }} error={otpError} />
{otpError && <div style={{ marginTop: 10 }}><FlashNote tone="error">Incorrect code.</FlashNote></div>}
<div style={{ marginTop: 12 }}><ResendLink seconds={60} /></div>
</div>
)}
<p className="demo-hint">Demo: any 6 digits verify; 000000 fails. Email with full/bo shows send errors.</p>
</div>
);
}
/* ====================== STEP 3 — ADDRESS ====================== */
function StepAddress({ onBack, onFinish }: { onBack: () => void; onFinish: () => void }) {
const [sameAs, setSameAs] = useState(true);
return (
<div>
<StepBack onClick={onBack} />
<h1>Your addresses</h1>
<p className="sub">Add your registered address and where we should send mail.</p>
<AddrSectionHead icon="home" tone="amber" title="Registered address" sub="Where your property is registered" />
<AddressBlock idPrefix="reg" />
<label className="check-row" style={{ marginTop: 16 }}>
<input type="checkbox" checked={sameAs} onChange={(e) => setSameAs(e.target.checked)} />
<span>My mailing address is the same as my registered address</span>
</label>
{!sameAs && (
<>
<AddrSectionHead icon="mail" tone="blue" title="Mailing / correspondence address" sub="Where we send physical mail" />
<AddressBlock idPrefix="mail" />
</>
)}
<div style={{ marginTop: 14 }}><FlashNote tone="info">Your property address is on record; the mailing address is where we send physical mail.</FlashNote></div>
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={onFinish}>Finish &amp; Enter Portal <Icon name="arrowR" size={16} /></button>
</div>
);
}
const ADDR_FLAGS: Record<string, string> = { US: "🇺🇸", IN: "🇮🇳", GB: "🇬🇧", AE: "🇦🇪" };
function AddrSectionHead({ icon, title, sub, tone }: { icon: string; title: string; sub: string; tone: "amber" | "blue" }) {
return (
<div className="addr-section-head">
<span className={`addr-tile addr-tile-${tone}`}><Icon name={icon} size={18} /></span>
<div className="col" style={{ gap: 1 }}>
<strong style={{ fontSize: 14.5 }}>{title}</strong>
<span className="faint" style={{ fontSize: 12 }}>{sub}</span>
</div>
</div>
);
}
function AddressBlock({ idPrefix }: { idPrefix: string }) {
const [country, setCountry] = useState<AddrCountry>(addressCountries[0]);
const [pin, setPin] = useState("");
const [city, setCity] = useState("");
const [stateName, setStateName] = useState("");
const [localities, setLocalities] = useState<string[]>([]);
const [locality, setLocality] = useState("");
const [line1, setLine1] = useState("");
const [line2, setLine2] = useState("");
const [postal, setPostal] = useState("");
const [suggestions, setSuggestions] = useState<{ line1: string; line2: string; city: string; state: string; postal: string }[]>([]);
const debounce = useRef<ReturnType<typeof setTimeout> | null>(null);
// India: PIN auto-fill
useEffect(() => {
if (country.lookup !== "pincode" || pin.length !== 6) return;
let alive = true;
fetch(`/api/geo?pin=${pin}`).then((r) => r.json()).then((d) => {
if (!alive || !d.ok) return;
setCity(d.city); setStateName(d.state); setLocalities(d.localities ?? []);
}).catch(() => {});
return () => { alive = false; };
}, [pin, country]);
// Non-India: address search
function onSearch(v: string) {
setLine1(v);
if (country.lookup !== "search") return;
if (debounce.current) clearTimeout(debounce.current);
if (v.trim().length < 3) { setSuggestions([]); return; }
debounce.current = setTimeout(() => {
fetch(`/api/geo?addr=${encodeURIComponent(v)}&country=${country.code.toLowerCase()}`).then((r) => r.json()).then((d) => {
if (d.ok) setSuggestions(d.suggestions ?? []);
}).catch(() => {});
}, 400);
}
function applySuggestion(s: { line1: string; line2: string; city: string; state: string; postal: string }) {
setLine1(s.line1); setLine2(s.line2); setCity(s.city); setStateName(s.state); setPostal(s.postal); setSuggestions([]);
}
return (
<div className="addr-block">
<div className="field"><label className="label">Country / Region</label>
<div className="country-field">
<span className="country-flag">{ADDR_FLAGS[country.code] ?? "🌐"}</span>
<select className="input" style={{ paddingLeft: 44 }} value={country.code} onChange={(e) => { const c = addressCountries.find((x) => x.code === e.target.value)!; setCountry(c); setSuggestions([]); setCity(""); setStateName(""); setLocalities([]); }}>
{addressCountries.map((c) => <option key={c.code} value={c.code}>{c.name}</option>)}
</select>
</div>
</div>
{country.lookup === "pincode" ? (
<>
<div className="field" style={{ marginTop: 12 }}><label className="label">{country.postalLabel}</label>
<div className="input-wrap">
<span className="input-ico"><Icon name="mapPin" size={17} /></span>
<input className="input" inputMode="numeric" maxLength={6} value={pin} onChange={(e) => setPin(e.target.value.replace(/\D/g, ""))} placeholder="6-digit PIN" />
</div>
<span className="faint" style={{ fontSize: 12 }}>City &amp; state auto-fill from your PIN.</span>
</div>
{(city || stateName) && <div className="addr-located"><Icon name="check" size={13} /> Location detected · {city}{stateName ? `, ${stateName}` : ""}</div>}
<div className="row gap-3" style={{ marginTop: 12 }}>
<div className="field grow"><label className="label">City</label>
<div className="input-wrap"><span className="input-ico"><Icon name="building" size={16} /></span><input className="input" value={city} onChange={(e) => setCity(e.target.value)} /></div>
</div>
<div className="field grow"><label className="label">State</label>
<select className="input" value={stateName} onChange={(e) => setStateName(e.target.value)}>
<option value="">Select</option>{country.states.map((s) => <option key={s}>{s}</option>)}
</select>
</div>
</div>
{localities.length > 0 && (
<div className="field" style={{ marginTop: 12 }}><label className="label">Area / Locality</label>
<select className="input" value={locality} onChange={(e) => { setLocality(e.target.value); setLine2(e.target.value); }}>
<option value="">Select locality</option>{localities.map((l) => <option key={l}>{l}</option>)}
</select>
</div>
)}
<div className="field" style={{ marginTop: 12 }}><label className="label">House / Flat no.</label>
<div className="input-wrap"><span className="input-ico"><Icon name="home" size={16} /></span><input className="input" value={line1} onChange={(e) => setLine1(e.target.value)} placeholder="House / Flat / Building" /></div>
</div>
</>
) : (
<>
<div className="field" style={{ marginTop: 12, position: "relative" }}><label className="label">Address Line 1 House No./Street</label>
<div className="input-wrap">
<span className="input-ico"><Icon name="search" size={16} /></span>
<input className="input" value={line1} onChange={(e) => onSearch(e.target.value)} placeholder="Start typing your address…" />
</div>
{suggestions.length > 0 && (
<div className="suggest">
{suggestions.map((s, i) => (
<button key={i} className="suggest-item" onClick={() => applySuggestion(s)}>
<Icon name="mapPin" size={16} />
<span className="si-text"><b>{s.line1}</b><span>{s.city}{s.state ? `, ${s.state}` : ""} · {s.postal}</span></span>
</button>
))}
</div>
)}
</div>
{(city || stateName) && <div className="addr-located"><Icon name="check" size={13} /> Selected · {city}{stateName ? `, ${stateName}` : ""}</div>}
<div className="field" style={{ marginTop: 12 }}><label className="label">Address Line 2</label><input className="input" value={line2} onChange={(e) => setLine2(e.target.value)} /></div>
<div className="row gap-3" style={{ marginTop: 12 }}>
<div className="field grow"><label className="label">City</label>
<div className="input-wrap"><span className="input-ico"><Icon name="building" size={16} /></span><input className="input" value={city} onChange={(e) => setCity(e.target.value)} /></div>
</div>
{country.states.length > 0 && (
<div className="field grow"><label className="label">State / Region</label>
<select className="input" value={stateName} onChange={(e) => setStateName(e.target.value)}><option value="">Select</option>{country.states.map((s) => <option key={s}>{s}</option>)}</select>
</div>
)}
</div>
<div className="field" style={{ marginTop: 12 }}><label className="label">{country.postalLabel}</label>
<div className="input-wrap"><span className="input-ico"><Icon name="mail" size={16} /></span><input className="input" value={postal} onChange={(e) => setPostal(e.target.value)} maxLength={country.postalLen} placeholder={country.postalLabel} /></div>
</div>
</>
)}
</div>
);
}
/* ====================== stepper ====================== */
function Stepper({ current }: { current: number }) {
return (
<div className="steps">
{STEPS.map((label, i) => {
const done = i < current, on = i === current;
return (
<div key={label} style={{ display: "contents" }}>
<div className={`step ${on ? "on" : ""} ${done ? "done" : ""}`}>
<span className="step-dot">{done ? <Icon name="check" size={14} /> : i + 1}</span>
<span className="step-label">{label}</span>
</div>
{i < STEPS.length - 1 && <span className={`step-bar ${done ? "fill" : ""}`} />}
</div>
);
})}
</div>
);
}
function cap(s: string) { return s.charAt(0).toUpperCase() + s.slice(1); }