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