"use client"; // ============================================================ // Profile — header card + 6 tabs: // Personal Info · KYC · Security · Notifications · Privacy · Devices // // Personal / KYC / Notifications / Privacy are served by the be-crm profile // module (via useProfileData → live data door, or the local mock when the Shell // isn't configured). Security & Devices are Shell-owned and stay on the mock. // ============================================================ import { useEffect, useRef, useState } from "react"; import { user, loginActivity, passwordStrength, } from "./account-data"; import { useProfileData, type ProfileData, type UiAccount, type UiKycSlot, type ContactKind, type KycKey, type NotifChannel, type UploadFile, } from "@/lib/profile-api"; import { Avatar, Btn, Field, Icon, Modal, OtpField, PageHead, Pill, SegTabs, StatusDot, Toggle, useToast, } from "./ui"; const TABS = [ { value: "personal", label: "Personal Info", icon: "user" }, { value: "kyc", label: "KYC", icon: "shield-check" }, { value: "security", label: "Security", icon: "lock" }, { value: "notifications", label: "Notifications", icon: "bell" }, { value: "privacy", label: "Privacy & Consent", icon: "privacy" }, { value: "devices", label: "Devices", icon: "devices" }, ]; /** Read a picked File into the base64 shape a data-door command carries. */ function readFileAsUpload(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const base64 = String(reader.result).split(",")[1] ?? ""; resolve({ fileName: file.name, contentType: file.type || "application/octet-stream", base64 }); }; reader.onerror = () => reject(reader.error ?? new Error("could not read file")); reader.readAsDataURL(file); }); } /** Turn a thrown data-door error into a short, readable toast message. */ function errText(e: unknown): string { const m = e instanceof Error ? e.message : String(e); return m.replace(/^\d+\s*[-:]\s*/, "").slice(0, 160) || "Something went wrong."; } export function Profile() { const p = useProfileData(); const [tab, setTab] = useState("personal"); return (
Account {p.account.status}} /> {p.error && (
Couldn't load your profile.{p.error}
)}
{tab === "personal" && } {tab === "kyc" && } {tab === "security" && } {tab === "notifications" && } {tab === "privacy" && } {tab === "devices" && }
); } /* ============================================================ */ /* Header card */ /* ============================================================ */ function ProfileHeaderCard({ account }: { account: UiAccount }) { const { push } = useToast(); const locale = [account.sector, account.pocket].filter(Boolean).join(" · ") || "Location on file"; return (
{account.name}
{account.role} · Member since {account.memberSince}
{locale} {account.category && {account.category}} {account.plot && House {account.plot}}
); } function HeroStat({ value, label, accent }: { value: string; label: string; accent?: string }) { return (
{value}
{label}
); } /* ============================================================ */ /* Tab: Personal Info — verify-to-reveal + change email/mobile */ /* ============================================================ */ function PersonalInfoTab({ p }: { p: ProfileData }) { const { push } = useToast(); const { account } = p; const verified = account.revealed; const [house, setHouse] = useState(""); const [err, setErr] = useState(""); const [busy, setBusy] = useState(false); const [change, setChange] = useState(null); async function verify() { if (!house.trim()) return; setBusy(true); setErr(""); try { const ok = await p.reveal(house.trim()); if (ok) push({ tone: "success", title: "Verified", desc: "Your full details are now visible and editable." }); else setErr("That doesn't match the house number on file."); } catch (e) { setErr(errText(e)); } finally { setBusy(false); } } const contactRows: { key: ContactKind; label: string; value: string }[] = [ { key: "email", label: "Email", value: account.email }, { key: "mobile", label: "Mobile", value: account.mobile }, { key: "whatsapp", label: "WhatsApp", value: account.whatsapp }, ]; return (

Contact details

{verified ? Revealed : Masked}
{!verified && (
Verify to reveal & edit Sensitive details are masked. Enter your house number to unmask and edit them.
{ setHouse(e.target.value); setErr(""); }} onKeyDown={(e) => e.key === "Enter" && verify()} /> {busy ? "Verifying…" : "Reveal"}
{err &&
{err}
}
)}
{contactRows.map((f) => (
{f.label}
{f.value} {verified && ( )}
))}
setChange("email")} disabled={!verified}>Change email setChange("mobile")} disabled={!verified}>Change mobile
{!verified &&

Reveal your details first to edit contact information.

}

