From f634312b8396640cc6a2b1343a09c5ed369eda66 Mon Sep 17 00:00:00 2001 From: tanweer919 Date: Wed, 15 Jul 2026 18:57:39 +0530 Subject: [PATCH] feat(profile): wire Profile to the be-crm profile data door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New src/lib/profile-api.ts (useProfileData) mirrors team-api: live (crm.account/ kyc/notifications/consent) or local mock, one interface. profile.tsx consumes it — Personal (verify-to-reveal + change contact OTP), KYC (real file upload/remove), Notifications (matrix, timezone, windows, add/verify destination), Privacy (consent). Security + Devices stay on the mock (Shell-owned). --- src/components/dashboard/profile.tsx | 470 +++++++++++++++++---------- src/lib/profile-api.ts | 373 +++++++++++++++++++++ 2 files changed, 678 insertions(+), 165 deletions(-) create mode 100644 src/lib/profile-api.ts diff --git a/src/components/dashboard/profile.tsx b/src/components/dashboard/profile.tsx index ea0f19c..7ec265a 100644 --- a/src/components/dashboard/profile.tsx +++ b/src/components/dashboard/profile.tsx @@ -3,15 +3,20 @@ // ============================================================ // 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, useMemo, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { - user, kycDocTypes, kyc as kycSeed, contactPrefs, contactDestinations, - contactTimezones, contactTimes, loginActivity, consentGroups, - passwordStrength, maskEmail, maskPhone, - type KycSlot, type NotifCategory, type ConsentGroup, + 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, @@ -26,7 +31,27 @@ const TABS = [ { 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 ( @@ -36,19 +61,26 @@ export function Profile() { title="Profile" subtitle="Manage your identity, verification, security and preferences." icon="user" - actions={ Account active} + actions={ Account {p.account.status}} /> - + {p.error && ( +
+ +
Couldn't load your profile.{p.error}
+
+ )} + +
- {tab === "personal" && } - {tab === "kyc" && } + {tab === "personal" && } + {tab === "kyc" && } {tab === "security" && } - {tab === "notifications" && } - {tab === "privacy" && } + {tab === "notifications" && } + {tab === "privacy" && } {tab === "devices" && }
@@ -59,35 +91,36 @@ export function Profile() { /* Header card */ /* ============================================================ */ -function ProfileHeaderCard() { +function ProfileHeaderCard({ account }: { account: UiAccount }) { const { push } = useToast(); + const locale = [account.sector, account.pocket].filter(Boolean).join(" · ") || "Location on file"; return (
- +
- {user.name} + {account.name}
-
{user.role} · Member since {user.memberSince}
+
{account.role} · Member since {account.memberSince}
- {user.sector} · {user.pocket} - {user.category} - House {user.plot} + {locale} + {account.category && {account.category}} + {account.plot && House {account.plot}}
- - - + + +
); @@ -105,31 +138,30 @@ function HeroStat({ value, label, accent }: { value: string; label: string; acce /* Tab: Personal Info — verify-to-reveal + change email/mobile */ /* ============================================================ */ -type ContactKind = "email" | "mobile" | "whatsapp"; - -function PersonalInfoTab() { +function PersonalInfoTab({ p }: { p: ProfileData }) { const { push } = useToast(); - const [verified, setVerified] = useState(false); - const [plot, setPlot] = useState(""); + 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); - // editable contact values (live-updating after OTP confirmation) - const [contacts, setContacts] = useState({ email: user.email.full, mobile: user.mobile.full, whatsapp: user.whatsapp.full }); - - function verify() { - if (plot.trim() === user.plot) { setVerified(true); setErr(""); 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 (hint: it's ${user.plot}).`); + 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); } } - function mask(kind: ContactKind, full: string) { - return kind === "email" ? maskEmail(full) : maskPhone(full); - } - - const contactRows: { key: ContactKind; label: string; full: string; masked: string }[] = [ - { key: "email", label: "Email", full: contacts.email, masked: mask("email", contacts.email) }, - { key: "mobile", label: "Mobile", full: contacts.mobile, masked: mask("mobile", contacts.mobile) }, - { key: "whatsapp", label: "WhatsApp", full: contacts.whatsapp, masked: mask("whatsapp", contacts.whatsapp) }, + 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 ( @@ -150,10 +182,10 @@ function PersonalInfoTab() { Sensitive details are masked. Enter your house number to unmask and edit them.
- { setPlot(e.target.value.replace(/\D/g, "")); setErr(""); }} + { setHouse(e.target.value); setErr(""); }} onKeyDown={(e) => e.key === "Enter" && verify()} /> - Reveal + {busy ? "Verifying…" : "Reveal"}
{err &&
{err}
} @@ -164,7 +196,7 @@ function PersonalInfoTab() {
{f.label}
- {verified ? f.full : f.masked} + {f.value} {verified && ( )} @@ -188,64 +220,82 @@ function PersonalInfoTab() { : Protected}
-
Full name
{user.name}
-
Role
{user.role}
-
Member since
{user.memberSince}
-
Account status
Active
+
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
-
{verified ? user.pan.full : user.pan.masked}{verified && via KYC}
+
{account.pan}{verified && via KYC}
Aadhaar
-
{verified ? user.aadhaar.full : user.aadhaar.masked}{verified && via KYC}
+
{account.aadhaar}{verified && via KYC}

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

- setChange(null)} - onSave={(kind, value) => setContacts((c) => ({ ...c, [kind]: value }))} - /> + setChange(null)} /> ); } const CONTACT_LABEL: Record = { email: "email", mobile: "mobile", whatsapp: "WhatsApp number" }; -// OTP-to-registered-mobile flow for changing a contact field. On confirm the -// new value is saved back so the Personal Info card updates live. -function ChangeContactModal({ kind, current, onClose, onSave }: { - kind: null | ContactKind; current: string; onClose: () => void; onSave: (kind: ContactKind, value: string) => void; -}) { +// 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); - // prefill with the current value whenever a new field is opened useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect - if (kind) { setStep("enter"); setVal(current); setOtp(""); } - }, [kind, current]); + 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 { setStep("otp"); push({ tone: "info", title: "Code sent", desc: `OTP sent to your registered mobile ${user.mobile.masked}.` }); }}>Send code - : <> setStep("enter")}>Back { if (kind) onSave(kind, val.trim()); push({ tone: "success", title: `${label[0].toUpperCase()}${label.slice(1)} updated`, desc: "Your change has been confirmed." }); close(); }}>Confirm + ? <>Cancel{busy ? "Sending…" : "Send code"} + : <> setStep("enter")}>Back{busy ? "Confirming…" : "Confirm"} } > {step === "enter" ? ( @@ -255,7 +305,7 @@ function ChangeContactModal({ kind, current, onClose, onSave }: { ) : (
- +
)}
@@ -266,54 +316,52 @@ function ChangeContactModal({ kind, current, onClose, onSave }: { /* Tab: KYC — inline upload, ID≠address, photo mandatory, % */ /* ============================================================ */ -function KycTab() { +function KycTab({ p }: { p: ProfileData }) { const { push } = useToast(); - const [slots, setSlots] = useState(kycSeed.slots); + 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 idDoc = slots.find((s) => s.key === "id")?.docId ?? null; - const addrDoc = slots.find((s) => s.key === "address")?.docId ?? null; + const docIdFor = (key: KycKey) => pending[key] ?? slots.find((s) => s.key === key)?.docId ?? null; + const idDoc = docIdFor("id"); + const addrDoc = docIdFor("address"); - const completion = useMemo(() => { - const done = slots.filter((s) => s.status === "verified" || s.status === "uploaded").length; - return Math.round((done / slots.length) * 100); - }, [slots]); - - function options(key: KycSlot["key"]) { + function options(key: KycKey) { const want = key === "id" ? ["id", "any"] : key === "address" ? ["address", "any"] : []; - return kycDocTypes.filter((d) => want.includes(d.use)); + return docTypes.filter((d) => want.includes(d.use)); } - - function pickDoc(key: KycSlot["key"], docId: string) { - // enforce two-different-documents rule between ID and address + 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; } - setSlots((s) => s.map((x) => x.key === key ? { ...x, docId } : x)); + 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) }); } } - function upload(key: KycSlot["key"]) { - const slot = slots.find((s) => s.key === key)!; - if (key !== "photo" && !slot.docId) { push({ tone: "error", title: "Select a document type first" }); return; } - const name = key === "photo" ? "selfie.jpg" : `${slot.docId}_upload.pdf`; - setSlots((s) => s.map((x) => x.key === key ? { ...x, fileName: name, status: "uploaded", note: "Under review" } : x)); - push({ tone: "success", title: "Uploaded", desc: "Your document is queued for review." }); - } - - function remove(key: KycSlot["key"]) { - setSlots((s) => s.map((x) => x.key === key ? { ...x, fileName: null, docId: key === "photo" ? null : x.docId, status: "missing", note: undefined } : x)); - } - - const photoDone = slots.find((s) => s.key === "photo")!.status !== "missing"; + 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

- {completion}% complete + {completionPct}% complete
-
+
{slots.map((s) => ( @@ -327,7 +375,8 @@ function KycTab() {
{slots.map((slot) => ( - pickDoc(slot.key, d)} onUpload={() => upload(slot.key)} onRemove={() => remove(slot.key)} /> + pickDoc(slot.key, d)} onUpload={(f) => upload(slot.key, f)} onRemove={() => remove(slot.key)} /> ))}
@@ -343,19 +392,22 @@ function KycTab() {
Current selection
-
Identity{kycDocTypes.find((d) => d.id === idDoc)?.label ?? "Not chosen"}
-
Address{kycDocTypes.find((d) => d.id === addrDoc)?.label ?? "Not chosen"}
+
Identity{docLabel(idDoc)}
+
Address{docLabel(addrDoc)}
); } -function KycSlotRow({ slot, options, onPick, onUpload, onRemove }: { - slot: KycSlot; options: typeof kycDocTypes; onPick: (d: string) => void; onUpload: () => void; onRemove: () => void; +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 = kycDocTypes.find((d) => d.id === slot.docId); + const docMeta = docTypes.find((d) => d.id === docId); + const accept = slot.key === "photo" ? "image/*" : "application/pdf,image/jpeg,image/png"; return (
@@ -366,23 +418,27 @@ function KycSlotRow({ slot, options, onPick, onUpload, onRemove }: {
{slot.key !== "photo" && ( - onPick(e.target.value)}> {options.map((o) => )} )} - {docMeta &&
{docMeta.hint} · {docMeta.formats} · ≤ {docMeta.maxMb} MB
} + {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.note}} + {slot.note && slot.status !== "rejected" && {slot.note}}
) : ( - )} @@ -392,7 +448,7 @@ function KycSlotRow({ slot, options, onPick, onUpload, onRemove }: { } /* ============================================================ */ -/* Tab: Security */ +/* Tab: Security (Shell-owned — local mock) */ /* ============================================================ */ function SecurityTab() { @@ -478,24 +534,29 @@ function SettingRow({ icon, title, desc, children }: { icon: string; title: stri /* Tab: Notifications */ /* ============================================================ */ -function NotificationsTab() { +function NotificationsTab({ p }: { p: ProfileData }) { const { push } = useToast(); - const [prefs, setPrefs] = useState(contactPrefs); - const [tz, setTz] = useState(contactTimezones[0]); - const [times, setTimes] = useState(contactTimes); - const channels: { key: keyof NotifCategory; label: string; icon: string }[] = [ + 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" }, ]; - function toggle(catId: string, ch: keyof NotifCategory) { - setPrefs((p) => p.map((c) => { - if (c.id !== catId) return c; - if (c.locked) { push({ tone: "info", title: "Required notifications", desc: "Security alerts can't be turned off." }); return c; } - return { ...c, [ch]: !c[ch] } as NotifCategory; - })); + 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 ( @@ -515,7 +576,7 @@ function NotificationsTab() { {channels.map((c) => ( - toggle(cat.id, c.key)} label={`${cat.label} ${c.label}`} /> + toggle(cat, c.key)} label={`${cat.label} ${c.label}`} /> ))}
@@ -527,13 +588,13 @@ function NotificationsTab() {

Contact-time window

- changeTz(e.target.value)}> + {timezoneOptions.map((t) => )}
- {times.map((t) => ( -
+ + 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() { +function PrivacyTab({ p }: { p: ProfileData }) { const { push } = useToast(); - const [groups, setGroups] = useState(consentGroups); - function toggle(gid: string, iid: string) { - setGroups((gs) => gs.map((g) => g.id !== gid ? g : { - ...g, - items: g.items.map((it) => { - if (it.id !== iid) return it; - if (it.locked) { push({ tone: "info", title: "Required consent", desc: "Statutory consents can't be withdrawn while active." }); return it; } - return { ...it, granted: !it.granted }; - }), - })); + 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 (
- {groups.map((g) => ( -
-
-

{g.title}

- {g.id === "marketing" && Optional} - {g.id === "essential" && Required} -
-
- {g.items.map((it) => ( -
-
-
{it.label}{it.locked && }
-
{it.desc}
+ {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} />
- toggle(g.id, it.id)} label={it.label} /> -
- ))} -
- {g.id === "marketing" && groups.find((x) => x.id === "marketing")!.items.find((i) => i.id === "promos")!.granted && ( -
-
Promotional sub-preferences
- {["Product offers", "Surveys & feedback", "Partner news"].map((s, i) => ( - ))}
- )} -
- ))} +
+ ); + })}
); } /* ============================================================ */ -/* Tab: Devices */ +/* Tab: Devices (Shell-owned — local mock) */ /* ============================================================ */ function DevicesTab() { diff --git a/src/lib/profile-api.ts b/src/lib/profile-api.ts new file mode 100644 index 0000000..946c587 --- /dev/null +++ b/src/lib/profile-api.ts @@ -0,0 +1,373 @@ +"use client"; + +// Profile data layer. Serves EITHER the local mock (when the Shell isn't configured +// — the polished demo keeps working) OR the live be-crm data door, behind one +// interface so the Profile screens are mode-agnostic. Mirrors team-api.ts. +// +// Live contract (be-crm profile module): +// query crm.account.read -> AccountReadDTO +// cmd crm.account.revealField { houseNumber } -> { revealed } +// cmd crm.account.update { …fields } -> AccountReadDTO +// cmd crm.account.requestContactChangeOtp { channel, newValue } -> { challengeId } +// cmd crm.account.changeContact { channel, newValue, challengeId, otp } +// query crm.kyc.list -> KycListDTO +// cmd crm.kyc.upload { slot, docTypeId?, file } +// cmd crm.kyc.remove { slot } +// query crm.notifications.get -> NotificationsGetDTO +// cmd crm.notifications.setPreference/setTimezone/setContactWindow +// cmd crm.notifications.addDestination/verifyDestination +// query crm.consent.list -> ConsentGroupDTO[] +// cmd crm.consent.set { groupId, itemId, granted } +// +// Security (password / 2FA / login alerts) and Devices are Shell-owned and are +// intentionally absent here — those tabs stay on the local mock. + +import { useCallback, useMemo, useState } from "react"; +import { useAppShell, useAuth, useQuery } from "@abe-kap/appshell-sdk/react"; +import { isShellConfigured } from "./appshell"; +import { + user as seedUser, kyc as seedKyc, kycDocTypes as seedDocTypes, + contactPrefs as seedPrefs, contactDestinations as seedDests, + contactTimezones as seedTz, contactTimes as seedWindows, consentGroups as seedConsent, +} from "@/components/dashboard/account-data"; + +/* ---- UI-facing shapes ---------------------------------------------------- */ + +export type ContactKind = "email" | "mobile" | "whatsapp"; +export type KycKey = "photo" | "id" | "address"; +export type KycStatus = "missing" | "uploaded" | "verified" | "rejected"; +export type NotifChannel = "email" | "sms" | "whatsapp" | "push"; +export type DestChannel = "email" | "sms" | "whatsapp"; + +export interface UiAccount { + principalId: string; + name: string; role: string; initials: string; avatarGradient: string; + memberSince: string; status: "active" | "suspended"; + allotmentNo: string | null; plot: string | null; sector: string | null; + pocket: string | null; category: string | null; size: string | null; + isAllottee: boolean; relationship: string | null; allotteeName: string | null; + // Display values — masked unless `revealed`. + email: string; mobile: string; whatsapp: string; pan: string; aadhaar: string; + revealed: boolean; kycCompletionPct: number; +} + +export interface UiKycSlot { + key: KycKey; title: string; required: boolean; + docId: string | null; fileName: string | null; status: KycStatus; note?: string; +} +export interface UiDocType { id: string; label: string; use: string; hint: string; formats: string; maxMb: number } +export interface UiKyc { slots: UiKycSlot[]; docTypes: UiDocType[]; completionPct: number } + +export interface UiNotifCategory { id: string; label: string; desc: string; locked: boolean; email: boolean; sms: boolean; whatsapp: boolean; push: boolean } +export interface UiWindow { id: string; label: string; range: string; startTime: string; endTime: string; enabled: boolean } +export interface UiDestination { id: string; channel: DestChannel; label: string; value: string; verified: boolean; primary: boolean } +export interface UiNotifications { prefs: UiNotifCategory[]; timezone: string; timezoneOptions: string[]; windows: UiWindow[]; destinations: UiDestination[] } + +export interface UiConsentItem { id: string; label: string; desc: string; granted: boolean; locked: boolean } +export interface UiConsentGroup { id: string; title: string; items: UiConsentItem[] } + +export interface UploadFile { fileName: string; contentType: string; base64: string } + +export interface ProfileData { + live: boolean; loading: boolean; error: string | null; + account: UiAccount; + kyc: UiKyc; + notifications: UiNotifications; + consent: UiConsentGroup[]; + // Account + reveal: (houseNumber: string) => Promise; + requestContactChangeOtp: (channel: ContactKind, newValue: string) => Promise; + changeContact: (channel: ContactKind, newValue: string, challengeId: string, otp: string) => Promise; + // KYC + uploadKyc: (slot: KycKey, docTypeId: string | null, file: UploadFile) => Promise; + removeKyc: (slot: KycKey) => Promise; + // Notifications + setPreference: (categoryId: string, channel: NotifChannel, value: boolean) => Promise; + setTimezone: (timezone: string) => Promise; + setContactWindow: (label: string, patch: { startTime: string; endTime: string; enabled: boolean }) => Promise; + addDestination: (channel: DestChannel, label: string, value: string) => Promise; + requestDestinationOtp: (id: string) => Promise; + verifyDestination: (id: string, challengeId: string, otp: string) => Promise; + // Consent + setConsent: (groupId: string, itemId: string, granted: boolean) => Promise; + refetch: () => void; +} + +/* ---- UI copy + static presentation maps ---------------------------------- */ + +const SLOT_META: Record = { + photo: { title: "Live Photo / Selfie" }, + id: { title: "Identity Proof" }, + address: { title: "Address Proof" }, +}; +const MIME_LABEL: Record = { "application/pdf": "PDF", "image/jpeg": "JPG", "image/png": "PNG" }; +const friendlyFormats = (mimes: string[]) => mimes.map((m) => MIME_LABEL[m] ?? m).join(", "); +const DOC_HINT: Record = { + aadhaar: "Front & back, masked first 8 digits", pan: "Clear photo of the front", + passport: "Photo page, must be valid", voter_id: "Front & back", driving_license: "Front & back, not expired", + utility_bill: "Electricity / water, < 3 months old", bank_statement: "First page with address, < 3 months", + rent_agreement: "All pages, registered copy", +}; + +const CATEGORY_DESC: Record = { + security: "New device, password and 2FA changes", + account: "Profile changes and account activity", + payments: "Invoices, payment reminders and receipts", + meetings: "Upcoming visits and rescheduling", + documents: "Document status and re-verification requests", + promotions: "Promotions, surveys and newsletters", +}; +const CONSENT_DESC: Record = { + terms_of_service: "Required to operate your account.", + kyc_verification: "Process KYC documents to meet regulatory obligations.", + marketing_email: "Offers, product news and updates by email.", + marketing_sms: "Offers and reminders by SMS/WhatsApp.", + partner_sharing: "Allow vetted partner brokers to process your data.", +}; + +// The four standard contact-time windows the UI offers. Live windows are these +// definitions overlaid with whatever be-crm has stored (times/enabled by label). +const WINDOW_DEFS: { label: string; startTime: string; endTime: string }[] = [ + { label: "Morning", startTime: "08:00", endTime: "12:00" }, + { label: "Afternoon", startTime: "12:00", endTime: "16:00" }, + { label: "Evening", startTime: "16:00", endTime: "20:00" }, + { label: "Night", startTime: "20:00", endTime: "22:00" }, +]; + +const GRADIENTS = [ + "linear-gradient(135deg,#fda913,#fd6d13)", "linear-gradient(135deg,#6366f1,#8b5cf6)", + "linear-gradient(135deg,#06b6d4,#3b82f6)", "linear-gradient(135deg,#10b981,#059669)", + "linear-gradient(135deg,#f43f5e,#ec4899)", "linear-gradient(135deg,#8b5cf6,#d946ef)", +]; +const gradientFor = (id: string) => GRADIENTS[[...id].reduce((a, c) => a + c.charCodeAt(0), 0) % GRADIENTS.length]; +const initialsOf = (name: string) => + name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?"; + +/* ---- be-crm DTO types ---------------------------------------------------- */ + +interface AccountReadDTO { + principalId: string; + allotmentNo: string | null; plot: string | null; sector: string | null; pocket: string | null; + category: string | null; size: string | null; isAllottee: boolean; + relationship: string | null; allotteeName: string | null; + email: string | null; mobile: string | null; whatsapp: string | null; + pan: string | null; aadhaar: string | null; + revealed: boolean; memberSince: string; status: "active" | "suspended"; kycCompletionPct: number; +} +interface KycSlotDTO { slot: KycKey; docTypeId: string | null; fileName: string | null; status: KycStatus; rejectReason: string | null; uploadedAt: string | null } +interface KycListDTO { slots: KycSlotDTO[]; docTypes: { id: string; label: string; use: string; formats: string[]; maxMb: number }[]; completionPct: number } +interface PrefDTO { categoryId: string; label: string; locked: boolean; email: boolean; sms: boolean; whatsapp: boolean; push: boolean } +interface WindowDTO { id: string; label: string; startTime: string; endTime: string; enabled: boolean } +interface DestDTO { id: string; channel: DestChannel; label: string | null; value: string; verified: boolean; isPrimary: boolean } +interface NotificationsGetDTO { preferences: PrefDTO[]; timezone: string; timezoneOptions: string[]; windows: WindowDTO[]; destinations: DestDTO[] } +interface ConsentItemDTO { itemId: string; label: string; statutory: boolean; granted: boolean } +interface ConsentGroupDTO { groupId: string; label: string; items: ConsentItemDTO[] } + +/* ---- shared derivations -------------------------------------------------- */ + +function kycNote(status: KycStatus, rejectReason: string | null): string | undefined { + if (rejectReason) return rejectReason; + if (status === "uploaded") return "Under review"; + if (status === "verified") return "Verified"; + return undefined; +} +function mergeWindows(stored: WindowDTO[]): UiWindow[] { + const byLabel = new Map(stored.map((w) => [w.label, w])); + return WINDOW_DEFS.map((def) => { + const w = byLabel.get(def.label); + const startTime = w?.startTime ?? def.startTime; + const endTime = w?.endTime ?? def.endTime; + return { id: w?.id ?? def.label, label: def.label, startTime, endTime, range: `${startTime} – ${endTime}`, enabled: w?.enabled ?? false }; + }); +} + +/* ======================================================================== */ +/* Mock implementation (no Shell configured) */ +/* ======================================================================== */ + +function useMockProfile(): ProfileData { + const [revealed, setRevealed] = useState(false); + const [contacts, setContacts] = useState({ email: seedUser.email.full, mobile: seedUser.mobile.full, whatsapp: seedUser.whatsapp.full }); + const [slots, setSlots] = useState(() => + seedKyc.slots.map((s) => ({ key: s.key, title: SLOT_META[s.key].title, required: s.required, docId: s.docId, fileName: s.fileName, status: s.status, note: s.note }))); + const [prefs, setPrefs] = useState(() => + seedPrefs.map((c) => ({ id: c.id, label: c.label, desc: c.desc, locked: Boolean(c.locked), email: c.email, sms: c.sms, whatsapp: c.whatsapp, push: c.push }))); + const [timezone, setTz] = useState(seedTz[0]); + const [windows, setWindows] = useState(() => + seedWindows.map((w) => ({ id: w.id, label: w.label, range: w.range, startTime: w.range.split(" – ")[0], endTime: w.range.split(" – ")[1], enabled: w.enabled }))); + const [destinations, setDestinations] = useState(() => + seedDests.map((d) => ({ id: d.id, channel: d.channel, label: d.label, value: d.value, verified: d.verified, primary: d.primary }))); + const [consent, setConsent] = useState(() => + seedConsent.map((g) => ({ id: g.id, title: g.title, items: g.items.map((it) => ({ id: it.id, label: it.label, desc: it.desc, granted: it.granted, locked: Boolean(it.locked) })) }))); + + const docTypes: UiDocType[] = useMemo(() => + seedDocTypes.map((d) => ({ id: d.id, label: d.label, use: d.use, hint: d.hint, formats: d.formats, maxMb: d.maxMb })), []); + + const account: UiAccount = { + principalId: seedUser.id, name: seedUser.name, role: seedUser.role, initials: seedUser.initials, + avatarGradient: seedUser.avatarGradient, memberSince: seedUser.memberSince, status: seedUser.status, + allotmentNo: seedUser.allotmentNo, plot: seedUser.plot, sector: seedUser.sector, pocket: seedUser.pocket, + category: seedUser.category, size: seedUser.size, isAllottee: seedUser.isAllottee, + relationship: seedUser.relationship, allotteeName: seedUser.allotteeName, + email: revealed ? contacts.email : seedUser.email.masked, + mobile: revealed ? contacts.mobile : seedUser.mobile.masked, + whatsapp: revealed ? contacts.whatsapp : seedUser.whatsapp.masked, + pan: revealed ? seedUser.pan.full : seedUser.pan.masked, + aadhaar: revealed ? seedUser.aadhaar.full : seedUser.aadhaar.masked, + revealed, kycCompletionPct: Math.round((slots.filter((s) => s.status !== "missing").length / slots.length) * 100), + }; + + const reveal = useCallback(async (houseNumber: string) => { + const ok = houseNumber.trim() === seedUser.plot; + if (ok) setRevealed(true); + return ok; + }, []); + + return { + live: false, loading: false, error: null, account, + kyc: { slots, docTypes, completionPct: account.kycCompletionPct }, + notifications: { prefs, timezone, timezoneOptions: seedTz, windows, destinations }, + consent, + reveal, + requestContactChangeOtp: async () => "mock-challenge", + changeContact: async (channel, newValue) => { setContacts((c) => ({ ...c, [channel]: newValue })); }, + uploadKyc: async (slot, docTypeId, file) => { + setSlots((s) => s.map((x) => x.key === slot ? { ...x, docId: docTypeId ?? x.docId, fileName: file.fileName, status: "uploaded", note: "Under review" } : x)); + }, + removeKyc: async (slot) => { + setSlots((s) => s.map((x) => x.key === slot ? { ...x, fileName: null, docId: slot === "photo" ? null : x.docId, status: "missing", note: undefined } : x)); + }, + setPreference: async (categoryId, channel, value) => { + setPrefs((p) => p.map((c) => c.id === categoryId ? { ...c, [channel]: value } : c)); + }, + setTimezone: async (tz) => setTz(tz), + setContactWindow: async (label, patch) => { + setWindows((w) => w.map((x) => x.label === label ? { ...x, ...patch, range: `${patch.startTime} – ${patch.endTime}` } : x)); + }, + addDestination: async (channel, label, value) => { + setDestinations((d) => [...d, { id: `dest_${d.length + 1}`, channel, label, value, verified: false, primary: false }]); + }, + requestDestinationOtp: async () => "mock-challenge", + verifyDestination: async (id) => { setDestinations((d) => d.map((x) => x.id === id ? { ...x, verified: true } : x)); }, + setConsent: async (groupId, itemId, granted) => { + setConsent((gs) => gs.map((g) => g.id !== groupId ? g : { ...g, items: g.items.map((it) => it.id === itemId ? { ...it, granted } : it) })); + }, + refetch: () => {}, + }; +} + +/* ======================================================================== */ +/* Live implementation (be-crm data door) */ +/* ======================================================================== */ + +function useLiveProfile(): ProfileData { + const { sdk } = useAppShell(); + const { user, context } = useAuth(); + + const accountQ = useQuery("crm.account.read"); + const kycQ = useQuery("crm.kyc.list"); + const notifQ = useQuery("crm.notifications.get"); + const consentQ = useQuery("crm.consent.list"); + + const refetch = useCallback(() => { accountQ.refetch(); kycQ.refetch(); notifQ.refetch(); consentQ.refetch(); }, [accountQ, kycQ, notifQ, consentQ]); + const cmd = useCallback(async (action: string, variables: Record) => { + await sdk.command(action, variables); refetch(); + }, [sdk, refetch]); + + const a = accountQ.data; + const displayName = (user?.displayName?.trim() || a?.allotteeName?.trim() || user?.email || "My account"); + // Role for the header: derive from the profile itself, not a hardcoded label. + const role = a?.isAllottee ? "Property Owner" : (a?.relationship?.trim() || "Representative"); + const roleFromContext = (context as { scope?: { role?: string } } | null)?.scope?.role; + + const account: UiAccount = { + principalId: a?.principalId ?? user?.id ?? "", + name: displayName, role: roleFromContext || role, initials: initialsOf(displayName), + avatarGradient: gradientFor(a?.principalId ?? user?.id ?? "x"), + memberSince: a?.memberSince ?? "—", status: a?.status ?? "active", + allotmentNo: a?.allotmentNo ?? null, plot: a?.plot ?? null, sector: a?.sector ?? null, + pocket: a?.pocket ?? null, category: a?.category ?? null, size: a?.size ?? null, + isAllottee: a?.isAllottee ?? false, relationship: a?.relationship ?? null, allotteeName: a?.allotteeName ?? null, + email: a?.email ?? "—", mobile: a?.mobile ?? "—", whatsapp: a?.whatsapp ?? "—", + pan: a?.pan ?? "—", aadhaar: a?.aadhaar ?? "—", + revealed: a?.revealed ?? false, kycCompletionPct: a?.kycCompletionPct ?? 0, + }; + + const kyc: UiKyc = useMemo(() => { + const d = kycQ.data; + const slots: UiKycSlot[] = (d?.slots ?? []).map((s) => ({ + key: s.slot, title: SLOT_META[s.slot].title, required: true, + docId: s.docTypeId, fileName: s.fileName, status: s.status, note: kycNote(s.status, s.rejectReason), + })); + const docTypes: UiDocType[] = (d?.docTypes ?? []).map((t) => ({ + id: t.id, label: t.label, use: t.use, hint: DOC_HINT[t.id] ?? "", formats: friendlyFormats(t.formats), maxMb: t.maxMb, + })); + return { slots, docTypes, completionPct: d?.completionPct ?? account.kycCompletionPct }; + }, [kycQ.data, account.kycCompletionPct]); + + const notifications: UiNotifications = useMemo(() => { + const d = notifQ.data; + const prefs: UiNotifCategory[] = (d?.preferences ?? []).map((p) => ({ + id: p.categoryId, label: p.label, desc: CATEGORY_DESC[p.categoryId] ?? "", locked: p.locked, + email: p.email, sms: p.sms, whatsapp: p.whatsapp, push: p.push, + })); + const destinations: UiDestination[] = (d?.destinations ?? []).map((x) => ({ + id: x.id, channel: x.channel, label: x.label ?? x.channel, value: x.value, verified: x.verified, primary: x.isPrimary, + })); + return { + prefs, timezone: d?.timezone ?? "Asia/Kolkata", + timezoneOptions: d?.timezoneOptions ?? ["Asia/Kolkata"], + windows: mergeWindows(d?.windows ?? []), destinations, + }; + }, [notifQ.data]); + + const consent: UiConsentGroup[] = useMemo(() => (consentQ.data ?? []).map((g) => ({ + id: g.groupId, title: g.label, + items: g.items.map((it) => ({ id: it.itemId, label: it.label, desc: CONSENT_DESC[it.itemId] ?? "", granted: it.granted, locked: it.statutory })), + })), [consentQ.data]); + + const reveal = useCallback(async (houseNumber: string): Promise => { + try { + await sdk.command("crm.account.revealField", { houseNumber }); + accountQ.refetch(); + return true; + } catch { return false; } + }, [sdk, accountQ]); + + return { + live: true, + loading: accountQ.loading || kycQ.loading || notifQ.loading || consentQ.loading, + error: (accountQ.error ?? kycQ.error ?? notifQ.error ?? consentQ.error)?.message ?? null, + account, kyc, notifications, consent, reveal, + requestContactChangeOtp: async (channel, newValue) => { + const r = await sdk.command<{ challengeId: string }>("crm.account.requestContactChangeOtp", { channel, newValue }); + return r.challengeId; + }, + changeContact: (channel, newValue, challengeId, otp) => + cmd("crm.account.changeContact", { channel, newValue, challengeId, otp }), + uploadKyc: (slot, docTypeId, file) => + cmd("crm.kyc.upload", { slot, ...(docTypeId ? { docTypeId } : {}), file }), + removeKyc: (slot) => cmd("crm.kyc.remove", { slot }), + setPreference: (categoryId, channel, value) => cmd("crm.notifications.setPreference", { categoryId, channel, value }), + setTimezone: (timezone) => cmd("crm.notifications.setTimezone", { timezone }), + setContactWindow: (label, patch) => cmd("crm.notifications.setContactWindow", { label, ...patch }), + addDestination: (channel, label, value) => cmd("crm.notifications.addDestination", { channel, label, value }), + requestDestinationOtp: async (id) => { + const r = await sdk.command<{ challengeId?: string }>("crm.notifications.verifyDestination", { id }); + return r.challengeId ?? ""; + }, + verifyDestination: (id, challengeId, otp) => cmd("crm.notifications.verifyDestination", { id, challengeId, otp }), + setConsent: (groupId, itemId, granted) => cmd("crm.consent.set", { groupId, itemId, granted }), + refetch, + }; +} + +/* ---- public hook: pick the implementation at module-config time --------- */ + +const SHELL = isShellConfigured(); + +export function useProfileData(): ProfileData { + // `SHELL` is constant for the life of the bundle (NEXT_PUBLIC_* is build-time), + // so the same hook path runs every render — Rules-of-Hooks safe. + return SHELL ? useLiveProfile() : useMockProfile(); +}