forked from Goutam/lynkeduppro-crm
d7eb82f182
- Support Center overview rebuilt as a bento grid: large live-chat hero tile, support team tile, mini channel tiles and a recent-tickets strip. - Restore full workspace sidebar (Workspace/Team/Profile groups) with a premium golden active state; unbuilt modules show a Coming Soon view. - Use Western names across the UI (James Carter; Emma Wilson, Liam Foster, Sophie Turner, Noah Mitchell) incl. tickets, threads, portal demo user. - Remove the Allottee card from Profile; add a clean Account & identity card and live-editable contacts. - Relabel "Plot" -> "House number" everywhere; drop the allotment chip in favour of the property category. - Premium visual pass: brand gradients, depth, glow accents, pill tabs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
680 lines
33 KiB
TypeScript
680 lines
33 KiB
TypeScript
"use client";
|
|
|
|
// ============================================================
|
|
// Profile — header card + 6 tabs:
|
|
// Personal Info · KYC · Security · Notifications · Privacy · Devices
|
|
// ============================================================
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import {
|
|
user, kycDocTypes, kyc as kycSeed, contactPrefs, contactDestinations,
|
|
contactTimezones, contactTimes, loginActivity, consentGroups,
|
|
passwordStrength, maskEmail, maskPhone,
|
|
type KycSlot, type NotifCategory, type ConsentGroup,
|
|
} from "./account-data";
|
|
import {
|
|
Avatar, Btn, Field, Icon, Modal, OtpField, PageHead, Pill, SegTabs,
|
|
StatusDot, Toggle, useToast,
|
|
} from "./ui";
|
|
|
|
const TABS = [
|
|
{ value: "personal", label: "Personal Info", icon: "user" },
|
|
{ value: "kyc", label: "KYC", icon: "shield-check" },
|
|
{ value: "security", label: "Security", icon: "lock" },
|
|
{ value: "notifications", label: "Notifications", icon: "bell" },
|
|
{ value: "privacy", label: "Privacy & Consent", icon: "privacy" },
|
|
{ value: "devices", label: "Devices", icon: "devices" },
|
|
];
|
|
|
|
export function Profile() {
|
|
const [tab, setTab] = useState("personal");
|
|
|
|
return (
|
|
<div className="view">
|
|
<PageHead
|
|
eyebrow="My Account"
|
|
title="Profile"
|
|
subtitle="Manage your identity, verification, security and preferences."
|
|
icon="user"
|
|
actions={<Pill tone="green"><StatusDot status="online" /> Account active</Pill>}
|
|
/>
|
|
|
|
<ProfileHeaderCard />
|
|
|
|
<SegTabs tabs={TABS} value={tab} onChange={setTab} />
|
|
|
|
<div className="view-body">
|
|
{tab === "personal" && <PersonalInfoTab />}
|
|
{tab === "kyc" && <KycTab />}
|
|
{tab === "security" && <SecurityTab />}
|
|
{tab === "notifications" && <NotificationsTab />}
|
|
{tab === "privacy" && <PrivacyTab />}
|
|
{tab === "devices" && <DevicesTab />}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ============================================================ */
|
|
/* Header card */
|
|
/* ============================================================ */
|
|
|
|
function ProfileHeaderCard() {
|
|
const { push } = useToast();
|
|
return (
|
|
<div className="card profile-hero">
|
|
<div className="profile-hero-bg" />
|
|
<div className="profile-hero-main">
|
|
<div className="profile-hero-avatar">
|
|
<Avatar initials={user.initials} gradient={user.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." })}>
|
|
<Icon name="camera" size={14} />
|
|
</button>
|
|
</div>
|
|
<div className="profile-hero-id">
|
|
<div className="profile-hero-name">
|
|
{user.name}
|
|
<Icon name="check-circle" size={18} className="verified-badge" />
|
|
</div>
|
|
<div className="profile-hero-role">{user.role} · Member since {user.memberSince}</div>
|
|
<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="owners" size={13} /> {user.category}</span>
|
|
<span className="hero-chip accent"><Icon name="check" size={13} /> House {user.plot}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="profile-hero-stats">
|
|
<HeroStat value={user.category} label="Category" />
|
|
<HeroStat value={user.size} label="House size" />
|
|
<HeroStat value="33%" label="KYC complete" accent="var(--orange)" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
function HeroStat({ value, label, accent }: { value: string; label: string; accent?: string }) {
|
|
return (
|
|
<div className="hero-stat">
|
|
<div className="hero-stat-v" style={accent ? { color: accent } : undefined}>{value}</div>
|
|
<div className="hero-stat-l">{label}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ============================================================ */
|
|
/* Tab: Personal Info — verify-to-reveal + change email/mobile */
|
|
/* ============================================================ */
|
|
|
|
type ContactKind = "email" | "mobile" | "whatsapp";
|
|
|
|
function PersonalInfoTab() {
|
|
const { push } = useToast();
|
|
const [verified, setVerified] = useState(false);
|
|
const [plot, setPlot] = useState("");
|
|
const [err, setErr] = useState("");
|
|
const [change, setChange] = useState<null | ContactKind>(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}).`);
|
|
}
|
|
|
|
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) },
|
|
];
|
|
|
|
return (
|
|
<div className="grid-2">
|
|
<div className="card">
|
|
<div className="card-head">
|
|
<h3>Contact details</h3>
|
|
{verified
|
|
? <Pill tone="green"><Icon name="eye" size={13} /> Revealed</Pill>
|
|
: <Pill tone="orange"><Icon name="eye-off" size={13} /> Masked</Pill>}
|
|
</div>
|
|
|
|
{!verified && (
|
|
<div className="reveal-gate">
|
|
<div className="reveal-gate-ic"><Icon name="lock" size={20} /></div>
|
|
<div className="reveal-gate-txt">
|
|
<strong>Verify to reveal & edit</strong>
|
|
<span>Sensitive details are masked. Enter your house number to unmask and edit them.</span>
|
|
</div>
|
|
<div className="reveal-gate-form">
|
|
<input className="ds-input" placeholder="House number" value={plot} maxLength={6}
|
|
onChange={(e) => { setPlot(e.target.value.replace(/\D/g, "")); setErr(""); }}
|
|
onKeyDown={(e) => e.key === "Enter" && verify()} />
|
|
<Btn icon="check" onClick={verify}>Reveal</Btn>
|
|
</div>
|
|
{err && <div className="ds-field-err"><Icon name="alert" size={12} /> {err}</div>}
|
|
</div>
|
|
)}
|
|
|
|
<dl className="kv-list">
|
|
{contactRows.map((f) => (
|
|
<div className="kv" key={f.key}>
|
|
<dt>{f.label}</dt>
|
|
<dd className={verified ? "kv-edit" : "masked"}>
|
|
{verified ? f.full : f.masked}
|
|
{verified && (
|
|
<button className="kv-edit-btn" aria-label={`Edit ${f.label}`} onClick={() => setChange(f.key)}><Icon name="edit" size={13} /></button>
|
|
)}
|
|
</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
|
|
<div className="card-actions">
|
|
<Btn variant="outline" icon="mail" onClick={() => setChange("email")} disabled={!verified}>Change email</Btn>
|
|
<Btn variant="outline" icon="phone" onClick={() => setChange("mobile")} disabled={!verified}>Change mobile</Btn>
|
|
</div>
|
|
{!verified && <p className="muted-note"><Icon name="info" size={13} /> Reveal your details first to edit contact information.</p>}
|
|
</div>
|
|
|
|
<div className="card">
|
|
<div className="card-head">
|
|
<h3>Account & identity</h3>
|
|
{verified
|
|
? <Pill tone="green"><Icon name="eye" size={13} /> Revealed</Pill>
|
|
: <Pill tone="muted"><Icon name="lock" size={12} /> Protected</Pill>}
|
|
</div>
|
|
<dl className="kv-list">
|
|
<div className="kv"><dt>Full name</dt><dd>{user.name}</dd></div>
|
|
<div className="kv"><dt>Role</dt><dd>{user.role}</dd></div>
|
|
<div className="kv"><dt>Member since</dt><dd>{user.memberSince}</dd></div>
|
|
<div className="kv"><dt>Account status</dt><dd><Pill tone="green"><StatusDot status="online" /> Active</Pill></dd></div>
|
|
<div className="kv">
|
|
<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>
|
|
</div>
|
|
<div className="kv">
|
|
<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>
|
|
</div>
|
|
</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>
|
|
</div>
|
|
|
|
<ChangeContactModal
|
|
kind={change}
|
|
current={change ? contacts[change] : ""}
|
|
onClose={() => setChange(null)}
|
|
onSave={(kind, value) => setContacts((c) => ({ ...c, [kind]: value }))}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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
|
|
// 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;
|
|
}) {
|
|
const { push } = useToast();
|
|
const [step, setStep] = useState<"enter" | "otp">("enter");
|
|
const [val, setVal] = useState("");
|
|
const [otp, setOtp] = useState("");
|
|
|
|
// 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]);
|
|
|
|
function close() { setStep("enter"); setVal(""); setOtp(""); onClose(); }
|
|
|
|
const isEmail = kind === "email";
|
|
const label = kind ? CONTACT_LABEL[kind] : "";
|
|
return (
|
|
<Modal
|
|
open={kind != null} onClose={close}
|
|
icon={isEmail ? "mail" : kind === "whatsapp" ? "chat" : "phone"}
|
|
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}`}
|
|
footer={
|
|
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={() => 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></>
|
|
}
|
|
>
|
|
{step === "enter" ? (
|
|
<Field label={`New ${label}`} hint="A one-time code is always sent to your existing registered mobile — never to the new destination.">
|
|
<input className="ds-input" type={isEmail ? "email" : "tel"} placeholder={isEmail ? "you@example.com" : "+91 90000 00000"} value={val} onChange={(e) => setVal(e.target.value)} />
|
|
</Field>
|
|
) : (
|
|
<div className="otp-wrap">
|
|
<OtpField value={otp} onChange={setOtp} />
|
|
<button className="link-inline" onClick={() => push({ tone: "info", title: "Code resent" })}>Resend code</button>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
/* ============================================================ */
|
|
/* Tab: KYC — inline upload, ID≠address, photo mandatory, % */
|
|
/* ============================================================ */
|
|
|
|
function KycTab() {
|
|
const { push } = useToast();
|
|
const [slots, setSlots] = useState<KycSlot[]>(kycSeed.slots);
|
|
|
|
const idDoc = slots.find((s) => s.key === "id")?.docId ?? null;
|
|
const addrDoc = slots.find((s) => s.key === "address")?.docId ?? null;
|
|
|
|
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"]) {
|
|
const want = key === "id" ? ["id", "any"] : key === "address" ? ["address", "any"] : [];
|
|
return kycDocTypes.filter((d) => want.includes(d.use));
|
|
}
|
|
|
|
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 === "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));
|
|
}
|
|
|
|
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";
|
|
|
|
return (
|
|
<div className="grid-side">
|
|
<div className="card">
|
|
<div className="card-head">
|
|
<h3>Verification documents</h3>
|
|
<Pill tone="orange">{completion}% complete</Pill>
|
|
</div>
|
|
|
|
<div className="kyc-progress">
|
|
<div className="kyc-progress-bar"><span style={{ width: `${completion}%` }} /></div>
|
|
<div className="kyc-progress-legend">
|
|
{slots.map((s) => (
|
|
<span key={s.key} className={`kyc-leg ${s.status}`}>
|
|
<Icon name={s.status === "missing" ? "x" : s.status === "verified" ? "check-circle" : "clock"} size={13} /> {s.title}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{!photoDone && <div className="callout tone-red"><Icon name="alert" size={18} /><div><strong>Live photo is mandatory.</strong><span>KYC cannot be completed without a verified photo.</span></div></div>}
|
|
|
|
<div className="kyc-slots">
|
|
{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)} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card card-muted">
|
|
<div className="card-head"><h3>Rules</h3></div>
|
|
<ul className="rule-mini">
|
|
<li><Icon name="check" size={14} /> A live photo / selfie is <strong>mandatory</strong>.</li>
|
|
<li><Icon name="check" size={14} /> Provide <strong>one ID</strong> and <strong>one address</strong> proof.</li>
|
|
<li><Icon name="check" size={14} /> ID and address must be <strong>two different documents</strong>.</li>
|
|
<li><Icon name="check" size={14} /> Accepted: PDF, JPG, PNG · size limits per document.</li>
|
|
<li><Icon name="check" size={14} /> Review completes within ~4 business hours.</li>
|
|
</ul>
|
|
<div className="kyc-doc-pairs">
|
|
<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>Address</span><Pill tone="purple">{kycDocTypes.find((d) => d.id === addrDoc)?.label ?? "Not chosen"}</Pill></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function KycSlotRow({ slot, options, onPick, onUpload, onRemove }: {
|
|
slot: KycSlot; options: typeof kycDocTypes; onPick: (d: string) => void; onUpload: () => void; onRemove: () => void;
|
|
}) {
|
|
const tone = slot.status === "verified" ? "green" : slot.status === "uploaded" ? "blue" : slot.status === "rejected" ? "red" : "muted";
|
|
const docMeta = kycDocTypes.find((d) => d.id === slot.docId);
|
|
return (
|
|
<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-main">
|
|
<div className="kyc-slot-top">
|
|
<span className="kyc-slot-title">{slot.title}{slot.required && <i className="req">*</i>}</span>
|
|
<Pill tone={tone}>{slot.status === "missing" ? "Missing" : slot.status === "uploaded" ? "Under review" : slot.status === "verified" ? "Verified" : "Rejected"}</Pill>
|
|
</div>
|
|
|
|
{slot.key !== "photo" && (
|
|
<select className="ds-select" value={slot.docId ?? ""} onChange={(e) => onPick(e.target.value)}>
|
|
<option value="" disabled>Select document type…</option>
|
|
{options.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
|
</select>
|
|
)}
|
|
|
|
{docMeta && <div className="kyc-slot-hint">{docMeta.hint} · {docMeta.formats} · ≤ {docMeta.maxMb} MB</div>}
|
|
|
|
{slot.fileName ? (
|
|
<div className="kyc-file">
|
|
<Icon name="paperclip" size={14} />
|
|
<span className="kyc-file-name">{slot.fileName}</span>
|
|
{slot.note && <span className="kyc-file-note">{slot.note}</span>}
|
|
<button className="ds-iconbtn sm" aria-label="Remove" onClick={onRemove}><Icon name="trash" size={14} /></button>
|
|
</div>
|
|
) : (
|
|
<button className="kyc-drop" onClick={onUpload}>
|
|
<Icon name="upload" size={16} /> Click to upload {slot.key === "photo" ? "a photo" : "document"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ============================================================ */
|
|
/* Tab: Security */
|
|
/* ============================================================ */
|
|
|
|
function SecurityTab() {
|
|
const { push } = useToast();
|
|
const [pw, setPw] = useState("");
|
|
const [twoFA, setTwoFA] = useState(false);
|
|
const [authApp, setAuthApp] = useState(false);
|
|
const [alerts, setAlerts] = useState(true);
|
|
const [twoFaModal, setTwoFaModal] = useState(false);
|
|
const strength = passwordStrength(pw);
|
|
|
|
return (
|
|
<div className="grid-2">
|
|
<div className="card">
|
|
<div className="card-head"><h3>Password</h3><Pill tone="muted">Updated 5 days ago</Pill></div>
|
|
<Field label="New password" hint="Min 8 chars · upper & lower · a number · a special character · no common patterns.">
|
|
<input className="ds-input" type="password" placeholder="••••••••" value={pw} onChange={(e) => setPw(e.target.value)} />
|
|
</Field>
|
|
{pw && (
|
|
<div className="pw-meter">
|
|
<div className="pw-bars">{[0, 1, 2, 3].map((i) => <span key={i} className={i < strength.score ? `lvl lvl-${strength.label.toLowerCase()}` : "lvl"} />)}</div>
|
|
<div className="pw-label">{strength.label}</div>
|
|
</div>
|
|
)}
|
|
<ul className="pw-checks">
|
|
{strength.checks.map((c) => <li key={c.label} className={c.ok ? "ok" : ""}><Icon name={c.ok ? "check-circle" : "x"} size={14} /> {c.label}</li>)}
|
|
</ul>
|
|
<div className="card-actions"><Btn icon="key" disabled={strength.score < 3} onClick={() => { push({ tone: "success", title: "Password updated" }); setPw(""); }}>Update password</Btn></div>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<div className="card-head"><h3>Two-factor & sign-in</h3></div>
|
|
<SettingRow icon="shield-check" title="Two-factor authentication" desc="Requires your password and a one-time code to enable.">
|
|
<Toggle checked={twoFA} onChange={(v) => { if (v) setTwoFaModal(true); else { setTwoFA(false); setAuthApp(false); push({ tone: "info", title: "Two-factor disabled" }); } }} label="2FA" />
|
|
</SettingRow>
|
|
<SettingRow icon="key" title="Authenticator app" desc={authApp ? "Connected · codes from your app" : "Use an app like Google Authenticator"}>
|
|
<Toggle checked={authApp} disabled={!twoFA} onChange={(v) => { setAuthApp(v); push({ tone: v ? "success" : "info", title: v ? "Authenticator linked" : "Authenticator removed" }); }} label="Authenticator" />
|
|
</SettingRow>
|
|
<SettingRow icon="bell" title="Login alerts" desc="Notify me when a new device signs in.">
|
|
<Toggle checked={alerts} onChange={(v) => { setAlerts(v); push({ tone: "info", title: v ? "Login alerts on" : "Login alerts off" }); }} label="Login alerts" />
|
|
</SettingRow>
|
|
{!twoFA && <p className="muted-note"><Icon name="info" size={13} /> Enable two-factor authentication to link an authenticator app.</p>}
|
|
</div>
|
|
|
|
<TwoFaModal open={twoFaModal} onClose={() => setTwoFaModal(false)} onDone={() => { setTwoFA(true); setTwoFaModal(false); push({ tone: "success", title: "Two-factor enabled", desc: "Your account is now protected with 2FA." }); }} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TwoFaModal({ open, onClose, onDone }: { open: boolean; onClose: () => void; onDone: () => void }) {
|
|
const { push } = useToast();
|
|
const [step, setStep] = useState<"password" | "otp">("password");
|
|
const [pw, setPw] = useState("");
|
|
const [otp, setOtp] = useState("");
|
|
function close() { setStep("password"); setPw(""); setOtp(""); onClose(); }
|
|
return (
|
|
<Modal open={open} onClose={close} icon="shield-check" title="Enable two-factor authentication"
|
|
subtitle={step === "password" ? "Confirm your password to continue." : `Enter the code sent to ${user.mobile.masked}.`}
|
|
footer={step === "password"
|
|
? <><Btn variant="ghost" onClick={close}>Cancel</Btn><Btn icon="arrow" iconRight="arrow" disabled={pw.length < 4} onClick={() => { setStep("otp"); push({ tone: "info", title: "Code sent", desc: "OTP sent to your registered mobile." }); }}>Continue</Btn></>
|
|
: <><Btn variant="ghost" onClick={() => setStep("password")}>Back</Btn><Btn icon="check" disabled={otp.length < 6} onClick={onDone}>Enable 2FA</Btn></>}
|
|
>
|
|
{step === "password" ? (
|
|
<Field label="Current password"><input className="ds-input" type="password" placeholder="••••••••" value={pw} onChange={(e) => setPw(e.target.value)} /></Field>
|
|
) : (
|
|
<div className="otp-wrap"><OtpField value={otp} onChange={setOtp} /><span className="muted-note">Both password and OTP are required to turn 2FA on.</span></div>
|
|
)}
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function SettingRow({ icon, title, desc, children }: { icon: string; title: string; desc: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className="setting-row">
|
|
<span className="setting-ic"><Icon name={icon} size={18} /></span>
|
|
<div className="setting-txt"><div className="setting-title">{title}</div><div className="setting-desc">{desc}</div></div>
|
|
<div className="setting-ctl">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ============================================================ */
|
|
/* Tab: Notifications */
|
|
/* ============================================================ */
|
|
|
|
function NotificationsTab() {
|
|
const { push } = useToast();
|
|
const [prefs, setPrefs] = useState<NotifCategory[]>(contactPrefs);
|
|
const [tz, setTz] = useState(contactTimezones[0]);
|
|
const [times, setTimes] = useState(contactTimes);
|
|
const channels: { key: keyof NotifCategory; 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;
|
|
}));
|
|
}
|
|
|
|
return (
|
|
<div className="grid-side">
|
|
<div className="card card-pad-0">
|
|
<div className="card-head pad"><h3>Per-channel matrix</h3><span className="muted-note">Choose how each category reaches you</span></div>
|
|
<div className="notif-table">
|
|
<div className="notif-row notif-head">
|
|
<span>Category</span>
|
|
{channels.map((c) => <span key={c.key} className="notif-ch"><Icon name={c.icon} size={14} /> {c.label}</span>)}
|
|
</div>
|
|
{prefs.map((cat) => (
|
|
<div className="notif-row" key={cat.id}>
|
|
<span className="notif-cat">
|
|
<span className="notif-cat-name">{cat.label}{cat.locked && <Icon name="lock" size={12} className="lock-ic" />}</span>
|
|
<span className="notif-cat-desc">{cat.desc}</span>
|
|
</span>
|
|
{channels.map((c) => (
|
|
<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}`} />
|
|
</span>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card-stack">
|
|
<div className="card">
|
|
<div className="card-head"><h3>Contact-time window</h3></div>
|
|
<Field label="Timezone">
|
|
<select className="ds-select" value={tz} onChange={(e) => setTz(e.target.value)}>
|
|
{contactTimezones.map((t) => <option key={t} value={t}>{t}</option>)}
|
|
</select>
|
|
</Field>
|
|
<div className="time-windows">
|
|
{times.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))}>
|
|
<Icon name={t.enabled ? "check-circle" : "clock"} size={15} />
|
|
<span className="tw-label">{t.label}</span>
|
|
<span className="tw-range">{t.range}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<p className="muted-note"><Icon name="info" size={13} /> Callbacks & proactive messages only arrive inside enabled windows, in your timezone.</p>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<div className="card-head"><h3>Destinations</h3></div>
|
|
<div className="dest-list">
|
|
{contactDestinations.map((d) => (
|
|
<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>
|
|
<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>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<Btn variant="ghost" icon="plus" onClick={() => push({ tone: "info", title: "Add destination" })}>Add destination</Btn>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ============================================================ */
|
|
/* Tab: Privacy & Consent */
|
|
/* ============================================================ */
|
|
|
|
function PrivacyTab() {
|
|
const { push } = useToast();
|
|
const [groups, setGroups] = useState<ConsentGroup[]>(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 };
|
|
}),
|
|
}));
|
|
}
|
|
|
|
return (
|
|
<div className="consent-cols">
|
|
{groups.map((g) => (
|
|
<div className="card" key={g.id}>
|
|
<div className="card-head">
|
|
<h3>{g.title}</h3>
|
|
{g.id === "marketing" && <Pill tone="muted">Optional</Pill>}
|
|
{g.id === "essential" && <Pill tone="red"><Icon name="lock" size={12} /> Required</Pill>}
|
|
</div>
|
|
<div className="consent-list">
|
|
{g.items.map((it) => (
|
|
<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>
|
|
<div className="consent-desc">{it.desc}</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>
|
|
);
|
|
}
|
|
|
|
/* ============================================================ */
|
|
/* Tab: Devices */
|
|
/* ============================================================ */
|
|
|
|
function DevicesTab() {
|
|
const { push } = useToast();
|
|
const [devices, setDevices] = useState(loginActivity);
|
|
|
|
function signOut(id: string) {
|
|
setDevices((d) => d.filter((x) => x.id !== id));
|
|
push({ tone: "success", title: "Signed out", desc: "That device no longer has access." });
|
|
}
|
|
|
|
return (
|
|
<div className="grid-side">
|
|
<div className="card">
|
|
<div className="card-head">
|
|
<h3>Connected devices</h3>
|
|
<Btn variant="outline" size="sm" icon="logout" onClick={() => { setDevices((d) => d.filter((x) => x.current)); push({ tone: "success", title: "Other devices signed out" }); }}>Sign out all others</Btn>
|
|
</div>
|
|
<div className="device-list">
|
|
{devices.filter((d) => d.event !== "password-change").map((d) => (
|
|
<div className={`device-row ${d.current ? "current" : ""}`} key={d.id}>
|
|
<span className="device-ic"><Icon name={d.kind} size={20} /></span>
|
|
<div className="device-txt">
|
|
<div className="device-name">{d.device}{d.current && <Pill tone="green">This device</Pill>}{!d.trusted && <Pill tone="orange"><Icon name="alert" size={11} /> New</Pill>}</div>
|
|
<div className="device-meta">{d.browser} · {d.os}</div>
|
|
<div className="device-meta"><Icon name="pin" size={12} /> {d.location} · {d.ip} · {d.lastActive}</div>
|
|
</div>
|
|
{!d.current && <button className="ds-iconbtn" aria-label="Sign out" onClick={() => signOut(d.id)}><Icon name="logout" size={16} /></button>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<div className="card-head"><h3>Recent activity</h3></div>
|
|
<div className="timeline">
|
|
{loginActivity.map((a) => (
|
|
<div className="tl-item" key={a.id}>
|
|
<span className={`tl-dot ev-${a.event}`}><Icon name={a.event === "password-change" ? "key" : a.event === "new-device" ? "alert" : a.event === "2fa-enabled" ? "shield-check" : "check"} size={12} /></span>
|
|
<div className="tl-body">
|
|
<div className="tl-title">{labelForEvent(a.event)}</div>
|
|
<div className="tl-meta">{a.device} · {a.location}</div>
|
|
<div className="tl-time">{a.lastActive}</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function labelForEvent(ev: string) {
|
|
switch (ev) {
|
|
case "password-change": return "Password changed";
|
|
case "new-device": return "New device signed in";
|
|
case "2fa-enabled": return "Two-factor enabled";
|
|
case "sign-out": return "Signed out";
|
|
default: return "Successful sign-in";
|
|
}
|
|
}
|