Account & identity

{verified ? Revealed : Protected}
Full name
{account.name}
Role
{account.role}
{account.allotmentNo &&
Allotment no.
{account.allotmentNo}
}
Member since
{account.memberSince}
Account status
{account.status === "active" ? "Active" : "Suspended"}
PAN
{account.pan}{verified && via KYC}
Aadhaar
{account.aadhaar}{verified && via KYC}

PAN & Aadhaar are managed under the KYC tab and can't be edited here.

setChange(null)} />
); } const CONTACT_LABEL: Record = { email: "email", mobile: "mobile", whatsapp: "WhatsApp number" }; // OTP-to-registered-mobile flow for changing a contact field. The one-time code // is always sent to the registered mobile (never the new destination), then the // new value is persisted only after the code verifies. function ChangeContactModal({ p, kind, onClose }: { p: ProfileData; kind: null | ContactKind; onClose: () => void }) { const { push } = useToast(); const [step, setStep] = useState<"enter" | "otp">("enter"); const [val, setVal] = useState(""); const [otp, setOtp] = useState(""); const [challengeId, setChallengeId] = useState(""); const [busy, setBusy] = useState(false); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect if (kind) { setStep("enter"); setVal(""); setOtp(""); setChallengeId(""); setBusy(false); } }, [kind]); function close() { setStep("enter"); setVal(""); setOtp(""); onClose(); } const isEmail = kind === "email"; const label = kind ? CONTACT_LABEL[kind] : ""; async function sendCode() { if (!kind) return; setBusy(true); try { const id = await p.requestContactChangeOtp(kind, val.trim()); setChallengeId(id); setStep("otp"); push({ tone: "info", title: "Code sent", desc: "A one-time code was sent to your registered mobile." }); } catch (e) { push({ tone: "error", title: "Couldn't send code", desc: errText(e) }); } finally { setBusy(false); } } async function confirm() { if (!kind) return; setBusy(true); try { await p.changeContact(kind, val.trim(), challengeId, otp); push({ tone: "success", title: `${label[0].toUpperCase()}${label.slice(1)} updated`, desc: "Your change has been confirmed." }); close(); } catch (e) { push({ tone: "error", title: "Couldn't confirm", desc: errText(e) }); } finally { setBusy(false); } } return ( Cancel{busy ? "Sending…" : "Send code"} : <> setStep("enter")}>Back{busy ? "Confirming…" : "Confirm"} } > {step === "enter" ? ( setVal(e.target.value)} /> ) : (
)}
); } /* ============================================================ */ /* Tab: KYC — inline upload, ID≠address, photo mandatory, % */ /* ============================================================ */ function KycTab({ p }: { p: ProfileData }) { const { push } = useToast(); const { slots, docTypes, completionPct } = p.kyc; // Doc type chosen for a slot but not yet uploaded (be-crm only persists it on upload). const [pending, setPending] = useState>>({}); const docIdFor = (key: KycKey) => pending[key] ?? slots.find((s) => s.key === key)?.docId ?? null; const idDoc = docIdFor("id"); const addrDoc = docIdFor("address"); function options(key: KycKey) { const want = key === "id" ? ["id", "any"] : key === "address" ? ["address", "any"] : []; return docTypes.filter((d) => want.includes(d.use)); } function pickDoc(key: KycKey, docId: string) { if (key === "id" && docId === addrDoc) { push({ tone: "error", title: "Pick a different document", desc: "Identity and address proofs must be two different documents." }); return; } if (key === "address" && docId === idDoc) { push({ tone: "error", title: "Pick a different document", desc: "Address and identity proofs must be two different documents." }); return; } setPending((s) => ({ ...s, [key]: docId })); } async function upload(key: KycKey, file: File) { const docId = key === "photo" ? null : docIdFor(key); if (key !== "photo" && !docId) { push({ tone: "error", title: "Select a document type first" }); return; } try { await p.uploadKyc(key, docId, await readFileAsUpload(file)); setPending((s) => { const n = { ...s }; delete n[key]; return n; }); push({ tone: "success", title: "Uploaded", desc: "Your document is queued for review." }); } catch (e) { push({ tone: "error", title: "Upload failed", desc: errText(e) }); } } async function remove(key: KycKey) { try { await p.removeKyc(key); push({ tone: "info", title: "Removed" }); } catch (e) { push({ tone: "error", title: "Couldn't remove", desc: errText(e) }); } } const photoDone = (slots.find((s) => s.key === "photo")?.status ?? "missing") !== "missing"; const docLabel = (id: string | null) => docTypes.find((d) => d.id === id)?.label ?? "Not chosen"; return (

Verification documents

{completionPct}% complete
{slots.map((s) => ( {s.title} ))}
{!photoDone &&
Live photo is mandatory.KYC cannot be completed without a verified photo.
}
{slots.map((slot) => ( pickDoc(slot.key, d)} onUpload={(f) => upload(slot.key, f)} onRemove={() => remove(slot.key)} /> ))}

