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