forked from Goutam/lynkeduppro-crm
feat(profile): wire Profile to the be-crm profile data door
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).
This commit is contained in:
@@ -3,15 +3,20 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// Profile — header card + 6 tabs:
|
// Profile — header card + 6 tabs:
|
||||||
// Personal Info · KYC · Security · Notifications · Privacy · Devices
|
// 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 {
|
import {
|
||||||
user, kycDocTypes, kyc as kycSeed, contactPrefs, contactDestinations,
|
user, loginActivity, passwordStrength,
|
||||||
contactTimezones, contactTimes, loginActivity, consentGroups,
|
|
||||||
passwordStrength, maskEmail, maskPhone,
|
|
||||||
type KycSlot, type NotifCategory, type ConsentGroup,
|
|
||||||
} from "./account-data";
|
} from "./account-data";
|
||||||
|
import {
|
||||||
|
useProfileData,
|
||||||
|
type ProfileData, type UiAccount, type UiKycSlot, type ContactKind, type KycKey, type NotifChannel, type UploadFile,
|
||||||
|
} from "@/lib/profile-api";
|
||||||
import {
|
import {
|
||||||
Avatar, Btn, Field, Icon, Modal, OtpField, PageHead, Pill, SegTabs,
|
Avatar, Btn, Field, Icon, Modal, OtpField, PageHead, Pill, SegTabs,
|
||||||
StatusDot, Toggle, useToast,
|
StatusDot, Toggle, useToast,
|
||||||
@@ -26,7 +31,27 @@ const TABS = [
|
|||||||
{ value: "devices", label: "Devices", icon: "devices" },
|
{ value: "devices", label: "Devices", icon: "devices" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Read a picked File into the base64 shape a data-door command carries. */
|
||||||
|
function readFileAsUpload(file: File): Promise<UploadFile> {
|
||||||
|
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() {
|
export function Profile() {
|
||||||
|
const p = useProfileData();
|
||||||
const [tab, setTab] = useState("personal");
|
const [tab, setTab] = useState("personal");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -36,19 +61,26 @@ export function Profile() {
|
|||||||
title="Profile"
|
title="Profile"
|
||||||
subtitle="Manage your identity, verification, security and preferences."
|
subtitle="Manage your identity, verification, security and preferences."
|
||||||
icon="user"
|
icon="user"
|
||||||
actions={<Pill tone="green"><StatusDot status="online" /> Account active</Pill>}
|
actions={<Pill tone={p.account.status === "active" ? "green" : "red"}><StatusDot status={p.account.status === "active" ? "online" : "offline"} /> Account {p.account.status}</Pill>}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ProfileHeaderCard />
|
{p.error && (
|
||||||
|
<div className="callout tone-red" style={{ marginBottom: 16 }}>
|
||||||
|
<Icon name="alert" size={18} />
|
||||||
|
<div><strong>Couldn't load your profile.</strong><span>{p.error}</span></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ProfileHeaderCard account={p.account} />
|
||||||
|
|
||||||
<SegTabs tabs={TABS} value={tab} onChange={setTab} />
|
<SegTabs tabs={TABS} value={tab} onChange={setTab} />
|
||||||
|
|
||||||
<div className="view-body">
|
<div className="view-body">
|
||||||
{tab === "personal" && <PersonalInfoTab />}
|
{tab === "personal" && <PersonalInfoTab p={p} />}
|
||||||
{tab === "kyc" && <KycTab />}
|
{tab === "kyc" && <KycTab p={p} />}
|
||||||
{tab === "security" && <SecurityTab />}
|
{tab === "security" && <SecurityTab />}
|
||||||
{tab === "notifications" && <NotificationsTab />}
|
{tab === "notifications" && <NotificationsTab p={p} />}
|
||||||
{tab === "privacy" && <PrivacyTab />}
|
{tab === "privacy" && <PrivacyTab p={p} />}
|
||||||
{tab === "devices" && <DevicesTab />}
|
{tab === "devices" && <DevicesTab />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,35 +91,36 @@ export function Profile() {
|
|||||||
/* Header card */
|
/* Header card */
|
||||||
/* ============================================================ */
|
/* ============================================================ */
|
||||||
|
|
||||||
function ProfileHeaderCard() {
|
function ProfileHeaderCard({ account }: { account: UiAccount }) {
|
||||||
const { push } = useToast();
|
const { push } = useToast();
|
||||||
|
const locale = [account.sector, account.pocket].filter(Boolean).join(" · ") || "Location on file";
|
||||||
return (
|
return (
|
||||||
<div className="card profile-hero">
|
<div className="card profile-hero">
|
||||||
<div className="profile-hero-bg" />
|
<div className="profile-hero-bg" />
|
||||||
<div className="profile-hero-main">
|
<div className="profile-hero-main">
|
||||||
<div className="profile-hero-avatar">
|
<div className="profile-hero-avatar">
|
||||||
<Avatar initials={user.initials} gradient={user.avatarGradient} size={86} square />
|
<Avatar initials={account.initials} gradient={account.avatarGradient} size={86} square />
|
||||||
<button className="profile-hero-cam" aria-label="Change photo" onClick={() => push({ tone: "info", title: "Photo upload", desc: "Choose a JPG or PNG up to 5 MB." })}>
|
<button className="profile-hero-cam" aria-label="Change photo" onClick={() => push({ tone: "info", title: "Photo upload", desc: "Choose a JPG or PNG up to 5 MB." })}>
|
||||||
<Icon name="camera" size={14} />
|
<Icon name="camera" size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="profile-hero-id">
|
<div className="profile-hero-id">
|
||||||
<div className="profile-hero-name">
|
<div className="profile-hero-name">
|
||||||
{user.name}
|
{account.name}
|
||||||
<Icon name="check-circle" size={18} className="verified-badge" />
|
<Icon name="check-circle" size={18} className="verified-badge" />
|
||||||
</div>
|
</div>
|
||||||
<div className="profile-hero-role">{user.role} · Member since {user.memberSince}</div>
|
<div className="profile-hero-role">{account.role} · Member since {account.memberSince}</div>
|
||||||
<div className="profile-hero-chips">
|
<div className="profile-hero-chips">
|
||||||
<span className="hero-chip"><Icon name="pin" size={13} /> {user.sector} · {user.pocket}</span>
|
<span className="hero-chip"><Icon name="pin" size={13} /> {locale}</span>
|
||||||
<span className="hero-chip"><Icon name="owners" size={13} /> {user.category}</span>
|
{account.category && <span className="hero-chip"><Icon name="owners" size={13} /> {account.category}</span>}
|
||||||
<span className="hero-chip accent"><Icon name="check" size={13} /> House {user.plot}</span>
|
{account.plot && <span className="hero-chip accent"><Icon name="check" size={13} /> House {account.plot}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="profile-hero-stats">
|
<div className="profile-hero-stats">
|
||||||
<HeroStat value={user.category} label="Category" />
|
<HeroStat value={account.category ?? "—"} label="Category" />
|
||||||
<HeroStat value={user.size} label="House size" />
|
<HeroStat value={account.size ?? "—"} label="House size" />
|
||||||
<HeroStat value="33%" label="KYC complete" accent="var(--orange)" />
|
<HeroStat value={`${account.kycCompletionPct}%`} label="KYC complete" accent="var(--orange)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -105,31 +138,30 @@ function HeroStat({ value, label, accent }: { value: string; label: string; acce
|
|||||||
/* Tab: Personal Info — verify-to-reveal + change email/mobile */
|
/* Tab: Personal Info — verify-to-reveal + change email/mobile */
|
||||||
/* ============================================================ */
|
/* ============================================================ */
|
||||||
|
|
||||||
type ContactKind = "email" | "mobile" | "whatsapp";
|
function PersonalInfoTab({ p }: { p: ProfileData }) {
|
||||||
|
|
||||||
function PersonalInfoTab() {
|
|
||||||
const { push } = useToast();
|
const { push } = useToast();
|
||||||
const [verified, setVerified] = useState(false);
|
const { account } = p;
|
||||||
const [plot, setPlot] = useState("");
|
const verified = account.revealed;
|
||||||
|
const [house, setHouse] = useState("");
|
||||||
const [err, setErr] = useState("");
|
const [err, setErr] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
const [change, setChange] = useState<null | ContactKind>(null);
|
const [change, setChange] = useState<null | ContactKind>(null);
|
||||||
|
|
||||||
// editable contact values (live-updating after OTP confirmation)
|
async function verify() {
|
||||||
const [contacts, setContacts] = useState({ email: user.email.full, mobile: user.mobile.full, whatsapp: user.whatsapp.full });
|
if (!house.trim()) return;
|
||||||
|
setBusy(true); setErr("");
|
||||||
function verify() {
|
try {
|
||||||
if (plot.trim() === user.plot) { setVerified(true); setErr(""); push({ tone: "success", title: "Verified", desc: "Your full details are now visible and editable." }); }
|
const ok = await p.reveal(house.trim());
|
||||||
else setErr(`That doesn't match the house number on file (hint: it's ${user.plot}).`);
|
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) {
|
const contactRows: { key: ContactKind; label: string; value: string }[] = [
|
||||||
return kind === "email" ? maskEmail(full) : maskPhone(full);
|
{ key: "email", label: "Email", value: account.email },
|
||||||
}
|
{ key: "mobile", label: "Mobile", value: account.mobile },
|
||||||
|
{ key: "whatsapp", label: "WhatsApp", value: account.whatsapp },
|
||||||
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) },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -150,10 +182,10 @@ function PersonalInfoTab() {
|
|||||||
<span>Sensitive details are masked. Enter your house number to unmask and edit them.</span>
|
<span>Sensitive details are masked. Enter your house number to unmask and edit them.</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="reveal-gate-form">
|
<div className="reveal-gate-form">
|
||||||
<input className="ds-input" placeholder="House number" value={plot} maxLength={6}
|
<input className="ds-input" placeholder="House number" value={house} maxLength={16}
|
||||||
onChange={(e) => { setPlot(e.target.value.replace(/\D/g, "")); setErr(""); }}
|
onChange={(e) => { setHouse(e.target.value); setErr(""); }}
|
||||||
onKeyDown={(e) => e.key === "Enter" && verify()} />
|
onKeyDown={(e) => e.key === "Enter" && verify()} />
|
||||||
<Btn icon="check" onClick={verify}>Reveal</Btn>
|
<Btn icon="check" onClick={verify} disabled={busy || !house.trim()}>{busy ? "Verifying…" : "Reveal"}</Btn>
|
||||||
</div>
|
</div>
|
||||||
{err && <div className="ds-field-err"><Icon name="alert" size={12} /> {err}</div>}
|
{err && <div className="ds-field-err"><Icon name="alert" size={12} /> {err}</div>}
|
||||||
</div>
|
</div>
|
||||||
@@ -164,7 +196,7 @@ function PersonalInfoTab() {
|
|||||||
<div className="kv" key={f.key}>
|
<div className="kv" key={f.key}>
|
||||||
<dt>{f.label}</dt>
|
<dt>{f.label}</dt>
|
||||||
<dd className={verified ? "kv-edit" : "masked"}>
|
<dd className={verified ? "kv-edit" : "masked"}>
|
||||||
{verified ? f.full : f.masked}
|
{f.value}
|
||||||
{verified && (
|
{verified && (
|
||||||
<button className="kv-edit-btn" aria-label={`Edit ${f.label}`} onClick={() => setChange(f.key)}><Icon name="edit" size={13} /></button>
|
<button className="kv-edit-btn" aria-label={`Edit ${f.label}`} onClick={() => setChange(f.key)}><Icon name="edit" size={13} /></button>
|
||||||
)}
|
)}
|
||||||
@@ -188,64 +220,82 @@ function PersonalInfoTab() {
|
|||||||
: <Pill tone="muted"><Icon name="lock" size={12} /> Protected</Pill>}
|
: <Pill tone="muted"><Icon name="lock" size={12} /> Protected</Pill>}
|
||||||
</div>
|
</div>
|
||||||
<dl className="kv-list">
|
<dl className="kv-list">
|
||||||
<div className="kv"><dt>Full name</dt><dd>{user.name}</dd></div>
|
<div className="kv"><dt>Full name</dt><dd>{account.name}</dd></div>
|
||||||
<div className="kv"><dt>Role</dt><dd>{user.role}</dd></div>
|
<div className="kv"><dt>Role</dt><dd>{account.role}</dd></div>
|
||||||
<div className="kv"><dt>Member since</dt><dd>{user.memberSince}</dd></div>
|
{account.allotmentNo && <div className="kv"><dt>Allotment no.</dt><dd>{account.allotmentNo}</dd></div>}
|
||||||
<div className="kv"><dt>Account status</dt><dd><Pill tone="green"><StatusDot status="online" /> Active</Pill></dd></div>
|
<div className="kv"><dt>Member since</dt><dd>{account.memberSince}</dd></div>
|
||||||
|
<div className="kv"><dt>Account status</dt><dd><Pill tone={account.status === "active" ? "green" : "red"}><StatusDot status={account.status === "active" ? "online" : "offline"} /> {account.status === "active" ? "Active" : "Suspended"}</Pill></dd></div>
|
||||||
<div className="kv">
|
<div className="kv">
|
||||||
<dt>PAN</dt>
|
<dt>PAN</dt>
|
||||||
<dd className={verified ? "kv-edit" : "masked"}>{verified ? user.pan.full : user.pan.masked}{verified && <span className="kv-lock"><Icon name="lock" size={12} /> via KYC</span>}</dd>
|
<dd className={verified ? "kv-edit" : "masked"}>{account.pan}{verified && <span className="kv-lock"><Icon name="lock" size={12} /> via KYC</span>}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="kv">
|
<div className="kv">
|
||||||
<dt>Aadhaar</dt>
|
<dt>Aadhaar</dt>
|
||||||
<dd className={verified ? "kv-edit" : "masked"}>{verified ? user.aadhaar.full : user.aadhaar.masked}{verified && <span className="kv-lock"><Icon name="lock" size={12} /> via KYC</span>}</dd>
|
<dd className={verified ? "kv-edit" : "masked"}>{account.aadhaar}{verified && <span className="kv-lock"><Icon name="lock" size={12} /> via KYC</span>}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<p className="muted-note"><Icon name="info" size={13} /> PAN & Aadhaar are managed under the KYC tab and can't be edited here.</p>
|
<p className="muted-note"><Icon name="info" size={13} /> PAN & Aadhaar are managed under the KYC tab and can't be edited here.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ChangeContactModal
|
<ChangeContactModal p={p} kind={change} onClose={() => setChange(null)} />
|
||||||
kind={change}
|
|
||||||
current={change ? contacts[change] : ""}
|
|
||||||
onClose={() => setChange(null)}
|
|
||||||
onSave={(kind, value) => setContacts((c) => ({ ...c, [kind]: value }))}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONTACT_LABEL: Record<ContactKind, string> = { email: "email", mobile: "mobile", whatsapp: "WhatsApp number" };
|
const CONTACT_LABEL: Record<ContactKind, string> = { email: "email", mobile: "mobile", whatsapp: "WhatsApp number" };
|
||||||
|
|
||||||
// OTP-to-registered-mobile flow for changing a contact field. On confirm the
|
// OTP-to-registered-mobile flow for changing a contact field. The one-time code
|
||||||
// new value is saved back so the Personal Info card updates live.
|
// is always sent to the registered mobile (never the new destination), then the
|
||||||
function ChangeContactModal({ kind, current, onClose, onSave }: {
|
// new value is persisted only after the code verifies.
|
||||||
kind: null | ContactKind; current: string; onClose: () => void; onSave: (kind: ContactKind, value: string) => void;
|
function ChangeContactModal({ p, kind, onClose }: { p: ProfileData; kind: null | ContactKind; onClose: () => void }) {
|
||||||
}) {
|
|
||||||
const { push } = useToast();
|
const { push } = useToast();
|
||||||
const [step, setStep] = useState<"enter" | "otp">("enter");
|
const [step, setStep] = useState<"enter" | "otp">("enter");
|
||||||
const [val, setVal] = useState("");
|
const [val, setVal] = useState("");
|
||||||
const [otp, setOtp] = 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(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
if (kind) { setStep("enter"); setVal(current); setOtp(""); }
|
if (kind) { setStep("enter"); setVal(""); setOtp(""); setChallengeId(""); setBusy(false); }
|
||||||
}, [kind, current]);
|
}, [kind]);
|
||||||
|
|
||||||
function close() { setStep("enter"); setVal(""); setOtp(""); onClose(); }
|
function close() { setStep("enter"); setVal(""); setOtp(""); onClose(); }
|
||||||
|
|
||||||
const isEmail = kind === "email";
|
const isEmail = kind === "email";
|
||||||
const label = kind ? CONTACT_LABEL[kind] : "";
|
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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
open={kind != null} onClose={close}
|
open={kind != null} onClose={close}
|
||||||
icon={isEmail ? "mail" : kind === "whatsapp" ? "chat" : "phone"}
|
icon={isEmail ? "mail" : kind === "whatsapp" ? "chat" : "phone"}
|
||||||
title={`Change ${label}`}
|
title={`Change ${label}`}
|
||||||
subtitle={step === "enter" ? "We'll send a code to your registered mobile to confirm." : `Enter the 6-digit code sent to ${user.mobile.masked}`}
|
subtitle={step === "enter" ? "We'll send a code to your registered mobile to confirm." : "Enter the 6-digit code sent to your registered mobile."}
|
||||||
footer={
|
footer={
|
||||||
step === "enter"
|
step === "enter"
|
||||||
? <><Btn variant="ghost" onClick={close}>Cancel</Btn><Btn icon="send" disabled={!val.trim() || val.trim() === current} onClick={() => { setStep("otp"); push({ tone: "info", title: "Code sent", desc: `OTP sent to your registered mobile ${user.mobile.masked}.` }); }}>Send code</Btn></>
|
? <><Btn variant="ghost" onClick={close}>Cancel</Btn><Btn icon="send" disabled={busy || !val.trim()} onClick={sendCode}>{busy ? "Sending…" : "Send code"}</Btn></>
|
||||||
: <><Btn variant="ghost" onClick={() => setStep("enter")}>Back</Btn><Btn icon="check" disabled={otp.length < 6} onClick={() => { 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</Btn></>
|
: <><Btn variant="ghost" onClick={() => setStep("enter")}>Back</Btn><Btn icon="check" disabled={busy || otp.length < 6} onClick={confirm}>{busy ? "Confirming…" : "Confirm"}</Btn></>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{step === "enter" ? (
|
{step === "enter" ? (
|
||||||
@@ -255,7 +305,7 @@ function ChangeContactModal({ kind, current, onClose, onSave }: {
|
|||||||
) : (
|
) : (
|
||||||
<div className="otp-wrap">
|
<div className="otp-wrap">
|
||||||
<OtpField value={otp} onChange={setOtp} />
|
<OtpField value={otp} onChange={setOtp} />
|
||||||
<button className="link-inline" onClick={() => push({ tone: "info", title: "Code resent" })}>Resend code</button>
|
<button className="link-inline" onClick={sendCode} disabled={busy}>Resend code</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -266,54 +316,52 @@ function ChangeContactModal({ kind, current, onClose, onSave }: {
|
|||||||
/* Tab: KYC — inline upload, ID≠address, photo mandatory, % */
|
/* Tab: KYC — inline upload, ID≠address, photo mandatory, % */
|
||||||
/* ============================================================ */
|
/* ============================================================ */
|
||||||
|
|
||||||
function KycTab() {
|
function KycTab({ p }: { p: ProfileData }) {
|
||||||
const { push } = useToast();
|
const { push } = useToast();
|
||||||
const [slots, setSlots] = useState<KycSlot[]>(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<Partial<Record<KycKey, string>>>({});
|
||||||
|
|
||||||
const idDoc = slots.find((s) => s.key === "id")?.docId ?? null;
|
const docIdFor = (key: KycKey) => pending[key] ?? slots.find((s) => s.key === key)?.docId ?? null;
|
||||||
const addrDoc = slots.find((s) => s.key === "address")?.docId ?? null;
|
const idDoc = docIdFor("id");
|
||||||
|
const addrDoc = docIdFor("address");
|
||||||
|
|
||||||
const completion = useMemo(() => {
|
function options(key: KycKey) {
|
||||||
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"]) {
|
|
||||||
const want = key === "id" ? ["id", "any"] : key === "address" ? ["address", "any"] : [];
|
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: KycKey, docId: string) {
|
||||||
function pickDoc(key: KycSlot["key"], docId: string) {
|
|
||||||
// enforce two-different-documents rule between ID and address
|
|
||||||
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 === "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; }
|
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 photoDone = (slots.find((s) => s.key === "photo")?.status ?? "missing") !== "missing";
|
||||||
const slot = slots.find((s) => s.key === key)!;
|
const docLabel = (id: string | null) => docTypes.find((d) => d.id === id)?.label ?? "Not chosen";
|
||||||
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";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid-side">
|
<div className="grid-side">
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-head">
|
<div className="card-head">
|
||||||
<h3>Verification documents</h3>
|
<h3>Verification documents</h3>
|
||||||
<Pill tone="orange">{completion}% complete</Pill>
|
<Pill tone="orange">{completionPct}% complete</Pill>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="kyc-progress">
|
<div className="kyc-progress">
|
||||||
<div className="kyc-progress-bar"><span style={{ width: `${completion}%` }} /></div>
|
<div className="kyc-progress-bar"><span style={{ width: `${completionPct}%` }} /></div>
|
||||||
<div className="kyc-progress-legend">
|
<div className="kyc-progress-legend">
|
||||||
{slots.map((s) => (
|
{slots.map((s) => (
|
||||||
<span key={s.key} className={`kyc-leg ${s.status}`}>
|
<span key={s.key} className={`kyc-leg ${s.status}`}>
|
||||||
@@ -327,7 +375,8 @@ function KycTab() {
|
|||||||
|
|
||||||
<div className="kyc-slots">
|
<div className="kyc-slots">
|
||||||
{slots.map((slot) => (
|
{slots.map((slot) => (
|
||||||
<KycSlotRow key={slot.key} slot={slot} options={options(slot.key)} onPick={(d) => pickDoc(slot.key, d)} onUpload={() => upload(slot.key)} onRemove={() => remove(slot.key)} />
|
<KycSlotRow key={slot.key} slot={slot} docId={docIdFor(slot.key)} docTypes={docTypes} options={options(slot.key)}
|
||||||
|
onPick={(d) => pickDoc(slot.key, d)} onUpload={(f) => upload(slot.key, f)} onRemove={() => remove(slot.key)} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -343,19 +392,22 @@ function KycTab() {
|
|||||||
</ul>
|
</ul>
|
||||||
<div className="kyc-doc-pairs">
|
<div className="kyc-doc-pairs">
|
||||||
<div className="muted-note"><Icon name="info" size={13} /> Current selection</div>
|
<div className="muted-note"><Icon name="info" size={13} /> Current selection</div>
|
||||||
<div className="pair-row"><span>Identity</span><Pill tone="blue">{kycDocTypes.find((d) => d.id === idDoc)?.label ?? "Not chosen"}</Pill></div>
|
<div className="pair-row"><span>Identity</span><Pill tone="blue">{docLabel(idDoc)}</Pill></div>
|
||||||
<div className="pair-row"><span>Address</span><Pill tone="purple">{kycDocTypes.find((d) => d.id === addrDoc)?.label ?? "Not chosen"}</Pill></div>
|
<div className="pair-row"><span>Address</span><Pill tone="purple">{docLabel(addrDoc)}</Pill></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function KycSlotRow({ slot, options, onPick, onUpload, onRemove }: {
|
function KycSlotRow({ slot, docId, docTypes, options, onPick, onUpload, onRemove }: {
|
||||||
slot: KycSlot; options: typeof kycDocTypes; onPick: (d: string) => void; onUpload: () => void; onRemove: () => void;
|
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<HTMLInputElement>(null);
|
||||||
const tone = slot.status === "verified" ? "green" : slot.status === "uploaded" ? "blue" : slot.status === "rejected" ? "red" : "muted";
|
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 (
|
return (
|
||||||
<div className={`kyc-slot status-${slot.status}`}>
|
<div className={`kyc-slot status-${slot.status}`}>
|
||||||
<div className="kyc-slot-ic"><Icon name={slot.key === "photo" ? "camera" : slot.key === "id" ? "user" : "pin"} size={18} /></div>
|
<div className="kyc-slot-ic"><Icon name={slot.key === "photo" ? "camera" : slot.key === "id" ? "user" : "pin"} size={18} /></div>
|
||||||
@@ -366,23 +418,27 @@ function KycSlotRow({ slot, options, onPick, onUpload, onRemove }: {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{slot.key !== "photo" && (
|
{slot.key !== "photo" && (
|
||||||
<select className="ds-select" value={slot.docId ?? ""} onChange={(e) => onPick(e.target.value)}>
|
<select className="ds-select" value={docId ?? ""} onChange={(e) => onPick(e.target.value)}>
|
||||||
<option value="" disabled>Select document type…</option>
|
<option value="" disabled>Select document type…</option>
|
||||||
{options.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
{options.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{docMeta && <div className="kyc-slot-hint">{docMeta.hint} · {docMeta.formats} · ≤ {docMeta.maxMb} MB</div>}
|
{docMeta && <div className="kyc-slot-hint">{docMeta.hint ? `${docMeta.hint} · ` : ""}{docMeta.formats} · ≤ {docMeta.maxMb} MB</div>}
|
||||||
|
{slot.status === "rejected" && slot.note && <div className="ds-field-err"><Icon name="alert" size={12} /> {slot.note}</div>}
|
||||||
|
|
||||||
|
<input ref={fileRef} type="file" accept={accept} hidden
|
||||||
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) onUpload(f); e.target.value = ""; }} />
|
||||||
|
|
||||||
{slot.fileName ? (
|
{slot.fileName ? (
|
||||||
<div className="kyc-file">
|
<div className="kyc-file">
|
||||||
<Icon name="paperclip" size={14} />
|
<Icon name="paperclip" size={14} />
|
||||||
<span className="kyc-file-name">{slot.fileName}</span>
|
<span className="kyc-file-name">{slot.fileName}</span>
|
||||||
{slot.note && <span className="kyc-file-note">{slot.note}</span>}
|
{slot.note && slot.status !== "rejected" && <span className="kyc-file-note">{slot.note}</span>}
|
||||||
<button className="ds-iconbtn sm" aria-label="Remove" onClick={onRemove}><Icon name="trash" size={14} /></button>
|
<button className="ds-iconbtn sm" aria-label="Remove" onClick={onRemove}><Icon name="trash" size={14} /></button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button className="kyc-drop" onClick={onUpload}>
|
<button className="kyc-drop" onClick={() => fileRef.current?.click()}>
|
||||||
<Icon name="upload" size={16} /> Click to upload {slot.key === "photo" ? "a photo" : "document"}
|
<Icon name="upload" size={16} /> Click to upload {slot.key === "photo" ? "a photo" : "document"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -392,7 +448,7 @@ function KycSlotRow({ slot, options, onPick, onUpload, onRemove }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================ */
|
/* ============================================================ */
|
||||||
/* Tab: Security */
|
/* Tab: Security (Shell-owned — local mock) */
|
||||||
/* ============================================================ */
|
/* ============================================================ */
|
||||||
|
|
||||||
function SecurityTab() {
|
function SecurityTab() {
|
||||||
@@ -478,24 +534,29 @@ function SettingRow({ icon, title, desc, children }: { icon: string; title: stri
|
|||||||
/* Tab: Notifications */
|
/* Tab: Notifications */
|
||||||
/* ============================================================ */
|
/* ============================================================ */
|
||||||
|
|
||||||
function NotificationsTab() {
|
function NotificationsTab({ p }: { p: ProfileData }) {
|
||||||
const { push } = useToast();
|
const { push } = useToast();
|
||||||
const [prefs, setPrefs] = useState<NotifCategory[]>(contactPrefs);
|
const { prefs, timezone, timezoneOptions, windows, destinations } = p.notifications;
|
||||||
const [tz, setTz] = useState(contactTimezones[0]);
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
const [times, setTimes] = useState(contactTimes);
|
const [verifyId, setVerifyId] = useState<string | null>(null);
|
||||||
const channels: { key: keyof NotifCategory; label: string; icon: string }[] = [
|
const channels: { key: NotifChannel; label: string; icon: string }[] = [
|
||||||
{ key: "email", label: "Email", icon: "mail" },
|
{ key: "email", label: "Email", icon: "mail" },
|
||||||
{ key: "sms", label: "SMS", icon: "phone" },
|
{ key: "sms", label: "SMS", icon: "phone" },
|
||||||
{ key: "whatsapp", label: "WhatsApp", icon: "chat" },
|
{ key: "whatsapp", label: "WhatsApp", icon: "chat" },
|
||||||
{ key: "push", label: "Push", icon: "bell" },
|
{ key: "push", label: "Push", icon: "bell" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function toggle(catId: string, ch: keyof NotifCategory) {
|
async function toggle(cat: (typeof prefs)[number], ch: NotifChannel) {
|
||||||
setPrefs((p) => p.map((c) => {
|
if (cat.locked) { push({ tone: "info", title: "Required notifications", desc: "Security alerts can't be turned off." }); return; }
|
||||||
if (c.id !== catId) return c;
|
try { await p.setPreference(cat.id, ch, !cat[ch]); }
|
||||||
if (c.locked) { push({ tone: "info", title: "Required notifications", desc: "Security alerts can't be turned off." }); return c; }
|
catch (e) { push({ tone: "error", title: "Couldn't save", desc: errText(e) }); }
|
||||||
return { ...c, [ch]: !c[ch] } as NotifCategory;
|
}
|
||||||
}));
|
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 (
|
return (
|
||||||
@@ -515,7 +576,7 @@ function NotificationsTab() {
|
|||||||
</span>
|
</span>
|
||||||
{channels.map((c) => (
|
{channels.map((c) => (
|
||||||
<span key={c.key} className="notif-ch">
|
<span key={c.key} className="notif-ch">
|
||||||
<Toggle checked={Boolean(cat[c.key])} disabled={cat.locked} onChange={() => toggle(cat.id, c.key)} label={`${cat.label} ${c.label}`} />
|
<Toggle checked={Boolean(cat[c.key])} disabled={cat.locked} onChange={() => toggle(cat, c.key)} label={`${cat.label} ${c.label}`} />
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -527,13 +588,13 @@ function NotificationsTab() {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-head"><h3>Contact-time window</h3></div>
|
<div className="card-head"><h3>Contact-time window</h3></div>
|
||||||
<Field label="Timezone">
|
<Field label="Timezone">
|
||||||
<select className="ds-select" value={tz} onChange={(e) => setTz(e.target.value)}>
|
<select className="ds-select" value={timezone} onChange={(e) => changeTz(e.target.value)}>
|
||||||
{contactTimezones.map((t) => <option key={t} value={t}>{t}</option>)}
|
{timezoneOptions.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
<div className="time-windows">
|
<div className="time-windows">
|
||||||
{times.map((t) => (
|
{windows.map((t) => (
|
||||||
<button key={t.id} className={`time-win ${t.enabled ? "on" : ""}`} onClick={() => setTimes((s) => s.map((x) => x.id === t.id ? { ...x, enabled: !x.enabled } : x))}>
|
<button key={t.label} className={`time-win ${t.enabled ? "on" : ""}`} onClick={() => toggleWindow(t)}>
|
||||||
<Icon name={t.enabled ? "check-circle" : "clock"} size={15} />
|
<Icon name={t.enabled ? "check-circle" : "clock"} size={15} />
|
||||||
<span className="tw-label">{t.label}</span>
|
<span className="tw-label">{t.label}</span>
|
||||||
<span className="tw-range">{t.range}</span>
|
<span className="tw-range">{t.range}</span>
|
||||||
@@ -546,76 +607,155 @@ function NotificationsTab() {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-head"><h3>Destinations</h3></div>
|
<div className="card-head"><h3>Destinations</h3></div>
|
||||||
<div className="dest-list">
|
<div className="dest-list">
|
||||||
{contactDestinations.map((d) => (
|
{destinations.length === 0 && <p className="muted-note"><Icon name="info" size={13} /> No extra destinations yet.</p>}
|
||||||
|
{destinations.map((d) => (
|
||||||
<div className="dest-row" key={d.id}>
|
<div className="dest-row" key={d.id}>
|
||||||
<span className="dest-ic"><Icon name={d.channel === "email" ? "mail" : d.channel === "sms" ? "phone" : "chat"} size={15} /></span>
|
<span className="dest-ic"><Icon name={d.channel === "email" ? "mail" : d.channel === "sms" ? "phone" : "chat"} size={15} /></span>
|
||||||
<div className="dest-txt"><div className="dest-label">{d.label}{d.primary && <Pill tone="orange">Primary</Pill>}</div><div className="dest-val">{d.value}</div></div>
|
<div className="dest-txt"><div className="dest-label">{d.label}{d.primary && <Pill tone="orange">Primary</Pill>}</div><div className="dest-val">{d.value}</div></div>
|
||||||
{d.verified ? <Pill tone="green"><Icon name="check" size={12} /> Verified</Pill> : <Btn variant="outline" size="sm" onClick={() => push({ tone: "info", title: "Verification sent" })}>Verify</Btn>}
|
{d.verified ? <Pill tone="green"><Icon name="check" size={12} /> Verified</Pill> : <Btn variant="outline" size="sm" onClick={() => setVerifyId(d.id)}>Verify</Btn>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Btn variant="ghost" icon="plus" onClick={() => push({ tone: "info", title: "Add destination" })}>Add destination</Btn>
|
<Btn variant="ghost" icon="plus" onClick={() => setAddOpen(true)}>Add destination</Btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AddDestinationModal p={p} open={addOpen} onClose={() => setAddOpen(false)} />
|
||||||
|
<VerifyDestinationModal p={p} id={verifyId} onClose={() => setVerifyId(null)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Modal open={open} onClose={onClose} icon="plus" title="Add a destination"
|
||||||
|
subtitle="Add another address or number, then verify it with a one-time code."
|
||||||
|
footer={<><Btn variant="ghost" onClick={onClose}>Cancel</Btn><Btn icon="check" disabled={busy || !value.trim()} onClick={save}>{busy ? "Adding…" : "Add destination"}</Btn></>}
|
||||||
|
>
|
||||||
|
<Field label="Channel">
|
||||||
|
<select className="ds-select" value={channel} onChange={(e) => setChannel(e.target.value as "email" | "sms" | "whatsapp")}>
|
||||||
|
<option value="email">Email</option>
|
||||||
|
<option value="sms">SMS</option>
|
||||||
|
<option value="whatsapp">WhatsApp</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Label" hint="A name to recognise this destination.">
|
||||||
|
<input className="ds-input" placeholder={isEmail ? "Work email" : "Alternate mobile"} value={label} onChange={(e) => setLabel(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label={isEmail ? "Email address" : "Phone number"}>
|
||||||
|
<input className="ds-input" type={isEmail ? "email" : "tel"} placeholder={isEmail ? "you@example.com" : "+91 90000 00000"} value={value} onChange={(e) => setValue(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Modal open={id != null} onClose={onClose} icon="shield-check" title="Verify destination"
|
||||||
|
subtitle="Enter the 6-digit code sent to your registered mobile."
|
||||||
|
footer={<><Btn variant="ghost" onClick={onClose}>Cancel</Btn><Btn icon="check" disabled={busy || otp.length < 6} onClick={confirm}>{busy ? "Verifying…" : "Verify"}</Btn></>}
|
||||||
|
>
|
||||||
|
<div className="otp-wrap">
|
||||||
|
<OtpField value={otp} onChange={setOtp} />
|
||||||
|
<span className="muted-note">{sent ? "A code was sent to your registered mobile." : "Sending a code…"}</span>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================ */
|
/* ============================================================ */
|
||||||
/* Tab: Privacy & Consent */
|
/* Tab: Privacy & Consent */
|
||||||
/* ============================================================ */
|
/* ============================================================ */
|
||||||
|
|
||||||
function PrivacyTab() {
|
function PrivacyTab({ p }: { p: ProfileData }) {
|
||||||
const { push } = useToast();
|
const { push } = useToast();
|
||||||
const [groups, setGroups] = useState<ConsentGroup[]>(consentGroups);
|
|
||||||
|
|
||||||
function toggle(gid: string, iid: string) {
|
async function toggle(gid: string, iid: string, locked: boolean, granted: boolean) {
|
||||||
setGroups((gs) => gs.map((g) => g.id !== gid ? g : {
|
if (locked) { push({ tone: "info", title: "Required consent", desc: "Statutory consents can't be withdrawn while active." }); return; }
|
||||||
...g,
|
try { await p.setConsent(gid, iid, !granted); }
|
||||||
items: g.items.map((it) => {
|
catch (e) { push({ tone: "error", title: "Couldn't save", desc: errText(e) }); }
|
||||||
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 };
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="consent-cols">
|
<div className="consent-cols">
|
||||||
{groups.map((g) => (
|
{p.consent.map((g) => {
|
||||||
<div className="card" key={g.id}>
|
const required = g.items.some((it) => it.locked);
|
||||||
<div className="card-head">
|
return (
|
||||||
<h3>{g.title}</h3>
|
<div className="card" key={g.id}>
|
||||||
{g.id === "marketing" && <Pill tone="muted">Optional</Pill>}
|
<div className="card-head">
|
||||||
{g.id === "essential" && <Pill tone="red"><Icon name="lock" size={12} /> Required</Pill>}
|
<h3>{g.title}</h3>
|
||||||
</div>
|
{required
|
||||||
<div className="consent-list">
|
? <Pill tone="red"><Icon name="lock" size={12} /> Required</Pill>
|
||||||
{g.items.map((it) => (
|
: <Pill tone="muted">Optional</Pill>}
|
||||||
<div className={`consent-item ${it.locked ? "locked" : ""}`} key={it.id}>
|
</div>
|
||||||
<div className="consent-txt">
|
<div className="consent-list">
|
||||||
<div className="consent-label">{it.label}{it.locked && <Icon name="lock" size={12} className="lock-ic" />}</div>
|
{g.items.map((it) => (
|
||||||
<div className="consent-desc">{it.desc}</div>
|
<div className={`consent-item ${it.locked ? "locked" : ""}`} key={it.id}>
|
||||||
|
<div className="consent-txt">
|
||||||
|
<div className="consent-label">{it.label}{it.locked && <Icon name="lock" size={12} className="lock-ic" />}</div>
|
||||||
|
{it.desc && <div className="consent-desc">{it.desc}</div>}
|
||||||
|
</div>
|
||||||
|
<Toggle checked={it.granted} disabled={it.locked} onChange={() => toggle(g.id, it.id, it.locked, it.granted)} label={it.label} />
|
||||||
</div>
|
</div>
|
||||||
<Toggle checked={it.granted} disabled={it.locked} onChange={() => toggle(g.id, it.id)} label={it.label} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{g.id === "marketing" && groups.find((x) => x.id === "marketing")!.items.find((i) => i.id === "promos")!.granted && (
|
|
||||||
<div className="consent-sub">
|
|
||||||
<div className="muted-note">Promotional sub-preferences</div>
|
|
||||||
{["Product offers", "Surveys & feedback", "Partner news"].map((s, i) => (
|
|
||||||
<label className="consent-sub-row" key={s}><span>{s}</span><Toggle checked={i === 0} onChange={() => {}} label={s} /></label>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================ */
|
/* ============================================================ */
|
||||||
/* Tab: Devices */
|
/* Tab: Devices (Shell-owned — local mock) */
|
||||||
/* ============================================================ */
|
/* ============================================================ */
|
||||||
|
|
||||||
function DevicesTab() {
|
function DevicesTab() {
|
||||||
|
|||||||
@@ -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<boolean>;
|
||||||
|
requestContactChangeOtp: (channel: ContactKind, newValue: string) => Promise<string>;
|
||||||
|
changeContact: (channel: ContactKind, newValue: string, challengeId: string, otp: string) => Promise<void>;
|
||||||
|
// KYC
|
||||||
|
uploadKyc: (slot: KycKey, docTypeId: string | null, file: UploadFile) => Promise<void>;
|
||||||
|
removeKyc: (slot: KycKey) => Promise<void>;
|
||||||
|
// Notifications
|
||||||
|
setPreference: (categoryId: string, channel: NotifChannel, value: boolean) => Promise<void>;
|
||||||
|
setTimezone: (timezone: string) => Promise<void>;
|
||||||
|
setContactWindow: (label: string, patch: { startTime: string; endTime: string; enabled: boolean }) => Promise<void>;
|
||||||
|
addDestination: (channel: DestChannel, label: string, value: string) => Promise<void>;
|
||||||
|
requestDestinationOtp: (id: string) => Promise<string>;
|
||||||
|
verifyDestination: (id: string, challengeId: string, otp: string) => Promise<void>;
|
||||||
|
// Consent
|
||||||
|
setConsent: (groupId: string, itemId: string, granted: boolean) => Promise<void>;
|
||||||
|
refetch: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- UI copy + static presentation maps ---------------------------------- */
|
||||||
|
|
||||||
|
const SLOT_META: Record<KycKey, { title: string }> = {
|
||||||
|
photo: { title: "Live Photo / Selfie" },
|
||||||
|
id: { title: "Identity Proof" },
|
||||||
|
address: { title: "Address Proof" },
|
||||||
|
};
|
||||||
|
const MIME_LABEL: Record<string, string> = { "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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<UiKycSlot[]>(() =>
|
||||||
|
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<UiNotifCategory[]>(() =>
|
||||||
|
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<UiWindow[]>(() =>
|
||||||
|
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<UiDestination[]>(() =>
|
||||||
|
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<UiConsentGroup[]>(() =>
|
||||||
|
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<AccountReadDTO>("crm.account.read");
|
||||||
|
const kycQ = useQuery<KycListDTO>("crm.kyc.list");
|
||||||
|
const notifQ = useQuery<NotificationsGetDTO>("crm.notifications.get");
|
||||||
|
const consentQ = useQuery<ConsentGroupDTO[]>("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<string, unknown>) => {
|
||||||
|
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<boolean> => {
|
||||||
|
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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user