Rules

  • A live photo / selfie is mandatory.
  • Provide one ID and one address proof.
  • ID and address must be two different documents.
  • Accepted: PDF, JPG, PNG · size limits per document.
  • Review completes within ~4 business hours.
Current selection
Identity{docLabel(idDoc)}
Address{docLabel(addrDoc)}
); } function KycSlotRow({ slot, docId, docTypes, options, onPick, onUpload, onRemove }: { slot: UiKycSlot; docId: string | null; docTypes: { id: string; label: string; hint: string; formats: string; maxMb: number }[]; options: { id: string; label: string }[]; onPick: (d: string) => void; onUpload: (f: File) => void; onRemove: () => void; }) { const fileRef = useRef(null); const tone = slot.status === "verified" ? "green" : slot.status === "uploaded" ? "blue" : slot.status === "rejected" ? "red" : "muted"; const docMeta = docTypes.find((d) => d.id === docId); const accept = slot.key === "photo" ? "image/*" : "application/pdf,image/jpeg,image/png"; return (
{slot.title}{slot.required && *} {slot.status === "missing" ? "Missing" : slot.status === "uploaded" ? "Under review" : slot.status === "verified" ? "Verified" : "Rejected"}
{slot.key !== "photo" && ( )} {docMeta &&
{docMeta.hint ? `${docMeta.hint} · ` : ""}{docMeta.formats} · ≤ {docMeta.maxMb} MB
} {slot.status === "rejected" && slot.note &&
{slot.note}
} { const f = e.target.files?.[0]; if (f) onUpload(f); e.target.value = ""; }} /> {slot.fileName ? (
{slot.fileName} {slot.note && slot.status !== "rejected" && {slot.note}}
) : ( )}
); } /* ============================================================ */ /* Tab: Security (Shell-owned — local mock) */ /* ============================================================ */ function SecurityTab() { const { push } = useToast(); const [pw, setPw] = useState(""); const [twoFA, setTwoFA] = useState(false); const [authApp, setAuthApp] = useState(false); const [alerts, setAlerts] = useState(true); const [twoFaModal, setTwoFaModal] = useState(false); const strength = passwordStrength(pw); return (

Password

Updated 5 days ago
setPw(e.target.value)} /> {pw && (
{[0, 1, 2, 3].map((i) => )}
{strength.label}
)}
    {strength.checks.map((c) =>
  • {c.label}
  • )}
{ push({ tone: "success", title: "Password updated" }); setPw(""); }}>Update password

Two-factor & sign-in

