diff --git a/public/image/logo.png b/public/image/logo.png new file mode 100644 index 0000000..8452a63 Binary files /dev/null and b/public/image/logo.png differ diff --git a/src/app/api/geo/route.ts b/src/app/api/geo/route.ts new file mode 100644 index 0000000..2aef40d --- /dev/null +++ b/src/app/api/geo/route.ts @@ -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 = { + "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 = { + "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 = { + 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" }); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 6fcc55b..e3630f3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 ( -
- - {siteConfig.shortName} - -

- {siteConfig.name} -

-

- {siteConfig.description} -

- - Go to Dashboard → - -
- ); + redirect("/portal/login"); } diff --git a/src/app/portal/layout.tsx b/src/app/portal/layout.tsx new file mode 100644 index 0000000..c1b7e74 --- /dev/null +++ b/src/app/portal/layout.tsx @@ -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 ( + <> + + + +
{children}
+ + ); +} diff --git a/src/app/portal/login/page.tsx b/src/app/portal/login/page.tsx new file mode 100644 index 0000000..488b0dd --- /dev/null +++ b/src/app/portal/login/page.tsx @@ -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 ( +
+ +
+ +
+
+ + +
+
+
+ +
+ ); +} diff --git a/src/app/portal/register/page.tsx b/src/app/portal/register/page.tsx new file mode 100644 index 0000000..ff7f71c --- /dev/null +++ b/src/app/portal/register/page.tsx @@ -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 ( +
+ +
+ +
+
+ + +
+
+
+ +
+ ); +} diff --git a/src/components/portal/bits.tsx b/src/components/portal/bits.tsx new file mode 100644 index 0000000..9014cc1 --- /dev/null +++ b/src/components/portal/bits.tsx @@ -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 ; +} + +/* ---- badge ---- */ +export function Badge({ tone = "gray", children }: { tone?: "blue" | "green" | "gray" | "amber"; children: React.ReactNode }) { + return {children}; +} + +/* ---- back button ---- */ +export function StepBack({ onClick, label = "Back" }: { onClick: () => void; label?: string }) { + return ( + + ); +} + +/* ---- account pill ---- */ +export function AccountPill({ initials, email, onChange }: { initials: string; email: string; onChange: () => void }) { + return ( +
+ + {email} + +
+ ); +} + +/* ---- flash note ---- */ +export function FlashNote({ tone = "info", children }: { tone?: "info" | "warn" | "error" | "success"; children: React.ReactNode }) { + const icon = tone === "error" || tone === "warn" ? "alert" : tone === "success" ? "check" : "info"; + return ( +
+ + {children} +
+ ); +} + +/* ---- method row ---- */ +export function MethodRow({ + icon, title, sub, badge, onClick, +}: { icon: string; title: string; sub?: string; badge?: string; onClick: () => void }) { + return ( + + ); +} + +/* ---- remember device ---- */ +export function RememberDevice({ checked, onChange, note }: { checked: boolean; onChange: (v: boolean) => void; note?: string }) { + return ( + + ); +} + +/* ---- OTP boxes ---- */ +export function OtpBoxes({ + length = 6, onComplete, error, onChange, +}: { length?: number; onComplete?: (code: string) => void; error?: boolean; onChange?: (code: string) => void }) { + const [vals, setVals] = useState(Array(length).fill("")); + const refs = useRef>([]); + + function emit(next: string[]) { + setVals(next); + const code = next.join(""); + onChange?.(code); + if (code.length === length && !next.includes("")) onComplete?.(code); + } + function setAt(i: number, v: string) { + const d = v.replace(/\D/g, "").slice(-1); + const next = [...vals]; next[i] = d; emit(next); + if (d && i < length - 1) refs.current[i + 1]?.focus(); + } + function onPaste(e: React.ClipboardEvent) { + const text = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, length); + if (!text) return; + e.preventDefault(); + const next = Array(length).fill(""); + text.split("").forEach((c, i) => (next[i] = c)); + emit(next); + refs.current[Math.min(text.length, length - 1)]?.focus(); + } + return ( +
+ {vals.map((d, i) => ( + { refs.current[i] = el; }} + className="otp-box" + inputMode="numeric" + autoComplete="one-time-code" + maxLength={1} + value={d} + autoFocus={i === 0} + aria-label={`Digit ${i + 1}`} + onChange={(e) => setAt(i, e.target.value)} + onKeyDown={(e) => { if (e.key === "Backspace" && !vals[i] && i > 0) refs.current[i - 1]?.focus(); }} + /> + ))} +
+ ); +} + +/* ---- resend link with cooldown ---- */ +export function ResendLink({ seconds = 30, onResend }: { seconds?: number; onResend?: () => void }) { + const [left, setLeft] = useState(seconds); + useEffect(() => { + if (left <= 0) return; + const t = setTimeout(() => setLeft((l) => l - 1), 1000); + return () => clearTimeout(t); + }, [left]); + if (left > 0) return Resend code in {left}s; + return ( + + ); +} + +/* ---- social buttons (real OAuth or demo mode) ---- */ +/** Returns true if it redirected to a real provider; false if demo mode. */ +export function startSocial(provider: "google" | "microsoft" | "apple"): boolean { + const url = buildOAuthUrl(provider); + if (url) { window.location.href = url; return true; } + return false; +} +export function SocialButtons({ onPick, verb = "Continue" }: { onPick: (p: "google" | "microsoft" | "apple") => void; verb?: string }) { + return ( +
+ + + +
+ ); +} + +/* ---- password strength meter ---- */ +export function PasswordStrength({ pw }: { pw: string }) { + const s = passwordStrength(pw); + if (!pw) return null; + return ( +
+
+ {[0, 1, 2, 3].map((i) => ( + + ))} +
+
Password strength: {s.label}
+
    + {s.checks.map((c) => ( +
  • + {c.label} +
  • + ))} +
+
+ ); +} + +/* ---- legal modal (scroll-to-end to accept) ---- */ +export function LegalModal({ doc, onClose, onReviewed }: { doc: LegalDoc; onClose: () => void; onReviewed: () => void }) { + const [reviewed, setReviewed] = useState(false); + const bodyRef = useRef(null); + // If the content fits without scrolling, there's nothing to scroll — mark reviewed. + useEffect(() => { + const el = bodyRef.current; + if (el && el.scrollHeight <= el.clientHeight + 12) setReviewed(true); + }, []); + function onScroll(e: React.UIEvent) { + const el = e.currentTarget; + if (el.scrollTop + el.clientHeight >= el.scrollHeight - 10 && !reviewed) setReviewed(true); + } + return ( +
+
+
+
+

{doc.title}

+ {doc.updated} +
+ +
+
+ {doc.sections.map((s) => ( +
+

{s.h}

+

{s.p}

+
+ ))} +
+
+ + + {reviewed ? "Reviewed — you can now accept" : "Scroll to the end to mark as reviewed"} + + +
+
+
+ ); +} + +/* ---- cookie banner ---- */ +export function CookieBanner() { + const [show, setShow] = useState(false); + useEffect(() => { + try { if (!localStorage.getItem("lup_cookie")) setShow(true); } catch { setShow(true); } + }, []); + function choose(v: string) { + try { localStorage.setItem("lup_cookie", v); } catch { /* ignore */ } + setShow(false); + } + if (!show) return null; + return ( +
+
+

We value your privacy

+

+ We use essential cookies to sign you in securely, and optional cookies for analytics. See our{" "} + . +

+
+
+ + +
+
+ ); +} diff --git a/src/components/portal/data.ts b/src/components/portal/data.ts new file mode 100644 index 0000000..ee9899f --- /dev/null +++ b/src/components/portal/data.ts @@ -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()}`; +} diff --git a/src/components/portal/icons.tsx b/src/components/portal/icons.tsx new file mode 100644 index 0000000..9dd91c0 --- /dev/null +++ b/src/components/portal/icons.tsx @@ -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 ( + + + + + + + ); +} + +export function MicrosoftMark({ className = "oauth-ico" }: { className?: string }) { + return ( + + + + + + + ); +} + +export function AppleMark({ className = "oauth-ico" }: { className?: string }) { + return ( + + + + ); +} + +const LINE: Record = { + shield: , + lock: , + globe: , + check: , + arrowR: , + chevL: , + chevR: , + mail: , + phone: , + user: , + key: , + eye: , + eyeOff: , + alert: , + info: , + x: , + smartphone: , + totp: , + whatsapp: , + sms: , + mapPin: , + home: , + edit: , + camera: , + copy: , + clock: , + sparkle: , + building: , + search: , +}; + +export function Icon({ name, size = 18, strokeWidth = 1.7 }: { name: string; size?: number; strokeWidth?: number }) { + return ( + + {LINE[name] ?? LINE.info} + + ); +} + +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 ; + } + return ( + + {initials} + + ); +} + +export function BrandLogo({ size = 44 }: { size?: number }) { + return ( + + + + + + + ); +} diff --git a/src/components/portal/login-flow.tsx b/src/components/portal/login-flow.tsx new file mode 100644 index 0000000..c5748a7 --- /dev/null +++ b/src/components/portal/login-flow.tsx @@ -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("identify"); + const [email, setEmail] = useState(""); + const [account, setAccount] = useState(null); + const [provider, setProvider] = useState(""); + const [remember, setRemember] = useState(false); + const [flash, setFlash] = useState(""); + + /* ---- 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 ( +
+ {step === "identify" && router.push("/portal/register")} />} + + {step === "connecting" && ( +
+ +
+

{provider ? `Connecting to ${cap(provider)}…` : "Looking up your account…"}

+ {provider &&

Demo mode — no provider keys configured.

} +
+
+ )} + + {step === "notfound" && ( +
+
+ No account found for  {email} +
+

We couldn't find that account

+

No account is on file for this email. Try another email or register a new account.

+
+ + +
+
+ )} + + {step === "primary" && account && ( +
+

Hi {account.firstName}

+

Choose how you want to sign in.

+
+ replace("identify")} /> +
+
+ {account.hasPasskey && push("passkey")} />} + push("password")} /> + push("another")} /> +
+
+ )} + + {step === "passkey" && account && ( + replace("password")} onAnother={() => replace("another")} onSuccess={() => afterAuth("passkey")} onFail={() => { setFlash("Passkey was cancelled or timed out."); replace("error"); }} /> + )} + + {step === "password" && account && ( + push("fp_confirm")} onOtp={() => replace("otp")} onLocked={() => setFlash("This account is temporarily locked.")} onOk={() => afterAuth("password")} /> + )} + + {step === "otp" && account && ( + afterAuth("otp")} /> + )} + + {step === "another" && account && ( + push(s)} /> + )} + + {step === "totp" && account && ( + afterAuth("totp")} /> + )} + + {step === "push" && ( + afterAuth("push")} onCancel={() => { setFlash("Sign-in request cancelled."); replace("another"); }} /> + )} + + {step === "error" && ( +
+ +

Couldn't sign you in

+

{flash || "Something interrupted the sign-in."}

+
    +
  • Make sure you're using the correct device or account.
  • +
  • Check your internet connection and try again.
  • +
  • You can switch to another verification method.
  • +
+
+ + +
+
+ )} + + {step === "terms" && { try { localStorage.setItem("lup_terms", "1"); } catch { /* ignore */ } replace("success"); }} />} + + {step === "success" && ( + router.push("/dashboard")} /> + )} + + {step === "recovery" && ( +
+ + +

Account recovery

+

None of the available methods worked. Our team can help verify your identity and restore access.

+ Contact support at help@lynkeduppro.com with your property or account details. + +
+ )} + + {step === "fp_confirm" && account && ( + push("fp_code")} /> + )} + {step === "fp_code" && ( + push("fp_reset")} /> + )} + {step === "fp_reset" && ( + { setFlash("Password updated. Please sign in."); replace("password"); }} /> + )} +
+ ); +} + +/* ============================ 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 ( +
+
Welcome back
+

Sign in to LynkedUp

+

Drone inspections, AI estimates and insurance-ready reports — all in one place.

+ +
+ +
or continue with email
+ + {!showEmail ? ( + + ) : ( +
{ e.preventDefault(); onEmail(); }}> +
+ +
+ + setEmail(e.target.value)} placeholder="you@example.com" /> +
+
+ +
+ )} +
+ +

New homeowner or contractor?

+
+ 256-bit SSL + Licensed & Insured + Drone-Powered +
+

Demo: any email signs in; an email starting with “new” shows “not found”.

+
+ ); +} + +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 ( +
+ + +

Use your passkey

+

Verify it's you with your device's fingerprint, face or screen lock.

+ {waiting ? ( +

Waiting for your device…

+ ) : ( + + )} +
+ + +
+

Demo: passkey always succeeds here.

+
+ ); +} + +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 ( +
+ +

Enter your password

+

Signing in as {account.maskedEmail}

+ {flash &&
{flash}
} + {err === "locked" &&
Account temporarily locked after too many attempts. .
} +
+
+ +
+ + setPw(e.target.value)} placeholder="••••••••" /> + +
+
+ +
+
+ + +
+

Demo: any password works; type “wrong” to see the lockout. Password always needs 2-step next.

+
+ ); +} + +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 ( +
+ +

2-step verification

+

Enter the 6-digit code we sent you to finish signing in.

+
+ + + +
+

Code sent to {target}

+ + {error &&
Incorrect code. Please try again.
} +
+ + +
+ +

Demo: any 6 digits verify; “000000” shows an error.

+
+ ); +} + +function AnotherWay({ account, onBack, go }: { account: Account; onBack: () => void; go: (s: Step) => void }) { + const [more, setMore] = useState(false); + return ( +
+ +

Try another way

+

Choose a different way to verify it's you.

+
+ {account.hasPasskey && go("passkey")} />} + {account.hasPush && go("push")} />} + go("password")} /> + {account.hasTotp && go("totp")} />} + go("otp")} /> + {more && <> + go("otp")} /> + go("otp")} /> + } +
+ {!more + ? + : } +
+ ); +} + +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 ( +
+ +

Authenticator code

+

Open your authenticator app and enter the 6-digit code.

+
+ {!setupKey ? ( + { if (c === "000000") setError(true); else onVerified(); }} error={error} /> + ) : ( +
+ + setKey(e.target.value)} placeholder="xxxx-xxxx-xxxx" /> + +
+ )} +
+ {error &&
Incorrect code.
} + + +

Demo: any 6 digits verify; “000000” fails.

+
+ ); +} + +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 ( +
+ + +

{approved ? "Approved — signing you in…" : "Check your phone"}

+

{approved ? "Your sign-in was approved." : "We sent a notification to your mobile app. Approve it to continue."}

+
{approved ? : }
+ {!approved && } +

Demo: auto-approves after ~3 seconds.

+
+ ); +} + +function TermsGate({ onAccept }: { onAccept: () => void }) { + const [reviewed, setReviewed] = useState(false); + const [checked, setChecked] = useState(false); + const scrollRef = useRef(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) { + const el = e.currentTarget; + if (el.scrollTop + el.clientHeight >= el.scrollHeight - 12) setReviewed(true); + } + return ( +
+

Before you continue

+

Please review and accept the portal terms to finish signing in.

+
+ {["Authorised use", "Accuracy of records", "Data & privacy", "Security", "Acceptable conduct"].map((h, i) => ( +
+

{i + 1}. {h}

+

+ 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. +

+
+ ))} +

— End of terms —

+
+ + +
+ ); +} + +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 ( +
+ +

You're signed in

+

Welcome back to the Lynkedup Pro portal.

+
+ + {email || account?.name} +
+ {remember &&

We'll remember this device for 90 days.

} + +
+ ); +} + +/* ---- forgot password mini machine ---- */ +function ForgotConfirm({ masked, onBack, onSend }: { masked: string; onBack: () => void; onSend: () => void }) { + return ( +
+ +

Reset your password

+

We'll send a verification code to {masked}.

+ +
+ ); +} +function ForgotCode({ onBack, onOk }: { onBack: () => void; onOk: () => void }) { + const [error, setError] = useState(false); + return ( +
+ +

Enter the code

+

Enter the 6-digit code we just sent.

+
(c === "000000" ? setError(true) : onOk())} error={error} />
+ {error &&
Incorrect code.
} +
+

Demo: any 6 digits work; “000000” fails.

+
+ ); +} +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 ( +
+

Create a new password

+

Choose a strong password you haven't used before.

+
+ + setPw(e.target.value)} placeholder="New password" /> +
+ +
+ + setConfirm(e.target.value)} placeholder="Re-enter password" /> +
+ {confirm && !okMatch &&

Passwords do not match.

} + +
+ ); +} + +/* ---- 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 ( + + + + ); +} +function cap(s: string) { return s.charAt(0).toUpperCase() + s.slice(1); } diff --git a/src/components/portal/parts.tsx b/src/components/portal/parts.tsx new file mode 100644 index 0000000..5f8a09c --- /dev/null +++ b/src/components/portal/parts.tsx @@ -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 ( + + ); +} + +/** Compact logo shown above the form on small screens (aside is hidden). */ +export function PanelBrand() { + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Lynkedup Pro Portal +
+ ); +} + +/** The three OAuth provider buttons used on login + register. */ +export function OAuthButtons({ onPick }: { onPick: (p: string) => void }) { + return ( +
+ + + +
+ ); +} + +export function Divider({ label }: { label: string }) { + return
{label}
; +} + +/** SSL / government / official-portal trust strip shown under the card. */ +export function TrustBadges() { + return ( +
+ 256-bit SSL + Licensed & Insured + Drone-Powered +
+ ); +} + +/** A small "← email@…" chip shown on inner steps. */ +export function BackChip({ label, onBack }: { label: string; onBack: () => void }) { + return ( + + ); +} diff --git a/src/components/portal/portal.css b/src/components/portal/portal.css new file mode 100644 index 0000000..18cb607 --- /dev/null +++ b/src/components/portal/portal.css @@ -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: ""; + 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); } diff --git a/src/components/portal/register-flow.tsx b/src/components/portal/register-flow.tsx new file mode 100644 index 0000000..72a23a2 --- /dev/null +++ b/src/components/portal/register-flow.tsx @@ -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 ( +
+ + + {step === 0 && ( + setStep(1)} toLogin={() => router.push("/portal/login")} + /> + )} + + {step === 1 && ( + setStep(0)} onContinue={() => setStep(2)} + /> + )} + + {step === 2 && ( + setStep(1)} onContinue={() => setStep(3)} + /> + )} + + {step === 3 && ( + setStep(2)} onFinish={finish} /> + )} +
+ ); +} + +/* ====================== 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); + + 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 ( +
+

Create your account

+

Sign up with a provider or your email to get started.

+
+ +
or continue with email
+
{ e.preventDefault(); if (valid) { p.setSso(null); setPhase("profile"); } }}> +
+ +
+ + p.setEmail(e.target.value)} placeholder="you@example.com" autoFocus /> +
+
+ +
+
+

Already registered?

+
+ ); + } + + return ( +
+

Complete your profile

+

Tell us a bit about you to set up your account.

+ +
+ {p.sso ? ( +
Connected with {cap(p.sso.provider)} · {p.sso.email}
+ ) : ( +
Creating account for {p.email}
+ )} +
+ +
+
+ + +
+
+ Profile photo + Optional now — becomes mandatory later at KYC. +
+
+ +
+
p.setFirst(e.target.value)} placeholder="First name" />
+
p.setLast(e.target.value)} placeholder="Last name" />
+
+ +
+ +
+ + p.setPhone(e.target.value.replace(/\D/g, ""))} placeholder={p.country.example} /> +
+ {p.phone && !p.phoneOk && Enter a valid {p.country.digits}-digit number.} +
+ + {p.sso ? ( +
Signed up with {cap(p.sso.provider)} — no password needed.
+ ) : ( +
+ + p.setPw(e.target.value)} placeholder="Create a strong password" /> + +
+ )} + +
+ + +
+ + {p.isSelf ? ( +
Homeowner account — you own this property.
+ ) : ( +
+ Registering on behalf of the property owner. +
+
+ Property Owner Details + {p.alloeNo && (p.alloeIdOk ? Valid : Checking…)} +
+
+ + p.setAlloeNo(e.target.value.toUpperCase())} placeholder="Alphanumeric ID" /> +
+
+
p.setAlloeFirst(e.target.value)} />
+
p.setAlloeLast(e.target.value)} />
+
+
+
+ )} + +
+ + +
+ + {!p.valid &&

Fill all required fields and accept both documents to continue.

} + + + + {modal === "terms" && setModal(null)} onReviewed={() => { reviewed.terms = true; setModal(null); }} />} + {modal === "privacy" && setModal(null)} onReviewed={() => { reviewed.privacy = true; setModal(null); }} />} +
+ ); +} +// 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 ( +
+ +

Identify the property

+

Find the property by its job ID, or look it up by address.

+ +
+ + +
+ + {mode === "have" ? ( +
+ + p.setAllotment(e.target.value.toUpperCase())} placeholder="LUP-123456" /> + Format: LUP- followed by 6 digits. +
+ ) : ( +
+
+
setHouseNo(e.target.value)} placeholder="123" />
+
setStreet(e.target.value)} placeholder="Street name" />
+
+
setCity(e.target.value)} placeholder="City or ZIP code" />
+ {!p.plotVerified ? ( + + ) : ( +
+ + LUP-****** · Verified · {houseNo} {street}, {city} +
+ )} +

Sensitive details stay masked until verified (verify-to-reveal).

+
+ )} + + {mode === "have" && p.allotment && !fmtOk &&

Enter a valid ID like LUP-123456.

} + + +
+ ); +} + +/* ====================== 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 ( +
+ +

Verify email & phone

+

Confirm both so we can secure your account.

+ +
+ p.setEmailVerified(true)} /> + p.setPhoneVerified(true)} /> +
+ +
+ +
+ ); +} + +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("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 ( +
+
+ {kind === "email" ? "Email" : "Mobile"} + Verified +
+

{kind === "email" ? value : `${cc} ${value}`}

+
+ ); + } + + return ( +
+
+ {kind === "email" ? "Email address" : "Mobile number"} +
+ + {kind === "email" ? ( + { setValue(e.target.value); setState("idle"); }} placeholder="you@example.com" /> + ) : ( +
+ {country.flag} {cc} + { setValue(e.target.value.replace(/\D/g, "")); setState("idle"); }} placeholder={country.example} /> +
+ )} + +
+
+ + +
+ +
+ + {state === "ok" &&

OTP sent via {via === "wa" ? "WhatsApp" : primaryLabel}.

} + {state === "invalid_email" &&
That email address looks invalid.
} + {state === "mailbox_full" &&
This mailbox appears full — try another email.
} + {state === "bounce_risk" &&
High bounce risk for this address.
} + {state === "invalid_mobile" &&
Enter a valid {country.digits}-digit mobile number.
} + + {state === "ok" && ( +
+ { if (c === "000000") setOtpError(true); else { setOtpError(false); onVerified(); } }} error={otpError} /> + {otpError &&
Incorrect code.
} +
+
+ )} +

Demo: any 6 digits verify; “000000” fails. Email with “full”/“bo” shows send errors.

+
+ ); +} + +/* ====================== STEP 3 — ADDRESS ====================== */ +function StepAddress({ onBack, onFinish }: { onBack: () => void; onFinish: () => void }) { + const [sameAs, setSameAs] = useState(true); + return ( +
+ +

Your addresses

+

Add your registered address and where we should send mail.

+ + + + + + + {!sameAs && ( + <> + + + + )} + +
Your property address is on record; the mailing address is where we send physical mail.
+ + +
+ ); +} + +const ADDR_FLAGS: Record = { US: "🇺🇸", IN: "🇮🇳", GB: "🇬🇧", AE: "🇦🇪" }; + +function AddrSectionHead({ icon, title, sub, tone }: { icon: string; title: string; sub: string; tone: "amber" | "blue" }) { + return ( +
+ +
+ {title} + {sub} +
+
+ ); +} + +function AddressBlock({ idPrefix }: { idPrefix: string }) { + const [country, setCountry] = useState(addressCountries[0]); + const [pin, setPin] = useState(""); + const [city, setCity] = useState(""); + const [stateName, setStateName] = useState(""); + const [localities, setLocalities] = useState([]); + 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 | 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 ( +
+
+
+ {ADDR_FLAGS[country.code] ?? "🌐"} + +
+
+ + {country.lookup === "pincode" ? ( + <> +
+
+ + setPin(e.target.value.replace(/\D/g, ""))} placeholder="6-digit PIN" /> +
+ City & state auto-fill from your PIN. +
+ {(city || stateName) &&
Location detected · {city}{stateName ? `, ${stateName}` : ""}
} +
+
+
setCity(e.target.value)} />
+
+
+ +
+
+ {localities.length > 0 && ( +
+ +
+ )} +
+
setLine1(e.target.value)} placeholder="House / Flat / Building" />
+
+ + ) : ( + <> +
+
+ + onSearch(e.target.value)} placeholder="Start typing your address…" /> +
+ {suggestions.length > 0 && ( +
+ {suggestions.map((s, i) => ( + + ))} +
+ )} +
+ {(city || stateName) &&
Selected · {city}{stateName ? `, ${stateName}` : ""}
} +
setLine2(e.target.value)} />
+
+
+
setCity(e.target.value)} />
+
+ {country.states.length > 0 && ( +
+ +
+ )} +
+
+
setPostal(e.target.value)} maxLength={country.postalLen} placeholder={country.postalLabel} />
+
+ + )} +
+ ); +} + +/* ====================== stepper ====================== */ +function Stepper({ current }: { current: number }) { + return ( +
+ {STEPS.map((label, i) => { + const done = i < current, on = i === current; + return ( +
+
+ {done ? : i + 1} + {label} +
+ {i < STEPS.length - 1 && } +
+ ); + })} +
+ ); +} + +function cap(s: string) { return s.charAt(0).toUpperCase() + s.slice(1); }