{ if (v) setTwoFaModal(true); else { setTwoFA(false); setAuthApp(false); push({ tone: "info", title: "Two-factor disabled" }); } }} label="2FA" /> { setAuthApp(v); push({ tone: v ? "success" : "info", title: v ? "Authenticator linked" : "Authenticator removed" }); }} label="Authenticator" /> { setAlerts(v); push({ tone: "info", title: v ? "Login alerts on" : "Login alerts off" }); }} label="Login alerts" /> {!twoFA &&

Enable two-factor authentication to link an authenticator app.

}
setTwoFaModal(false)} onDone={() => { setTwoFA(true); setTwoFaModal(false); push({ tone: "success", title: "Two-factor enabled", desc: "Your account is now protected with 2FA." }); }} />
); } function TwoFaModal({ open, onClose, onDone }: { open: boolean; onClose: () => void; onDone: () => void }) { const { push } = useToast(); const [step, setStep] = useState<"password" | "otp">("password"); const [pw, setPw] = useState(""); const [otp, setOtp] = useState(""); function close() { setStep("password"); setPw(""); setOtp(""); onClose(); } return ( Cancel { setStep("otp"); push({ tone: "info", title: "Code sent", desc: "OTP sent to your registered mobile." }); }}>Continue : <> setStep("password")}>BackEnable 2FA} > {step === "password" ? ( setPw(e.target.value)} /> ) : (
Both password and OTP are required to turn 2FA on.
)}
); } function SettingRow({ icon, title, desc, children }: { icon: string; title: string; desc: string; children: React.ReactNode }) { return (
{title}
{desc}
{children}
); } /* ============================================================ */ /* Tab: Notifications */ /* ============================================================ */ function NotificationsTab({ p }: { p: ProfileData }) { const { push } = useToast(); const { prefs, timezone, timezoneOptions, windows, destinations } = p.notifications; const [addOpen, setAddOpen] = useState(false); const [verifyId, setVerifyId] = useState(null); const channels: { key: NotifChannel; label: string; icon: string }[] = [ { key: "email", label: "Email", icon: "mail" }, { key: "sms", label: "SMS", icon: "phone" }, { key: "whatsapp", label: "WhatsApp", icon: "chat" }, { key: "push", label: "Push", icon: "bell" }, ]; async function toggle(cat: (typeof prefs)[number], ch: NotifChannel) { if (cat.locked) { push({ tone: "info", title: "Required notifications", desc: "Security alerts can't be turned off." }); return; } try { await p.setPreference(cat.id, ch, !cat[ch]); } catch (e) { push({ tone: "error", title: "Couldn't save", desc: errText(e) }); } } async function toggleWindow(w: (typeof windows)[number]) { try { await p.setContactWindow(w.label, { startTime: w.startTime, endTime: w.endTime, enabled: !w.enabled }); } catch (e) { push({ tone: "error", title: "Couldn't save", desc: errText(e) }); } } async function changeTz(tz: string) { try { await p.setTimezone(tz); } catch (e) { push({ tone: "error", title: "Couldn't save", desc: errText(e) }); } } return (

Per-channel matrix

Choose how each category reaches you
Category {channels.map((c) => {c.label})}
{prefs.map((cat) => (
{cat.label}{cat.locked && } {cat.desc} {channels.map((c) => ( toggle(cat, c.key)} label={`${cat.label} ${c.label}`} /> ))}
))}

Contact-time window

{windows.map((t) => ( ))}

Callbacks & proactive messages only arrive inside enabled windows, in your timezone.

Destinations

{destinations.length === 0 &&

No extra destinations yet.

} {destinations.map((d) => (
{d.label}{d.primary && Primary}
{d.value}
{d.verified ? Verified : setVerifyId(d.id)}>Verify}
))}
setAddOpen(true)}>Add destination
setAddOpen(false)} /> setVerifyId(null)} />
); } function AddDestinationModal({ p, open, onClose }: { p: ProfileData; open: boolean; onClose: () => void }) { const { push } = useToast(); const [channel, setChannel] = useState<"email" | "sms" | "whatsapp">("email"); const [label, setLabel] = useState(""); const [value, setValue] = useState(""); const [busy, setBusy] = useState(false); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect if (open) { setChannel("email"); setLabel(""); setValue(""); setBusy(false); } }, [open]); async function save() { setBusy(true); try { await p.addDestination(channel, label.trim() || channel, value.trim()); push({ tone: "success", title: "Destination added", desc: "Verify it to start receiving messages there." }); onClose(); } catch (e) { push({ tone: "error", title: "Couldn't add", desc: errText(e) }); } finally { setBusy(false); } } const isEmail = channel === "email"; return ( Cancel{busy ? "Adding…" : "Add destination"}} > setLabel(e.target.value)} /> setValue(e.target.value)} /> ); } function VerifyDestinationModal({ p, id, onClose }: { p: ProfileData; id: string | null; onClose: () => void }) { const { push } = useToast(); const [otp, setOtp] = useState(""); const [challengeId, setChallengeId] = useState(""); const [busy, setBusy] = useState(false); const [sent, setSent] = useState(false); useEffect(() => { if (!id) return; let cancelled = false; // eslint-disable-next-line react-hooks/set-state-in-effect setOtp(""); setChallengeId(""); setSent(false); setBusy(true); p.requestDestinationOtp(id) .then((cid) => { if (!cancelled) { setChallengeId(cid); setSent(true); } }) .catch((e) => { if (!cancelled) push({ tone: "error", title: "Couldn't send code", desc: errText(e) }); }) .finally(() => { if (!cancelled) setBusy(false); }); return () => { cancelled = true; }; }, [id, p, push]); async function confirm() { if (!id) return; setBusy(true); try { await p.verifyDestination(id, challengeId, otp); push({ tone: "success", title: "Destination verified" }); onClose(); } catch (e) { push({ tone: "error", title: "Couldn't verify", desc: errText(e) }); } finally { setBusy(false); } } return ( Cancel{busy ? "Verifying…" : "Verify"}} >
{sent ? "A code was sent to your registered mobile." : "Sending a code…"}
); } /* ============================================================ */ /* Tab: Privacy & Consent */ /* ============================================================ */ function PrivacyTab({ p }: { p: ProfileData }) { const { push } = useToast(); async function toggle(gid: string, iid: string, locked: boolean, granted: boolean) { if (locked) { push({ tone: "info", title: "Required consent", desc: "Statutory consents can't be withdrawn while active." }); return; } try { await p.setConsent(gid, iid, !granted); } catch (e) { push({ tone: "error", title: "Couldn't save", desc: errText(e) }); } } return (
{p.consent.map((g) => { const required = g.items.some((it) => it.locked); return (

{g.title}

{required ? Required : Optional}
{g.items.map((it) => (
{it.label}{it.locked && }
{it.desc &&
{it.desc}
}
toggle(g.id, it.id, it.locked, it.granted)} label={it.label} />
))}
); })}
); } /* ============================================================ */ /* Tab: Devices (Shell-owned — local mock) */ /* ============================================================ */ function DevicesTab() { const { push } = useToast(); const [devices, setDevices] = useState(loginActivity); function signOut(id: string) { setDevices((d) => d.filter((x) => x.id !== id)); push({ tone: "success", title: "Signed out", desc: "That device no longer has access." }); } return (

Connected devices

{ setDevices((d) => d.filter((x) => x.current)); push({ tone: "success", title: "Other devices signed out" }); }}>Sign out all others
{devices.filter((d) => d.event !== "password-change").map((d) => (
{d.device}{d.current && This device}{!d.trusted && New}
{d.browser} · {d.os}
{d.location} · {d.ip} · {d.lastActive}
{!d.current && }
))}

Recent activity

{loginActivity.map((a) => (
{labelForEvent(a.event)}
{a.device} · {a.location}
{a.lastActive}
))}
); } function labelForEvent(ev: string) { switch (ev) { case "password-change": return "Password changed"; case "new-device": return "New device signed in"; case "2fa-enabled": return "Two-factor enabled"; case "sign-out": return "Signed out"; default: return "Successful sign-in"; } }