Replace dashboard with Profile, Support Center & Rules modules
- Remove old dashboard stats/charts content; keep sidebar + header shell which now routes between Profile, Support Center and Rules views. - Profile: hero card + 6 tabs (Personal Info with verify-to-reveal + editable contacts via OTP, KYC two-doc rule + completion %, Security password policy + 2FA, Notifications matrix + contact windows, Privacy consents, Devices + activity timeline). - Support Center: 5 channel cards, support team, Message Center with typing, New Ticket category->department routing + attachment rules, My Tickets + status-timeline modal, Live Chat handshake, Callback/Email modals, Help Center search + accordion. - Rules: the 1-13 business-rules checklist. - Shared design system (Icon, Avatar, PageHead, Pill, Toggle, OtpField, Modal, Toast, Field, Tabs) + full mock data in account-data.ts. - Premium dark visual pass: brand gradients, depth, glow accents, pill tabs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,566 @@
|
||||
// ============================================================
|
||||
// LynkedUp Pro — Account / Profile / Support mock data (D).
|
||||
// Everything here is a client-side mock so the screens are
|
||||
// fully clickable without a backend. Ready-to-paste shapes.
|
||||
//
|
||||
// Anchored to the same demo identity used by the auth portal:
|
||||
// Rajesh Kumar Sharma · Allotment YEA-654321 · Plot 181.
|
||||
// ============================================================
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* helpers */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
export function maskEmail(email: string): string {
|
||||
const [user = "", domain = ""] = email.split("@");
|
||||
if (!domain) return email;
|
||||
if (user.length <= 2) return `${user[0] ?? ""}••@${domain}`;
|
||||
return `${user[0]}${"•".repeat(Math.max(4, user.length - 2))}${user[user.length - 1]}@${domain}`;
|
||||
}
|
||||
export function maskPhone(phone: string): string {
|
||||
const tail = phone.replace(/\D/g, "").slice(-2);
|
||||
return `+91 ••••• ••${tail}`;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* USER — the signed-in account holder */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
export type RevealField = { label: string; masked: string; full: string };
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
name: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
initials: string;
|
||||
role: string;
|
||||
avatarGradient: string;
|
||||
// property / allotment
|
||||
allotmentNo: string;
|
||||
plot: string; // verify-to-reveal key
|
||||
sector: string;
|
||||
pocket: string;
|
||||
category: string;
|
||||
size: string;
|
||||
// allottee vs account holder
|
||||
isAllottee: boolean;
|
||||
relationship: string; // "Self" when account holder IS allottee
|
||||
allotteeName: string;
|
||||
// contact (masked until verified)
|
||||
email: RevealField;
|
||||
mobile: RevealField;
|
||||
whatsapp: RevealField;
|
||||
pan: RevealField;
|
||||
aadhaar: RevealField;
|
||||
memberSince: string;
|
||||
status: "active" | "suspended";
|
||||
};
|
||||
|
||||
export const user: User = {
|
||||
id: "usr_LUP_00181",
|
||||
name: "Rajesh Kumar Sharma",
|
||||
firstName: "Rajesh",
|
||||
lastName: "Sharma",
|
||||
initials: "RS",
|
||||
role: "Property Owner",
|
||||
avatarGradient: "linear-gradient(135deg,#fda913,#fd6d13)",
|
||||
allotmentNo: "YEA-654321",
|
||||
plot: "181",
|
||||
sector: "Sector 18",
|
||||
pocket: "Pocket B",
|
||||
category: "Residential",
|
||||
size: "300 sq.m",
|
||||
isAllottee: true,
|
||||
relationship: "Self",
|
||||
allotteeName: "Rajesh Kumar Sharma",
|
||||
email: { label: "Email", masked: maskEmail("rajesh.sharma@example.com"), full: "rajesh.sharma@example.com" },
|
||||
mobile: { label: "Mobile", masked: "+91 ••••• ••012", full: "+91 98765 43012" },
|
||||
whatsapp: { label: "WhatsApp", masked: "+91 ••••• ••012", full: "+91 98765 43012" },
|
||||
pan: { label: "PAN", masked: "•••••••• 12F", full: "ABCPS1234F" },
|
||||
aadhaar: { label: "Aadhaar", masked: "•••• •••• 9012", full: "4321 8765 9012" },
|
||||
memberSince: "March 2021",
|
||||
status: "active",
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* COUNTRY CODES */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
export type CountryCode = { code: string; flag: string; name: string; digits: number; example: string };
|
||||
export const countryCodes: CountryCode[] = [
|
||||
{ code: "+91", flag: "🇮🇳", name: "India", digits: 10, example: "98765 43210" },
|
||||
{ code: "+1", flag: "🇺🇸", name: "United States", digits: 10, example: "201 555 0123" },
|
||||
{ code: "+44", flag: "🇬🇧", name: "United Kingdom", digits: 10, example: "7400 123456" },
|
||||
{ code: "+971", flag: "🇦🇪", name: "UAE", digits: 9, example: "50 123 4567" },
|
||||
{ code: "+65", flag: "🇸🇬", name: "Singapore", digits: 8, example: "8123 4567" },
|
||||
{ code: "+61", flag: "🇦🇺", name: "Australia", digits: 9, example: "412 345 678" },
|
||||
];
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* KYC */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
export type KycUse = "id" | "address" | "any";
|
||||
export type KycDocType = {
|
||||
id: string;
|
||||
label: string;
|
||||
use: KycUse; // what this document can prove
|
||||
hint: string;
|
||||
formats: string; // accepted attachment formats
|
||||
maxMb: number;
|
||||
};
|
||||
|
||||
// Note rule: an ID document and an ADDRESS document must be two
|
||||
// DIFFERENT document types. A passport (use:"any") may satisfy
|
||||
// either slot but not both at once.
|
||||
export const kycDocTypes: KycDocType[] = [
|
||||
{ id: "aadhaar", label: "Aadhaar Card", use: "any", hint: "Front & back, masked first 8 digits", formats: "PDF, JPG, PNG", maxMb: 5 },
|
||||
{ id: "pan", label: "PAN Card", use: "id", hint: "Clear photo of the front", formats: "PDF, JPG, PNG", maxMb: 5 },
|
||||
{ id: "passport", label: "Passport", use: "any", hint: "Photo page, must be valid", formats: "PDF, JPG, PNG", maxMb: 5 },
|
||||
{ id: "voter", label: "Voter ID", use: "id", hint: "Front & back", formats: "PDF, JPG, PNG", maxMb: 5 },
|
||||
{ id: "dl", label: "Driving Licence", use: "id", hint: "Front & back, not expired", formats: "PDF, JPG, PNG", maxMb: 5 },
|
||||
{ id: "utility", label: "Utility Bill", use: "address", hint: "Electricity / water, < 3 months old", formats: "PDF, JPG, PNG", maxMb: 8 },
|
||||
{ id: "bank", label: "Bank Statement", use: "address", hint: "First page with address, < 3 months", formats: "PDF", maxMb: 8 },
|
||||
{ id: "rent", label: "Registered Rent Agreement", use: "address", hint: "All pages, registered copy", formats: "PDF", maxMb: 10 },
|
||||
];
|
||||
|
||||
export type KycSlot = {
|
||||
key: "photo" | "id" | "address";
|
||||
title: string;
|
||||
required: boolean;
|
||||
docId: string | null; // chosen kycDocTypes id
|
||||
fileName: string | null;
|
||||
status: "missing" | "uploaded" | "verified" | "rejected";
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export const kyc: { completion: number; slots: KycSlot[] } = {
|
||||
completion: 33,
|
||||
slots: [
|
||||
{ key: "photo", title: "Live Photo / Selfie", required: true, docId: null, fileName: "selfie_rajesh.jpg", status: "verified", note: "Face match passed" },
|
||||
{ key: "id", title: "Identity Proof", required: true, docId: "pan", fileName: "pan_front.pdf", status: "uploaded", note: "Under review" },
|
||||
{ key: "address", title: "Address Proof", required: true, docId: null, fileName: null, status: "missing" },
|
||||
],
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* NOTIFICATIONS — channels + per-destination matrix */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
export type NotifCategory = {
|
||||
id: string;
|
||||
label: string;
|
||||
desc: string;
|
||||
locked?: boolean; // statutory / security — cannot disable
|
||||
email: boolean;
|
||||
sms: boolean;
|
||||
whatsapp: boolean;
|
||||
push: boolean;
|
||||
};
|
||||
|
||||
export const contactPrefs: NotifCategory[] = [
|
||||
{ id: "security", label: "Security & sign-in alerts", desc: "New device, password and 2FA changes", locked: true, email: true, sms: true, whatsapp: false, push: true },
|
||||
{ id: "kyc", label: "KYC & verification", desc: "Document status and re-verification requests", email: true, sms: true, whatsapp: true, push: true },
|
||||
{ id: "tickets", label: "Support tickets", desc: "Replies, status changes and resolutions", email: true, sms: false, whatsapp: true, push: true },
|
||||
{ id: "billing", label: "Billing & dues", desc: "Invoices, payment reminders and receipts", email: true, sms: true, whatsapp: false, push: false },
|
||||
{ id: "inspections", label: "Inspections & schedule", desc: "Upcoming visits and rescheduling", email: true, sms: false, whatsapp: true, push: true },
|
||||
{ id: "marketing", label: "Offers & product news", desc: "Promotions, surveys and newsletters", email: false, sms: false, whatsapp: false, push: false },
|
||||
];
|
||||
|
||||
export type ContactDestination = { id: string; channel: "email" | "sms" | "whatsapp"; label: string; value: string; verified: boolean; primary: boolean };
|
||||
export const contactDestinations: ContactDestination[] = [
|
||||
{ id: "e1", channel: "email", label: "Personal email", value: "rajesh.sharma@example.com", verified: true, primary: true },
|
||||
{ id: "e2", channel: "email", label: "Work email", value: "r.sharma@acme.co.in", verified: false, primary: false },
|
||||
{ id: "s1", channel: "sms", label: "Registered mobile", value: "+91 98765 43012", verified: true, primary: true },
|
||||
{ id: "w1", channel: "whatsapp", label: "WhatsApp", value: "+91 98765 43012", verified: true, primary: true },
|
||||
];
|
||||
|
||||
export const contactTimezones = [
|
||||
"Asia/Kolkata (IST · GMT+5:30)",
|
||||
"Asia/Dubai (GST · GMT+4:00)",
|
||||
"Europe/London (GMT/BST)",
|
||||
"America/New_York (ET)",
|
||||
"Asia/Singapore (SGT · GMT+8:00)",
|
||||
"Australia/Sydney (AET)",
|
||||
];
|
||||
|
||||
// Contact-time windows the user is willing to be reached in.
|
||||
export type ContactTime = { id: string; label: string; range: string; enabled: boolean };
|
||||
export const contactTimes: ContactTime[] = [
|
||||
{ id: "morning", label: "Morning", range: "08:00 – 12:00", enabled: true },
|
||||
{ id: "afternoon", label: "Afternoon", range: "12:00 – 16:00", enabled: true },
|
||||
{ id: "evening", label: "Evening", range: "16:00 – 20:00", enabled: true },
|
||||
{ id: "night", label: "Night", range: "20:00 – 22:00", enabled: false },
|
||||
];
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* SECURITY — login activity / devices */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
export type LoginActivity = {
|
||||
id: string;
|
||||
device: string;
|
||||
os: string;
|
||||
kind: "desktop" | "mobile" | "tablet";
|
||||
browser: string;
|
||||
location: string;
|
||||
ip: string;
|
||||
current: boolean;
|
||||
trusted: boolean;
|
||||
lastActive: string;
|
||||
event: "sign-in" | "password-change" | "2fa-enabled" | "new-device" | "sign-out";
|
||||
};
|
||||
|
||||
export const loginActivity: LoginActivity[] = [
|
||||
{ id: "d1", device: "MacBook Pro 14\"", os: "macOS 15.2", kind: "desktop", browser: "Chrome 132", location: "Noida, IN", ip: "49.36.x.x", current: true, trusted: true, lastActive: "Active now", event: "sign-in" },
|
||||
{ id: "d2", device: "iPhone 15", os: "iOS 18.3", kind: "mobile", browser: "Safari", location: "Noida, IN", ip: "49.36.x.x", current: false, trusted: true, lastActive: "2 hours ago", event: "sign-in" },
|
||||
{ id: "d3", device: "Windows PC", os: "Windows 11", kind: "desktop", browser: "Edge 131", location: "Gurugram, IN", ip: "182.71.x.x", current: false, trusted: false, lastActive: "Yesterday, 9:14 PM", event: "new-device" },
|
||||
{ id: "d4", device: "iPad Air", os: "iPadOS 18", kind: "tablet", browser: "Safari", location: "Dubai, AE", ip: "94.205.x.x", current: false, trusted: false, lastActive: "3 days ago", event: "sign-in" },
|
||||
{ id: "d5", device: "MacBook Pro 14\"", os: "macOS 15.2", kind: "desktop", browser: "Chrome 132", location: "Noida, IN", ip: "49.36.x.x", current: false, trusted: true, lastActive: "5 days ago", event: "password-change" },
|
||||
];
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* PRIVACY & CONSENT */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
export type ConsentItem = { id: string; label: string; desc: string; granted: boolean; locked?: boolean };
|
||||
export type ConsentGroup = { id: string; title: string; items: ConsentItem[] };
|
||||
|
||||
export const consentGroups: ConsentGroup[] = [
|
||||
{
|
||||
id: "essential",
|
||||
title: "Essential & statutory",
|
||||
items: [
|
||||
{ id: "tos", label: "Terms of Use & service agreement", desc: "Required to operate your account.", granted: true, locked: true },
|
||||
{ id: "kyc-process", label: "Identity verification processing", desc: "Process KYC documents to meet regulatory obligations.", granted: true, locked: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "operational",
|
||||
title: "Operational data sharing",
|
||||
items: [
|
||||
{ id: "authority", label: "Share records with the authority", desc: "Sync allotment & inspection records with the development authority.", granted: true },
|
||||
{ id: "processors", label: "Trusted processors", desc: "Allow vetted vendors to process data on our behalf.", granted: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "marketing",
|
||||
title: "Marketing & analytics",
|
||||
items: [
|
||||
{ id: "analytics", label: "Product analytics", desc: "Help improve the portal with anonymous usage data.", granted: true },
|
||||
{ id: "personalize", label: "Personalised recommendations", desc: "Tailor content based on your activity.", granted: false },
|
||||
{ id: "promos", label: "Promotional messages", desc: "Offers, surveys and partner news.", granted: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* SUPPORT — channels, team, tickets, threads, help */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
export type SupportChannel = {
|
||||
id: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
icon: string; // Icon name
|
||||
accent: string; // token var
|
||||
meta: string; // SLA / availability
|
||||
action: string; // CTA label
|
||||
status: "online" | "busy" | "offline";
|
||||
};
|
||||
|
||||
export const supportChannels: SupportChannel[] = [
|
||||
{ id: "chat", title: "Live Chat", desc: "Instant help from a support agent", icon: "chat", accent: "var(--orange)", meta: "Avg. wait < 2 min", action: "Start chat", status: "online" },
|
||||
{ id: "ticket", title: "Raise a Ticket", desc: "Track a request end-to-end", icon: "ticket", accent: "var(--blue)", meta: "First reply in 4 hrs", action: "New ticket", status: "online" },
|
||||
{ id: "callback", title: "Request Callback", desc: "We'll call you in your time window", icon: "phone", accent: "var(--green)", meta: "Within working hours", action: "Schedule", status: "online" },
|
||||
{ id: "email", title: "Email Support", desc: "care@lynkeduppro.com", icon: "mail", accent: "var(--purple)", meta: "Reply in 1 business day", action: "Send email", status: "online" },
|
||||
{ id: "help", title: "Help Center", desc: "Guides, FAQs and how-tos", icon: "book", accent: "var(--cyan)", meta: "120+ articles", action: "Browse", status: "online" },
|
||||
];
|
||||
|
||||
export type SupportAgent = {
|
||||
id: string;
|
||||
name: string;
|
||||
initials: string;
|
||||
role: string;
|
||||
team: string;
|
||||
gradient: string;
|
||||
status: "online" | "away" | "offline";
|
||||
rating: number;
|
||||
};
|
||||
|
||||
export const supportTeam: SupportAgent[] = [
|
||||
{ id: "a1", name: "Priya Nair", initials: "PN", role: "Senior Support Lead", team: "Accounts & KYC", gradient: "linear-gradient(135deg,#fda913,#fd6d13)", status: "online", rating: 4.9 },
|
||||
{ id: "a2", name: "Arjun Mehta", initials: "AM", role: "Support Specialist", team: "Billing", gradient: "linear-gradient(135deg,#285ef0,#09b9c6)", status: "online", rating: 4.8 },
|
||||
{ id: "a3", name: "Sara Khan", initials: "SK", role: "Technical Support", team: "Portal & Login", gradient: "linear-gradient(135deg,#9036e9,#285ef0)", status: "away", rating: 4.7 },
|
||||
{ id: "a4", name: "Vikram Rao", initials: "VR", role: "Field Coordinator", team: "Inspections", gradient: "linear-gradient(135deg,#14bc83,#09b9c6)", status: "offline", rating: 4.9 },
|
||||
];
|
||||
|
||||
// Category → department routing + attachment rules for the New Ticket form.
|
||||
export type TicketCategory = {
|
||||
id: string;
|
||||
label: string;
|
||||
department: string;
|
||||
sla: string;
|
||||
requiresAttachment: boolean;
|
||||
attachmentNote: string;
|
||||
};
|
||||
|
||||
export const ticketCategories: TicketCategory[] = [
|
||||
{ id: "kyc", label: "KYC / Document verification", department: "Accounts & KYC", sla: "4 business hours", requiresAttachment: true, attachmentNote: "Attach the document in question (PDF/JPG/PNG, ≤ 8 MB)." },
|
||||
{ id: "login", label: "Login / Account access", department: "Portal & Login", sla: "2 business hours", requiresAttachment: false, attachmentNote: "Screenshots optional (PNG/JPG, ≤ 5 MB)." },
|
||||
{ id: "billing", label: "Billing & payments", department: "Billing", sla: "1 business day", requiresAttachment: true, attachmentNote: "Attach invoice or receipt (PDF, ≤ 8 MB)." },
|
||||
{ id: "allotment", label: "Allotment / Property records", department: "Accounts & KYC", sla: "1 business day", requiresAttachment: false, attachmentNote: "Allotment letter optional (PDF, ≤ 10 MB)." },
|
||||
{ id: "inspection", label: "Inspection / Site visit", department: "Inspections", sla: "1 business day", requiresAttachment: false, attachmentNote: "Site photos optional (JPG/PNG, ≤ 10 MB each)." },
|
||||
{ id: "other", label: "Something else", department: "General Support", sla: "1 business day", requiresAttachment: false, attachmentNote: "Attachments optional (≤ 8 MB)." },
|
||||
];
|
||||
|
||||
export type TicketStatus = "open" | "in-progress" | "awaiting-customer" | "resolved" | "closed";
|
||||
|
||||
export type TicketEvent = { status: TicketStatus | "created"; label: string; at: string; by: string };
|
||||
export type Ticket = {
|
||||
id: string;
|
||||
subject: string;
|
||||
category: string; // TicketCategory label
|
||||
department: string;
|
||||
priority: "low" | "medium" | "high" | "urgent";
|
||||
status: TicketStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
agentId: string | null;
|
||||
timeline: TicketEvent[];
|
||||
};
|
||||
|
||||
export const tickets: Ticket[] = [
|
||||
{
|
||||
id: "LUP-48217",
|
||||
subject: "Aadhaar re-upload not reflecting",
|
||||
category: "KYC / Document verification",
|
||||
department: "Accounts & KYC",
|
||||
priority: "high",
|
||||
status: "in-progress",
|
||||
createdAt: "24 Jun 2026",
|
||||
updatedAt: "28 Jun 2026",
|
||||
agentId: "a1",
|
||||
timeline: [
|
||||
{ status: "created", label: "Ticket created", at: "24 Jun, 10:12 AM", by: "You" },
|
||||
{ status: "open", label: "Routed to Accounts & KYC", at: "24 Jun, 10:13 AM", by: "System" },
|
||||
{ status: "in-progress", label: "Priya Nair picked up the ticket", at: "24 Jun, 11:40 AM", by: "Priya Nair" },
|
||||
{ status: "awaiting-customer", label: "Requested a clearer scan", at: "25 Jun, 09:05 AM", by: "Priya Nair" },
|
||||
{ status: "in-progress", label: "You re-uploaded the document", at: "28 Jun, 04:22 PM", by: "You" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "LUP-47980",
|
||||
subject: "Unable to receive login OTP on mobile",
|
||||
category: "Login / Account access",
|
||||
department: "Portal & Login",
|
||||
priority: "urgent",
|
||||
status: "resolved",
|
||||
createdAt: "18 Jun 2026",
|
||||
updatedAt: "19 Jun 2026",
|
||||
agentId: "a3",
|
||||
timeline: [
|
||||
{ status: "created", label: "Ticket created", at: "18 Jun, 08:40 PM", by: "You" },
|
||||
{ status: "in-progress", label: "Sara Khan investigating SMS gateway", at: "18 Jun, 09:02 PM", by: "Sara Khan" },
|
||||
{ status: "resolved", label: "OTP delivery restored", at: "19 Jun, 10:15 AM", by: "Sara Khan" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "LUP-47512",
|
||||
subject: "Duplicate maintenance invoice for June",
|
||||
category: "Billing & payments",
|
||||
department: "Billing",
|
||||
priority: "medium",
|
||||
status: "awaiting-customer",
|
||||
createdAt: "12 Jun 2026",
|
||||
updatedAt: "26 Jun 2026",
|
||||
agentId: "a2",
|
||||
timeline: [
|
||||
{ status: "created", label: "Ticket created", at: "12 Jun, 02:10 PM", by: "You" },
|
||||
{ status: "in-progress", label: "Arjun Mehta reviewing billing run", at: "12 Jun, 03:30 PM", by: "Arjun Mehta" },
|
||||
{ status: "awaiting-customer", label: "Need bank reference for the second debit", at: "26 Jun, 11:00 AM", by: "Arjun Mehta" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "LUP-46330",
|
||||
subject: "Reschedule roof inspection visit",
|
||||
category: "Inspection / Site visit",
|
||||
department: "Inspections",
|
||||
priority: "low",
|
||||
status: "closed",
|
||||
createdAt: "02 Jun 2026",
|
||||
updatedAt: "06 Jun 2026",
|
||||
agentId: "a4",
|
||||
timeline: [
|
||||
{ status: "created", label: "Ticket created", at: "02 Jun, 06:20 PM", by: "You" },
|
||||
{ status: "in-progress", label: "Vikram Rao coordinating slot", at: "03 Jun, 10:00 AM", by: "Vikram Rao" },
|
||||
{ status: "resolved", label: "Visit moved to 09 Jun, 11 AM", at: "04 Jun, 04:45 PM", by: "Vikram Rao" },
|
||||
{ status: "closed", label: "Inspection completed", at: "06 Jun, 01:30 PM", by: "System" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Message Center — inbox threads + messages (with a typing flag).
|
||||
export type ChatMsg = { id: string; from: "me" | "agent"; text: string; at: string };
|
||||
export type SupportThread = {
|
||||
id: string;
|
||||
agentId: string;
|
||||
subject: string;
|
||||
preview: string;
|
||||
unread: number;
|
||||
pinned: boolean;
|
||||
typing: boolean;
|
||||
updatedAt: string;
|
||||
messages: ChatMsg[];
|
||||
};
|
||||
|
||||
export const supportThreads: SupportThread[] = [
|
||||
{
|
||||
id: "t1",
|
||||
agentId: "a1",
|
||||
subject: "KYC — Aadhaar re-upload",
|
||||
preview: "Thanks, I can see the new scan now…",
|
||||
unread: 2,
|
||||
pinned: true,
|
||||
typing: true,
|
||||
updatedAt: "2 min ago",
|
||||
messages: [
|
||||
{ id: "m1", from: "agent", text: "Hi Rajesh, this is Priya from Accounts & KYC. I'm looking into ticket LUP-48217.", at: "11:40 AM" },
|
||||
{ id: "m2", from: "me", text: "Thanks Priya. The earlier scan was blurry so I uploaded a sharper one.", at: "11:42 AM" },
|
||||
{ id: "m3", from: "agent", text: "Thanks, I can see the new scan now. Reviewing it with the verification team.", at: "11:43 AM" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "t2",
|
||||
agentId: "a2",
|
||||
subject: "Billing — duplicate invoice",
|
||||
preview: "Could you share the bank reference number?",
|
||||
unread: 0,
|
||||
pinned: false,
|
||||
typing: false,
|
||||
updatedAt: "Yesterday",
|
||||
messages: [
|
||||
{ id: "m1", from: "agent", text: "Hello! I'm reviewing the June billing run for the duplicate charge.", at: "3:30 PM" },
|
||||
{ id: "m2", from: "agent", text: "Could you share the bank reference number for the second debit?", at: "3:32 PM" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "t3",
|
||||
agentId: "a3",
|
||||
subject: "Login — OTP not received",
|
||||
preview: "Glad it's working now. Closing this out.",
|
||||
unread: 0,
|
||||
pinned: false,
|
||||
typing: false,
|
||||
updatedAt: "19 Jun",
|
||||
messages: [
|
||||
{ id: "m1", from: "me", text: "I stopped getting the OTP on my registered mobile last night.", at: "8:40 PM" },
|
||||
{ id: "m2", from: "agent", text: "There was a gateway issue in your region — fixed now. Please retry.", at: "10:14 AM" },
|
||||
{ id: "m3", from: "me", text: "Working now, thank you!", at: "10:20 AM" },
|
||||
{ id: "m4", from: "agent", text: "Glad it's working now. Closing this out.", at: "10:21 AM" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* HELP CENTER */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
export type HelpArticle = { q: string; a: string };
|
||||
export type HelpTopic = { id: string; title: string; icon: string; accent: string; count: number; articles: HelpArticle[] };
|
||||
|
||||
export const helpTopics: HelpTopic[] = [
|
||||
{
|
||||
id: "getting-started",
|
||||
title: "Getting started",
|
||||
icon: "rocket",
|
||||
accent: "var(--orange)",
|
||||
count: 3,
|
||||
articles: [
|
||||
{ q: "How do I verify my property details?", a: "Open Profile → Personal Info and click ‘Verify to reveal’. Enter your plot number (e.g. 181) to unmask and confirm your records." },
|
||||
{ q: "What does ‘allottee’ mean if I'm a representative?", a: "If you manage the property on someone's behalf, the account holder and the allottee differ. Your relationship is shown on the Personal Info tab." },
|
||||
{ q: "How do I update my profile photo?", a: "Use the camera button on your profile header card. JPG or PNG up to 5 MB." },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "kyc",
|
||||
title: "KYC & verification",
|
||||
icon: "shield",
|
||||
accent: "var(--blue)",
|
||||
count: 4,
|
||||
articles: [
|
||||
{ q: "Which documents do I need for KYC?", a: "A live photo (mandatory), one identity proof and one address proof. The identity and address documents must be two different document types." },
|
||||
{ q: "Can I use Aadhaar for both ID and address?", a: "No. ID and address proofs must be different document types. Aadhaar can fill either slot, but not both at the same time." },
|
||||
{ q: "How long does verification take?", a: "Most documents are reviewed within 4 business hours. You'll be notified on your registered channels." },
|
||||
{ q: "My document was rejected — what now?", a: "Open the rejected slot, read the reason, and re-upload a clearer copy. A support ticket is created automatically for high-priority cases." },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "security",
|
||||
title: "Login & security",
|
||||
icon: "lock",
|
||||
accent: "var(--purple)",
|
||||
count: 4,
|
||||
articles: [
|
||||
{ q: "How do I enable two-factor authentication?", a: "Profile → Security → Two-factor. Enabling 2FA requires both your password and a one-time code for confirmation." },
|
||||
{ q: "I'm not receiving my OTP", a: "OTPs are sent to your registered mobile. Check signal and spam filters; if it persists, raise a ticket under Login / Account access." },
|
||||
{ q: "What is the password policy?", a: "At least 8 characters with upper & lower case, a number and a special character — and no common patterns like ‘1234’ or ‘password’." },
|
||||
{ q: "How do I sign out other devices?", a: "Profile → Devices shows every active session. Use ‘Sign out’ on any device, or ‘Sign out all other devices’." },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "billing",
|
||||
title: "Billing & payments",
|
||||
icon: "card",
|
||||
accent: "var(--green)",
|
||||
count: 3,
|
||||
articles: [
|
||||
{ q: "Where can I download invoices?", a: "Invoices are emailed to your primary email and available under Billing. Receipts are issued within minutes of payment." },
|
||||
{ q: "I was charged twice", a: "Raise a Billing ticket and attach the invoice/receipt. Keep your bank reference handy to speed up the reconciliation." },
|
||||
{ q: "How do I change my billing email?", a: "Add a destination under Notifications and mark it primary for the Billing category in the per-destination matrix." },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* RULES — the 1–13 business-rules checklist */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
export type Rule = { n: number; title: string; detail: string; tag: string; tagColor: string };
|
||||
|
||||
export const rules: Rule[] = [
|
||||
{ n: 1, title: "Verify-to-reveal", detail: "Masked personal details (email, mobile, PAN, Aadhaar) stay hidden until the account holder confirms their plot number. For this account the plot is 181.", tag: "Profile", tagColor: "var(--orange)" },
|
||||
{ n: 2, title: "OTP goes to the registered mobile", detail: "Any sensitive change — email, mobile or password — sends a one-time code to the registered mobile number, never to the new/unverified destination.", tag: "Security", tagColor: "var(--blue)" },
|
||||
{ n: 3, title: "Allottee ≠ account holder", detail: "When the relationship is not ‘Self’, the account holder is a representative and the allottee's name is shown separately. Actions are logged against the account holder.", tag: "Profile", tagColor: "var(--orange)" },
|
||||
{ n: 4, title: "KYC needs two different documents", detail: "Identity proof and address proof must be two different document types. A single document (e.g. Aadhaar) cannot satisfy both slots at once.", tag: "KYC", tagColor: "var(--purple)" },
|
||||
{ n: 5, title: "Live photo is mandatory", detail: "A live photo / selfie is required for KYC completion in addition to the ID and address proofs. Completion percentage never reaches 100% without it.", tag: "KYC", tagColor: "var(--purple)" },
|
||||
{ n: 6, title: "Password policy", detail: "Minimum 8 characters with upper & lower case, a number and a special character. Common patterns like ‘1234’, ‘qwerty’ or ‘password’ are rejected.", tag: "Security", tagColor: "var(--blue)" },
|
||||
{ n: 7, title: "2FA requires password + OTP", detail: "Turning two-factor authentication on or off requires re-entering the password and confirming a one-time code. Authenticator apps and login alerts build on this.", tag: "Security", tagColor: "var(--blue)" },
|
||||
{ n: 8, title: "Ticket lifecycle", detail: "Tickets move Open → In Progress → Awaiting Customer → Resolved → Closed. Every transition is timestamped and attributed in the status timeline.", tag: "Support", tagColor: "var(--green)" },
|
||||
{ n: 9, title: "Category → department routing", detail: "Each ticket category auto-routes to the right department (e.g. KYC → Accounts & KYC) with its own SLA shown before submission.", tag: "Support", tagColor: "var(--green)" },
|
||||
{ n: 10, title: "Attachment rules", detail: "Some categories require an attachment; each defines accepted formats and a maximum file size. The form blocks submission until required attachments are present.", tag: "Support", tagColor: "var(--green)" },
|
||||
{ n: 11, title: "Contact-time windows + timezone", detail: "Callbacks and proactive messages are only sent inside the contact-time windows the user enables, interpreted in their selected timezone.", tag: "Notifications", tagColor: "var(--cyan)" },
|
||||
{ n: 12, title: "Statutory consent is locked", detail: "Essential and statutory consents (Terms of Use, KYC processing) cannot be switched off while the account is active. Marketing consents and sub-toggles are fully optional.", tag: "Privacy", tagColor: "var(--red)" },
|
||||
{ n: 13, title: "New-device login alerts", detail: "Signing in from an untrusted device raises a security alert on the registered channels and lists the device under Devices for review or sign-out.", tag: "Security", tagColor: "var(--blue)" },
|
||||
];
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* password strength (shared with Security tab) */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
export type Strength = { score: number; label: "Weak" | "Fair" | "Good" | "Strong"; checks: { ok: boolean; label: string }[] };
|
||||
export function passwordStrength(pw: string): Strength {
|
||||
const checks = [
|
||||
{ ok: pw.length >= 8, label: "At least 8 characters" },
|
||||
{ ok: /[a-z]/.test(pw) && /[A-Z]/.test(pw), label: "Upper & lower case" },
|
||||
{ ok: /\d/.test(pw), label: "A number" },
|
||||
{ ok: /[^A-Za-z0-9]/.test(pw), label: "A special character" },
|
||||
];
|
||||
const passed = checks.filter((c) => c.ok).length;
|
||||
const weakList = ["1234", "abcd", "password", "qwerty", "0000"];
|
||||
const isWeak = weakList.some((w) => pw.toLowerCase().includes(w));
|
||||
const score = isWeak ? Math.min(passed, 1) : passed;
|
||||
const label = score <= 1 ? "Weak" : score === 2 ? "Fair" : score === 3 ? "Good" : "Strong";
|
||||
return { score, label, checks };
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/* ---- vertical bar chart (HTML/CSS, animated heights) ---- */
|
||||
export function BarsChart({
|
||||
labels,
|
||||
series,
|
||||
max,
|
||||
yTicks,
|
||||
height = 132,
|
||||
highlight,
|
||||
barWidth = 7,
|
||||
}: {
|
||||
labels: string[];
|
||||
series: { color: string; values: number[] }[];
|
||||
max: number;
|
||||
yTicks?: string[];
|
||||
height?: number;
|
||||
highlight?: number; // single-series: index to highlight, others muted
|
||||
barWidth?: number;
|
||||
}) {
|
||||
const [on, setOn] = useState(false);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setOn(true), 40);
|
||||
return () => clearTimeout(t);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="chart" style={{ height }}>
|
||||
{yTicks && (
|
||||
<div className="chart-y">
|
||||
{yTicks.map((t) => <span key={t}>{t}</span>)}
|
||||
</div>
|
||||
)}
|
||||
<div className="chart-plot">
|
||||
{labels.map((lab, i) => (
|
||||
<div className="bar-group" key={i}>
|
||||
<div className="bars">
|
||||
{series.map((s, si) => {
|
||||
const muted = highlight !== undefined && i !== highlight;
|
||||
return (
|
||||
<span
|
||||
key={si}
|
||||
className="vbar"
|
||||
style={{
|
||||
width: barWidth,
|
||||
height: on ? `${Math.max(2, (s.values[i] / max) * 100)}%` : "0%",
|
||||
background: muted ? "var(--track)" : s.color,
|
||||
transitionDelay: `${i * 35 + si * 20}ms`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="bar-x">{lab}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- mini sparkline bars (stat cards) ---- */
|
||||
export function Spark({ values, color, height = 40, width = 8 }: { values: number[]; color: string; height?: number; width?: number }) {
|
||||
const [on, setOn] = useState(false);
|
||||
useEffect(() => { const t = setTimeout(() => setOn(true), 40); return () => clearTimeout(t); }, []);
|
||||
const max = Math.max(...values, 1);
|
||||
return (
|
||||
<div className="spark" style={{ height }}>
|
||||
{values.map((v, i) => (
|
||||
<span key={i} style={{ width, height: on ? `${(v / max) * 100}%` : "0%", background: color, transitionDelay: `${i * 30}ms` }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- donut chart ---- */
|
||||
export function Donut({
|
||||
segments, total, label, size = 150, thickness = 18,
|
||||
}: {
|
||||
segments: { value: number; color: string }[];
|
||||
total: string;
|
||||
label: string;
|
||||
size?: number;
|
||||
thickness?: number;
|
||||
}) {
|
||||
const [on, setOn] = useState(false);
|
||||
useEffect(() => { const t = setTimeout(() => setOn(true), 60); return () => clearTimeout(t); }, []);
|
||||
const r = (size - thickness) / 2;
|
||||
const c = 2 * Math.PI * r;
|
||||
const sum = segments.reduce((a, s) => a + s.value, 0) || 1;
|
||||
let offset = 0;
|
||||
return (
|
||||
<div className="donut" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--track)" strokeWidth={thickness} />
|
||||
{segments.map((s, i) => {
|
||||
const frac = s.value / sum;
|
||||
const len = on ? frac * c : 0;
|
||||
const dash = `${len} ${c - len}`;
|
||||
const el = (
|
||||
<circle
|
||||
key={i}
|
||||
cx={size / 2} cy={size / 2} r={r} fill="none"
|
||||
stroke={s.color} strokeWidth={thickness} strokeLinecap="round"
|
||||
strokeDasharray={dash}
|
||||
strokeDashoffset={-offset * c}
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
style={{ transition: "stroke-dasharray .9s cubic-bezier(.2,.7,.2,1)" }}
|
||||
/>
|
||||
);
|
||||
offset += frac;
|
||||
return el;
|
||||
})}
|
||||
</svg>
|
||||
<div className="donut-c">
|
||||
<div className="dv">{total}</div>
|
||||
<div className="dl">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- semicircle gauge ---- */
|
||||
export function Gauge({
|
||||
value, max = 100, color, size = 150, label, sub,
|
||||
}: {
|
||||
value: number; max?: number; color: string; size?: number; label?: string; sub?: string;
|
||||
}) {
|
||||
const [on, setOn] = useState(false);
|
||||
useEffect(() => { const t = setTimeout(() => setOn(true), 60); return () => clearTimeout(t); }, []);
|
||||
const sw = 14;
|
||||
const r = (size - sw) / 2;
|
||||
const cx = size / 2, cy = size / 2;
|
||||
const semi = Math.PI * r; // half circumference
|
||||
const frac = Math.min(1, value / max);
|
||||
const len = on ? frac * semi : 0;
|
||||
return (
|
||||
<div className="gauge" style={{ width: size, height: size / 2 + 16 }}>
|
||||
<svg width={size} height={size / 2 + 16} viewBox={`0 0 ${size} ${size / 2 + 16}`}>
|
||||
<path d={`M ${sw / 2} ${cy} A ${r} ${r} 0 0 1 ${size - sw / 2} ${cy}`} fill="none" stroke="var(--track)" strokeWidth={sw} strokeLinecap="round" />
|
||||
<path
|
||||
d={`M ${sw / 2} ${cy} A ${r} ${r} 0 0 1 ${size - sw / 2} ${cy}`}
|
||||
fill="none" stroke={color} strokeWidth={sw} strokeLinecap="round"
|
||||
strokeDasharray={`${len} ${semi}`}
|
||||
style={{ transition: "stroke-dasharray 1s cubic-bezier(.2,.7,.2,1)" }}
|
||||
/>
|
||||
</svg>
|
||||
{(label || sub) && (
|
||||
<div className="gauge-c">
|
||||
{label && <div className="gv">{label}</div>}
|
||||
{sub && <div className="gl">{sub}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- progress bar ---- */
|
||||
export function Progress({ value, color, track }: { value: number; color: string; track?: boolean }) {
|
||||
const [on, setOn] = useState(false);
|
||||
useEffect(() => { const t = setTimeout(() => setOn(true), 50); return () => clearTimeout(t); }, []);
|
||||
return (
|
||||
<div className="bar">
|
||||
<span style={{ width: on ? `${value}%` : "0%", background: color, transition: "width .9s cubic-bezier(.2,.7,.2,1)" }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- horizontal segmented bar (pipeline) ---- */
|
||||
export function HBar({ value, max, color }: { value: number; max: number; color: string }) {
|
||||
const [on, setOn] = useState(false);
|
||||
useEffect(() => { const t = setTimeout(() => setOn(true), 50); return () => clearTimeout(t); }, []);
|
||||
return (
|
||||
<div className="bar" style={{ height: 9 }}>
|
||||
<span style={{ width: on ? `${(value / max) * 100}%` : "0%", background: color, transition: "width .8s cubic-bezier(.2,.7,.2,1)" }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,258 +1,53 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================
|
||||
// Dashboard shell — keeps the sidebar + header and swaps the
|
||||
// inner content between the Profile, Support and Rules views.
|
||||
// ============================================================
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { TrendingUp, Cloud, MoreHorizontal, ArrowUpRight } from "lucide-react";
|
||||
import { Sidebar } from "./sidebar";
|
||||
import { Topbar } from "./topbar";
|
||||
import { FigIcon } from "./figicon";
|
||||
import { BarsChart, Spark, Donut, Gauge, Progress, HBar } from "./charts";
|
||||
import { ToastProvider } from "./ui";
|
||||
import { Profile } from "./profile";
|
||||
import { Support } from "./support";
|
||||
import { Rules } from "./rules";
|
||||
import "../../app/dashboard/dashboard.css";
|
||||
|
||||
const C = {
|
||||
orange: "#fda913", orange2: "#fd6d13", yellow: "#ffd60a",
|
||||
cyan: "#09b9c6", green: "#14bc83", blue: "#285ef0", purple: "#9036e9", red: "#f0563f",
|
||||
const HEADINGS: Record<string, { title: string; subtitle: string }> = {
|
||||
profile: { title: "Profile", subtitle: "Your account, identity and preferences" },
|
||||
support: { title: "Support Center", subtitle: "Get help, track requests and find answers" },
|
||||
rules: { title: "Rules Checklist", subtitle: "The 13 rules behind Profile & Support" },
|
||||
};
|
||||
|
||||
const rnd = (v: number, p = 0.12) => Math.max(1, Math.round(v * (1 + (Math.random() * 2 - 1) * p)));
|
||||
const arr = (base: number[], p = 0.14) => base.map((v) => rnd(v, p));
|
||||
|
||||
export function Dashboard() {
|
||||
const [theme, setTheme] = useState<"dark" | "light">("dark");
|
||||
const [active, setActive] = useState("dashboard");
|
||||
const [active, setActive] = useState("profile");
|
||||
|
||||
useEffect(() => {
|
||||
// Sync the persisted theme from localStorage (an external system) on mount.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
try { const t = localStorage.getItem("lup_dash_theme"); if (t === "light" || t === "dark") setTheme(t); } catch {}
|
||||
}, []);
|
||||
function toggle() {
|
||||
setTheme((t) => { const n = t === "dark" ? "light" : "dark"; try { localStorage.setItem("lup_dash_theme", n); } catch {} return n; });
|
||||
}
|
||||
|
||||
// ---- live data ----
|
||||
const [pnl, setPnl] = useState({
|
||||
revenue: [4, 6, 3, 7, 5, 6, 4], gross: [5, 3, 6, 4, 7, 5, 6],
|
||||
comm: [3, 5, 4, 6, 5, 7, 4], net: [4, 6, 5, 3, 6, 4, 7],
|
||||
});
|
||||
const [rev, setRev] = useState([5, 7, 4, 8, 6, 9, 16, 7, 5, 8]);
|
||||
const [sparks, setSparks] = useState({
|
||||
revenue: [4, 7, 5, 9, 6, 10, 8], leads: [6, 4, 8, 5, 9, 6, 10], meets: [5, 8, 6, 9, 7, 10, 8],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setPnl((p) => ({ revenue: arr(p.revenue), gross: arr(p.gross), comm: arr(p.comm), net: arr(p.net) }));
|
||||
setRev((r) => r.map((v, i) => (i === 6 ? 16 : rnd(v, 0.18))));
|
||||
setSparks((s) => ({ revenue: arr(s.revenue), leads: arr(s.leads), meets: arr(s.meets) }));
|
||||
}, 2600);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const days = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
|
||||
const yk = ["8k", "6k", "4k", "2k", "0"];
|
||||
const head = HEADINGS[active] ?? HEADINGS.profile;
|
||||
|
||||
return (
|
||||
<div className="dash-root" data-theme={theme}>
|
||||
<Sidebar active={active} onSelect={setActive} />
|
||||
<div className="dash-main">
|
||||
<Topbar theme={theme} onToggle={toggle} />
|
||||
<Topbar theme={theme} onToggle={toggle} title={head.title} subtitle={head.subtitle} />
|
||||
<div className="dash-content">
|
||||
|
||||
{/* ===== Statistics ===== */}
|
||||
<h2 className="sec-title">Statistics</h2>
|
||||
<div className="grid" style={{ gridTemplateColumns: "1.55fr 1fr", marginBottom: 16 }}>
|
||||
<div className="grid" style={{ gridTemplateColumns: "1fr 1fr 1fr" }}>
|
||||
<BigStat icon={<FigIcon name="i_revenue" size={18} />} ic={C.orange} tag="GROSS PROFIT $114K" value="$626K" label="Revenue" spark={sparks.revenue} sc={C.orange} />
|
||||
<BigStat icon={<FigIcon name="i_hotleads" size={18} />} ic={C.red} tag="REQUIRES ACTION" value="20k" label="Hot Leads" spark={sparks.leads} sc={C.purple} />
|
||||
<BigStat icon={<FigIcon name="i_meets" size={18} />} ic={C.cyan} tag="NEXT 7 DAYS" value="70k" label="Upcoming Meets" spark={sparks.meets} sc={C.cyan} />
|
||||
</div>
|
||||
<div className="grid" style={{ gridTemplateColumns: "1fr 1fr" }}>
|
||||
<MiniStat icon={<FigIcon name="i_collected" size={16} />} ic={C.cyan} value="$311.3k" label="Collected to Date" sub="32 payments" pill="pill-cyan" />
|
||||
<MiniStat icon={<FigIcon name="i_ar" size={16} />} ic={C.orange} value="$325.9k" label="Outstanding AR" sub="25 pending" pill="pill-orange" />
|
||||
<MiniStat icon={<FigIcon name="i_payouts" size={16} />} ic={C.purple} value="$203.7k" label="Pending Payouts" sub="20 pending" pill="pill-blue" />
|
||||
<MiniStat icon={<FigIcon name="i_vendor" size={16} />} ic={C.blue} value="$432.4k" label="YTD Vendor Spend" sub="57 invoices" pill="pill-blue" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== Pipeline / Win / Storm ===== */}
|
||||
<div className="grid" style={{ gridTemplateColumns: "1fr 1fr 1fr", marginBottom: 16 }}>
|
||||
<div className="dcard">
|
||||
<div className="card-h"><h3>Open Pipeline</h3><span className="pill pill-orange">27 leads</span></div>
|
||||
<div className="big-val" style={{ marginBottom: 14 }}>$396K</div>
|
||||
{[["New", 6], ["Contacted", 7], ["Appointment", 7], ["Estimate", 3]].map(([l, n]) => (
|
||||
<div className="pl-row" key={l as string}>
|
||||
<span className="lab">{l}</span>
|
||||
<HBar value={n as number} max={8} color={C.orange} />
|
||||
<span className="n">{n}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="dcard">
|
||||
<div className="card-h"><h3>Win Rate</h3><TrendingUp size={16} color={C.green} /></div>
|
||||
<div className="big-val" style={{ marginBottom: 14 }}>94%</div>
|
||||
<Progress value={94} color={C.cyan} />
|
||||
<div className="row" style={{ gap: 16, marginTop: 14, flexWrap: "wrap" }}>
|
||||
<Legend dot={C.cyan} text="17 Won" />
|
||||
<Legend dot="var(--muted)" text="18 Decided" />
|
||||
<Legend dot={C.red} text="1 Lost" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="dcard">
|
||||
<div className="card-h"><h3>Storm-Attributed Revenue</h3><span className="pill pill-blue">19 Storm Leads</span></div>
|
||||
<div className="big-val" style={{ marginBottom: 14 }}>24%</div>
|
||||
<Progress value={24} color={C.blue} />
|
||||
<div className="lbl" style={{ marginTop: 12 }}>$56K of $626K</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== Roof / Risk / Weather ===== */}
|
||||
<div className="grid" style={{ gridTemplateColumns: "1fr 1.15fr 1.25fr", marginBottom: 16 }}>
|
||||
<div className="dcard">
|
||||
<div className="card-h"><div><h3>Roof Condition</h3><div className="card-sub">Territory health snapshot</div></div></div>
|
||||
<div className="row" style={{ justifyContent: "center", margin: "6px 0 10px" }}>
|
||||
<Donut segments={[{ value: 30, color: C.orange }, { value: 14, color: C.cyan }]} total="44" label="Total" />
|
||||
</div>
|
||||
<div className="row" style={{ gap: 18, justifyContent: "center" }}>
|
||||
<Legend dot={C.orange} text="Good" />
|
||||
<Legend dot={C.cyan} text="Bad" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dcard">
|
||||
<div className="card-h"><div><h3>Risk Index</h3><div className="card-sub">Storm Impact Probability</div></div></div>
|
||||
<div className="row" style={{ justifyContent: "center", margin: "4px 0" }}>
|
||||
<Gauge value={51} max={100} color={C.blue} size={170} label="51" sub="of 100" />
|
||||
</div>
|
||||
<div className="row between" style={{ marginTop: 6 }}>
|
||||
<Readout big="10 mph" small="Wind Exp." />
|
||||
<Readout big="52%" small="Vulnerable" color={C.orange} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dcard">
|
||||
<div className="row between">
|
||||
<div>
|
||||
<div className="row gap-2"><Cloud size={20} color={C.cyan} /><span className="big-val">80°F</span></div>
|
||||
<div className="lbl">Plano, US</div>
|
||||
</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<div className="card-sub">Updated: 02:48 PM</div>
|
||||
<div className="row gap-3" style={{ marginTop: 8, justifyContent: "flex-end" }}>
|
||||
<span className="row gap-2" style={{ fontSize: 12 }}><span style={{ color: "var(--muted)", display: "inline-flex" }}><FigIcon name="i_wind" size={14} /></span> 10 mph</span>
|
||||
<span className="row gap-2" style={{ fontSize: 12 }}><span style={{ color: C.blue, display: "inline-flex" }}><FigIcon name="i_humidity" size={14} /></span> 82%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="wx-hours">
|
||||
{[["12 PM", 94], ["5 PM", 79], ["8 PM", 82], ["10 PM", 90], ["1 AM", 92], ["5 AM", 79]].map(([t, te]) => (
|
||||
<div className="wx-hr" key={t as string}>
|
||||
<div className="t">{t}</div>
|
||||
<Cloud size={16} color="var(--faint)" style={{ display: "block", margin: "6px auto 0" }} />
|
||||
<div className="te">{te}°</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== P&L Summary ===== */}
|
||||
<h2 className="sec-title">P&L Summary</h2>
|
||||
<div className="grid" style={{ gridTemplateColumns: "1fr 1fr 1fr 1fr", marginBottom: 16 }}>
|
||||
<PnlCard title="Revenue" value="$626K" labels={days} yk={yk}
|
||||
series={[{ color: C.orange, values: pnl.revenue }, { color: C.orange2, values: pnl.gross }]} />
|
||||
<PnlCard title="Gross Profit" value="$114K" labels={days} yk={yk}
|
||||
series={[{ color: C.cyan, values: pnl.gross }, { color: C.orange, values: pnl.revenue }]} />
|
||||
<PnlCard title="Commissions" value="$63K" labels={days} yk={yk}
|
||||
series={[{ color: C.blue, values: pnl.comm }, { color: "#6f9bff", values: pnl.net }]} />
|
||||
<PnlCard title="Net Profit" value="$51K" labels={days} yk={yk}
|
||||
series={[{ color: C.orange, values: pnl.net }, { color: C.blue, values: pnl.comm }]} />
|
||||
</div>
|
||||
|
||||
{/* ===== Revenue Potential / Top Reps ===== */}
|
||||
<div className="grid" style={{ gridTemplateColumns: "1.7fr 1fr" }}>
|
||||
<div className="dcard">
|
||||
<div className="card-h">
|
||||
<div><h3>Revenue Potential</h3><div className="card-sub">By neighborhood rating</div></div>
|
||||
<span className="pill pill-orange"><ArrowUpRight size={13} /> live</span>
|
||||
</div>
|
||||
<BarsChart
|
||||
labels={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
|
||||
series={[{ color: C.orange, values: rev }]}
|
||||
max={18} highlight={6} height={210} barWidth={26}
|
||||
yTicks={["$240k", "$180k", "$120k", "$60k", "0"]}
|
||||
/>
|
||||
</div>
|
||||
<div className="dcard">
|
||||
<div className="card-h"><h3>Top Sales Reps</h3><button className="link-btn">View All</button></div>
|
||||
{[["Hannah Reyes", "hannah@lynkeduppro.com", C.orange], ["Cody Tatum", "cody@lynkeduppro.com", C.cyan], ["Travis Boone", "travis@lynkeduppro.com", C.blue]].map(([nm, em, col], i) => (
|
||||
<div className="rep-row" key={nm as string}>
|
||||
<span className="rk">0{i + 1}</span>
|
||||
<span className="av" style={{ background: col as string, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{(nm as string).split(" ").map((x) => x[0]).join("")}</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="nm">{nm}</div>
|
||||
<div className="em">{em}</div>
|
||||
</div>
|
||||
<span className="amt">$112,500</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ToastProvider>
|
||||
{active === "profile" && <Profile />}
|
||||
{active === "support" && <Support />}
|
||||
{active === "rules" && <Rules />}
|
||||
</ToastProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- sub components ---- */
|
||||
function BigStat({ icon, ic, tag, value, label, spark, sc }: { icon: React.ReactNode; ic: string; tag: string; value: string; label: string; spark: number[]; sc: string }) {
|
||||
return (
|
||||
<div className="dcard">
|
||||
<div className="row between" style={{ marginBottom: 14 }}>
|
||||
<span className="stat-ic" style={{ background: `color-mix(in srgb, ${ic} 18%, transparent)`, color: ic }}>{icon}</span>
|
||||
<span className="card-sub" style={{ fontWeight: 600, letterSpacing: "0.04em" }}>{tag}</span>
|
||||
</div>
|
||||
<div className="row between" style={{ alignItems: "flex-end" }}>
|
||||
<div>
|
||||
<div className="big-val">{value}</div>
|
||||
<div className="lbl">{label}</div>
|
||||
</div>
|
||||
<Spark values={spark} color={sc} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniStat({ icon, ic, value, label, sub, pill }: { icon: React.ReactNode; ic: string; value: string; label: string; sub: string; pill: string }) {
|
||||
return (
|
||||
<div className="mini-card">
|
||||
<div className="top">
|
||||
<span className="stat-ic" style={{ width: 30, height: 30, background: `color-mix(in srgb, ${ic} 18%, transparent)`, color: ic }}>{icon}</span>
|
||||
<span className={`pill ${pill}`}>{sub}</span>
|
||||
</div>
|
||||
<div className="mid-val" style={{ marginTop: 12 }}>{value}</div>
|
||||
<div className="lbl">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PnlCard({ title, value, labels, yk, series }: { title: string; value: string; labels: string[]; yk: string[]; series: { color: string; values: number[] }[] }) {
|
||||
return (
|
||||
<div className="dcard">
|
||||
<div className="card-h"><h3>{title}</h3><MoreHorizontal size={16} color="var(--faint)" /></div>
|
||||
<div className="mid-val" style={{ marginBottom: 14 }}>{value}</div>
|
||||
<BarsChart labels={labels} series={series} max={9} yTicks={yk} height={120} barWidth={5} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Legend({ dot, text }: { dot: string; text: string }) {
|
||||
return <span className="row gap-2" style={{ fontSize: 12, color: "var(--muted)" }}><span className="ldot" style={{ background: dot }} /> {text}</span>;
|
||||
}
|
||||
|
||||
function Readout({ big, small, color }: { big: string; small: string; color?: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 800, color: color || "var(--text)" }}>{big}</div>
|
||||
<div className="lbl">{small}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { figmaIcons } from "./figma-icons";
|
||||
|
||||
/** Renders a Figma-exported icon (recolored to currentColor) or a fallback. */
|
||||
export function FigIcon({ name, size = 18 }: { name: string; size?: number }) {
|
||||
const svg = figmaIcons[name];
|
||||
if (!svg) return <Sparkles size={size} />;
|
||||
return (
|
||||
<span
|
||||
className="fig-ic"
|
||||
style={{ width: size, height: size }}
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,700 @@
|
||||
"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="key" size={13} /> Allotment {user.allotmentNo}</span>
|
||||
<span className="hero-chip accent"><Icon name="check" size={13} /> Plot {user.plot}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="profile-hero-stats">
|
||||
<HeroStat value={user.category} label="Category" />
|
||||
<HeroStat value={user.size} label="Plot 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 });
|
||||
// editable account-holder fields
|
||||
const [editId, setEditId] = useState(false);
|
||||
const [holder, setHolder] = useState({ name: user.name, relationship: user.relationship });
|
||||
const [holderDraft, setHolderDraft] = useState(holder);
|
||||
|
||||
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 plot number on file (hint: it's ${user.plot}).`);
|
||||
}
|
||||
|
||||
function mask(kind: ContactKind, full: string) {
|
||||
return kind === "email" ? maskEmail(full) : maskPhone(full);
|
||||
}
|
||||
|
||||
const rows: { key: ContactKind | "pan" | "aadhaar"; label: string; full: string; masked: string; editable: boolean }[] = [
|
||||
{ key: "email", label: "Email", full: contacts.email, masked: mask("email", contacts.email), editable: true },
|
||||
{ key: "mobile", label: "Mobile", full: contacts.mobile, masked: mask("mobile", contacts.mobile), editable: true },
|
||||
{ key: "whatsapp", label: "WhatsApp", full: contacts.whatsapp, masked: mask("whatsapp", contacts.whatsapp), editable: true },
|
||||
{ key: "pan", label: "PAN", full: user.pan.full, masked: user.pan.masked, editable: false },
|
||||
{ key: "aadhaar", label: "Aadhaar", full: user.aadhaar.full, masked: user.aadhaar.masked, editable: false },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid-2">
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<h3>Personal 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 plot number to unmask and edit them.</span>
|
||||
</div>
|
||||
<div className="reveal-gate-form">
|
||||
<input className="ds-input" placeholder="Plot 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">
|
||||
{rows.map((f) => (
|
||||
<div className="kv" key={f.key}>
|
||||
<dt>{f.label}</dt>
|
||||
<dd className={verified ? "kv-edit" : "masked"}>
|
||||
{verified ? f.full : f.masked}
|
||||
{verified && f.editable && (
|
||||
<button className="kv-edit-btn" aria-label={`Edit ${f.label}`} onClick={() => setChange(f.key as ContactKind)}><Icon name="edit" size={13} /></button>
|
||||
)}
|
||||
{verified && !f.editable && <span className="kv-lock"><Icon name="lock" size={12} /> via KYC</span>}
|
||||
</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>Allottee & account holder</h3>
|
||||
{!editId
|
||||
? <Btn variant="outline" size="sm" icon="edit" onClick={() => { setHolderDraft(holder); setEditId(true); }}>Edit</Btn>
|
||||
: <div className="row gap-2"><Btn variant="ghost" size="sm" onClick={() => setEditId(false)}>Cancel</Btn><Btn size="sm" icon="check" onClick={() => { setHolder(holderDraft); setEditId(false); push({ tone: "success", title: "Details saved" }); }}>Save</Btn></div>}
|
||||
</div>
|
||||
{user.isAllottee ? (
|
||||
<div className="callout tone-green">
|
||||
<Icon name="check-circle" size={18} />
|
||||
<div><strong>You are the allottee.</strong><span>The account holder and the allottee are the same person ({holder.relationship}).</span></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="callout tone-orange">
|
||||
<Icon name="info" size={18} />
|
||||
<div><strong>Acting on behalf of the allottee.</strong><span>Account holder differs from the allottee — actions are logged against you.</span></div>
|
||||
</div>
|
||||
)}
|
||||
{editId ? (
|
||||
<div className="edit-grid">
|
||||
<Field label="Account holder name"><input className="ds-input" value={holderDraft.name} onChange={(e) => setHolderDraft((d) => ({ ...d, name: e.target.value }))} /></Field>
|
||||
<Field label="Relationship to allottee">
|
||||
<select className="ds-select" value={holderDraft.relationship} onChange={(e) => setHolderDraft((d) => ({ ...d, relationship: e.target.value }))}>
|
||||
{["Self", "Spouse", "Son / Daughter", "Authorized Representative", "Power of Attorney"].map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
) : (
|
||||
<dl className="kv-list">
|
||||
<div className="kv"><dt>Allottee name</dt><dd>{user.allotteeName}</dd></div>
|
||||
<div className="kv"><dt>Account holder</dt><dd>{holder.name}</dd></div>
|
||||
<div className="kv"><dt>Relationship</dt><dd><Pill tone="blue">{holder.relationship}</Pill></dd></div>
|
||||
<div className="kv"><dt>Allotment no.</dt><dd>{user.allotmentNo}</dd></div>
|
||||
<div className="kv"><dt>Property</dt><dd>{user.sector} · {user.pocket} · Plot {user.plot}</dd></div>
|
||||
</dl>
|
||||
)}
|
||||
</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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================
|
||||
// Rules Checklist — the 1–13 business rules in one place.
|
||||
// (plot 181 · OTP to registered mobile · KYC two-docs ·
|
||||
// password policy · ticket lifecycle · …)
|
||||
// ============================================================
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { rules } from "./account-data";
|
||||
import { Icon, PageHead, Pill, Segmented } from "./ui";
|
||||
|
||||
export function Rules() {
|
||||
const tags = useMemo(() => ["All", ...Array.from(new Set(rules.map((r) => r.tag)))], []);
|
||||
const [tag, setTag] = useState("All");
|
||||
const list = tag === "All" ? rules : rules.filter((r) => r.tag === tag);
|
||||
|
||||
return (
|
||||
<div className="view">
|
||||
<PageHead
|
||||
eyebrow="Reference"
|
||||
title="Rules Checklist"
|
||||
subtitle="The 13 business rules that govern Profile & Support — one source of truth."
|
||||
icon="check-circle"
|
||||
actions={<Pill tone="blue">{rules.length} rules</Pill>}
|
||||
/>
|
||||
|
||||
<Segmented value={tag} onChange={setTag} options={tags.map((t) => ({ value: t, label: t }))} />
|
||||
|
||||
<div className="rules-grid view-body">
|
||||
{list.map((r) => (
|
||||
<div className="card rule-card" key={r.n} style={{ ["--accent" as string]: r.tagColor }}>
|
||||
<div className="rule-top">
|
||||
<span className="rule-n">{String(r.n).padStart(2, "0")}</span>
|
||||
<Pill tone="custom" style={{ color: r.tagColor, background: `color-mix(in srgb, ${r.tagColor} 16%, transparent)` }}>{r.tag}</Pill>
|
||||
</div>
|
||||
<div className="rule-title"><Icon name="check-circle" size={16} /> {r.title}</div>
|
||||
<p className="rule-detail">{r.detail}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronsUpDown } from "lucide-react";
|
||||
import { FigIcon } from "./figicon";
|
||||
import { Icon } from "./ui";
|
||||
import { user } from "./account-data";
|
||||
|
||||
type Item = { key: string; label: string };
|
||||
type Item = { key: string; label: string; icon: string };
|
||||
|
||||
const GROUPS: { title: string; items: Item[] }[] = [
|
||||
{
|
||||
title: "Workspace",
|
||||
title: "Account",
|
||||
items: [
|
||||
{ key: "dashboard", label: "Dashboard" },
|
||||
{ key: "owners", label: "Owners Box" },
|
||||
{ key: "projects", label: "Projects" },
|
||||
{ key: "leads", label: "Leads" },
|
||||
{ key: "verify", label: "Lead Verification" },
|
||||
{ key: "pipeline", label: "Pipeline" },
|
||||
{ key: "profile", label: "Profile", icon: "user" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Workspace",
|
||||
title: "Help & Support",
|
||||
items: [
|
||||
{ key: "dispatch", label: "LynkDispatch" },
|
||||
{ key: "storm", label: "Storm Intel" },
|
||||
{ key: "territory", label: "Territory Map" },
|
||||
{ key: "procanvas", label: "ProCanvas" },
|
||||
{ key: "estimates", label: "Estimates" },
|
||||
{ key: "support", label: "Support Center", icon: "chat" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Team",
|
||||
title: "Reference",
|
||||
items: [
|
||||
{ key: "schedule", label: "Team Schedule" },
|
||||
{ key: "leaderboard", label: "Leaderboard" },
|
||||
{ key: "subtasks", label: "Subcontractor Tasks" },
|
||||
{ key: "people", label: "People" },
|
||||
{ key: "rules", label: "Rules Checklist", icon: "check-circle" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const EXTRA: Item[] = [
|
||||
{ key: "settings", label: "Org Settings" },
|
||||
{ key: "ai", label: "AI Assistant" },
|
||||
{ key: "home", label: "Home" },
|
||||
];
|
||||
|
||||
export function Sidebar({ active, onSelect }: { active: string; onSelect: (k: string) => void }) {
|
||||
return (
|
||||
<aside className="dash-sidebar">
|
||||
@@ -57,32 +40,26 @@ export function Sidebar({ active, onSelect }: { active: string; onSelect: (k: st
|
||||
{GROUPS.map((g, gi) => (
|
||||
<div key={gi}>
|
||||
<div className="nav-group">{g.title}</div>
|
||||
{g.items.map((it) => <NavBtn key={it.key} it={it} active={active} onSelect={onSelect} />)}
|
||||
{g.items.map((it) => (
|
||||
<button key={it.key} className={`nav-item ${active === it.key ? "active" : ""}`} onClick={() => onSelect(it.key)}>
|
||||
<Icon name={it.icon} size={18} />
|
||||
{it.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<div style={{ height: 8 }} />
|
||||
{EXTRA.map((it) => <NavBtn key={it.key} it={it} active={active} onSelect={onSelect} />)}
|
||||
</nav>
|
||||
|
||||
<div className="sb-foot">
|
||||
<div className="sb-user">
|
||||
<span className="av" style={{ background: "linear-gradient(135deg,#285ef0,#09b9c6)", display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>JJ</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="nm">Justin Johnson</div>
|
||||
<div className="rl">Owner</div>
|
||||
<button className="sb-user">
|
||||
<span className="av" style={{ background: user.avatarGradient, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{user.initials}</span>
|
||||
<div style={{ flex: 1, textAlign: "left" }}>
|
||||
<div className="nm">{user.name}</div>
|
||||
<div className="rl">{user.role}</div>
|
||||
</div>
|
||||
<ChevronsUpDown size={15} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function NavBtn({ it, active, onSelect }: { it: Item; active: string; onSelect: (k: string) => void }) {
|
||||
return (
|
||||
<button className={`nav-item ${active === it.key ? "active" : ""}`} onClick={() => onSelect(it.key)}>
|
||||
<FigIcon name={it.key} size={18} />
|
||||
{it.label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================
|
||||
// Support Center
|
||||
// Overview (5 channel cards + team) · Message Center ·
|
||||
// New Ticket · My Tickets (+ timeline modal) · Help Center
|
||||
// plus Live Chat handshake & Callback/Email modals.
|
||||
// ============================================================
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
supportChannels, supportTeam, ticketCategories, tickets as ticketSeed,
|
||||
supportThreads as threadSeed, helpTopics, contactTimes, countryCodes,
|
||||
type Ticket, type TicketStatus, type SupportThread, type ChatMsg,
|
||||
} from "./account-data";
|
||||
import {
|
||||
Avatar, Btn, Field, Icon, Modal, PageHead, Pill, Segmented, StatusDot, useToast,
|
||||
} from "./ui";
|
||||
|
||||
const SECTIONS = [
|
||||
{ value: "overview", label: "Overview", icon: "chat" },
|
||||
{ value: "messages", label: "Messages", icon: "mail" },
|
||||
{ value: "new", label: "New Ticket", icon: "ticket" },
|
||||
{ value: "tickets", label: "My Tickets", icon: "book" },
|
||||
{ value: "help", label: "Help Center", icon: "info" },
|
||||
];
|
||||
|
||||
const STATUS_META: Record<TicketStatus | "created", { label: string; tone: string }> = {
|
||||
created: { label: "Created", tone: "muted" },
|
||||
open: { label: "Open", tone: "blue" },
|
||||
"in-progress": { label: "In Progress", tone: "orange" },
|
||||
"awaiting-customer": { label: "Awaiting You", tone: "purple" },
|
||||
resolved: { label: "Resolved", tone: "green" },
|
||||
closed: { label: "Closed", tone: "muted" },
|
||||
};
|
||||
|
||||
export function Support() {
|
||||
const [section, setSection] = useState("overview");
|
||||
const [chatOpen, setChatOpen] = useState(false);
|
||||
const [callbackOpen, setCallbackOpen] = useState(false);
|
||||
const [emailOpen, setEmailOpen] = useState(false);
|
||||
|
||||
function onChannel(id: string) {
|
||||
if (id === "chat") setChatOpen(true);
|
||||
else if (id === "ticket") setSection("new");
|
||||
else if (id === "callback") setCallbackOpen(true);
|
||||
else if (id === "email") setEmailOpen(true);
|
||||
else if (id === "help") setSection("help");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="view">
|
||||
<PageHead
|
||||
eyebrow="Help & Support"
|
||||
title="Support Center"
|
||||
subtitle="Reach a human, track a request, or find an answer fast."
|
||||
icon="chat"
|
||||
actions={<Btn icon="chat" onClick={() => setChatOpen(true)}>Start live chat</Btn>}
|
||||
/>
|
||||
|
||||
<Segmented options={SECTIONS} value={section} onChange={setSection} />
|
||||
|
||||
<div className="view-body">
|
||||
{section === "overview" && <Overview onChannel={onChannel} />}
|
||||
{section === "messages" && <MessageCenter />}
|
||||
{section === "new" && <NewTicket onDone={() => setSection("tickets")} />}
|
||||
{section === "tickets" && <MyTickets />}
|
||||
{section === "help" && <HelpCenter />}
|
||||
</div>
|
||||
|
||||
<LiveChatModal open={chatOpen} onClose={() => setChatOpen(false)} />
|
||||
<CallbackModal open={callbackOpen} onClose={() => setCallbackOpen(false)} />
|
||||
<EmailModal open={emailOpen} onClose={() => setEmailOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================ */
|
||||
/* Overview — channel cards + support team */
|
||||
/* ============================================================ */
|
||||
|
||||
function Overview({ onChannel }: { onChannel: (id: string) => void }) {
|
||||
return (
|
||||
<div className="grid-side">
|
||||
<div>
|
||||
<div className="channel-grid">
|
||||
{supportChannels.map((c) => (
|
||||
<button key={c.id} className="channel-card" style={{ ["--accent" as string]: c.accent }} onClick={() => onChannel(c.id)}>
|
||||
<span className="channel-ic"><Icon name={c.icon} size={22} /></span>
|
||||
<div className="channel-status"><StatusDot status={c.status} /> {c.status === "online" ? "Available" : c.status}</div>
|
||||
<div className="channel-title">{c.title}</div>
|
||||
<div className="channel-desc">{c.desc}</div>
|
||||
<div className="channel-foot">
|
||||
<span className="channel-meta">{c.meta}</span>
|
||||
<span className="channel-cta">{c.action} <Icon name="arrow" size={14} /></span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-head"><h3>Your support team</h3><Pill tone="green"><StatusDot status="online" /> 2 online</Pill></div>
|
||||
<div className="team-list">
|
||||
{supportTeam.map((a) => (
|
||||
<div className="team-row" key={a.id}>
|
||||
<Avatar initials={a.initials} gradient={a.gradient} size={42} status={a.status} />
|
||||
<div className="team-txt">
|
||||
<div className="team-name">{a.name}</div>
|
||||
<div className="team-role">{a.role}</div>
|
||||
<div className="team-team"><Icon name="shield" size={11} /> {a.team}</div>
|
||||
</div>
|
||||
<div className="team-rating"><Icon name="star" size={13} /> {a.rating}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================ */
|
||||
/* Message Center — inbox + live thread + typing */
|
||||
/* ============================================================ */
|
||||
|
||||
function MessageCenter() {
|
||||
const [threads, setThreads] = useState<SupportThread[]>(threadSeed);
|
||||
const [activeId, setActiveId] = useState(threadSeed[0].id);
|
||||
const [draft, setDraft] = useState("");
|
||||
const endRef = useRef<HTMLDivElement | null>(null);
|
||||
const active = threads.find((t) => t.id === activeId)!;
|
||||
|
||||
function send() {
|
||||
const text = draft.trim();
|
||||
if (!text) return;
|
||||
const msg: ChatMsg = { id: `m${Date.now()}`, from: "me", text, at: "Now" };
|
||||
setThreads((ts) => ts.map((t) => t.id === activeId ? { ...t, messages: [...t.messages, msg], typing: true, preview: text } : t));
|
||||
setDraft("");
|
||||
// simulate agent typing → reply
|
||||
setTimeout(() => {
|
||||
const reply: ChatMsg = { id: `m${Date.now() + 1}`, from: "agent", text: "Thanks — noted. I'll update the ticket and get back to you shortly.", at: "Now" };
|
||||
setThreads((ts) => ts.map((t) => t.id === activeId ? { ...t, messages: [...t.messages, reply], typing: false } : t));
|
||||
endRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, 1800);
|
||||
setTimeout(() => endRef.current?.scrollIntoView({ behavior: "smooth" }), 50);
|
||||
}
|
||||
|
||||
function openThread(id: string) {
|
||||
setActiveId(id);
|
||||
setThreads((ts) => ts.map((t) => t.id === id ? { ...t, unread: 0 } : t));
|
||||
}
|
||||
|
||||
const agent = supportTeam.find((a) => a.id === active.agentId)!;
|
||||
|
||||
return (
|
||||
<div className="card card-pad-0 msgctr">
|
||||
<aside className="msg-inbox">
|
||||
<div className="msg-inbox-head">Inbox <Pill tone="orange">{threads.reduce((n, t) => n + t.unread, 0)}</Pill></div>
|
||||
<div className="msg-inbox-list">
|
||||
{[...threads].sort((a, b) => Number(b.pinned) - Number(a.pinned)).map((t) => {
|
||||
const ta = supportTeam.find((x) => x.id === t.agentId)!;
|
||||
return (
|
||||
<button key={t.id} className={`msg-thread ${t.id === activeId ? "active" : ""}`} onClick={() => openThread(t.id)}>
|
||||
<Avatar initials={ta.initials} gradient={ta.gradient} size={38} status={ta.status} />
|
||||
<div className="msg-thread-txt">
|
||||
<div className="msg-thread-top"><span className="msg-thread-name">{ta.name}</span><span className="msg-thread-time">{t.updatedAt}</span></div>
|
||||
<div className="msg-thread-sub">{t.subject}</div>
|
||||
<div className="msg-thread-prev">{t.typing ? <em className="typing-now">typing…</em> : t.preview}</div>
|
||||
</div>
|
||||
{t.unread > 0 && <span className="msg-unread">{t.unread}</span>}
|
||||
{t.pinned && <Icon name="check" size={12} className="msg-pin" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="msg-thread-pane">
|
||||
<header className="msg-pane-head">
|
||||
<Avatar initials={agent.initials} gradient={agent.gradient} size={40} status={agent.status} />
|
||||
<div className="msg-pane-id">
|
||||
<div className="msg-pane-name">{agent.name}</div>
|
||||
<div className="msg-pane-sub">{active.typing ? <span className="typing-now">typing…</span> : `${agent.role} · ${agent.team}`}</div>
|
||||
</div>
|
||||
<Pill tone="blue">{active.subject}</Pill>
|
||||
</header>
|
||||
|
||||
<div className="msg-stream">
|
||||
{active.messages.map((m) => (
|
||||
<div key={m.id} className={`bubble-row ${m.from}`}>
|
||||
{m.from === "agent" && <Avatar initials={agent.initials} gradient={agent.gradient} size={28} />}
|
||||
<div className="bubble"><p>{m.text}</p><span className="bubble-time">{m.at}</span></div>
|
||||
</div>
|
||||
))}
|
||||
{active.typing && (
|
||||
<div className="bubble-row agent">
|
||||
<Avatar initials={agent.initials} gradient={agent.gradient} size={28} />
|
||||
<div className="bubble typing"><span className="dot" /><span className="dot" /><span className="dot" /></div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
|
||||
<div className="msg-compose">
|
||||
<button className="ds-iconbtn" aria-label="Attach"><Icon name="paperclip" size={18} /></button>
|
||||
<input className="ds-input flush" placeholder="Write a reply…" value={draft} onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => e.key === "Enter" && send()} />
|
||||
<Btn icon="send" onClick={send} disabled={!draft.trim()}>Send</Btn>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================ */
|
||||
/* New Ticket — category → department routing + attachment rules */
|
||||
/* ============================================================ */
|
||||
|
||||
function NewTicket({ onDone }: { onDone: () => void }) {
|
||||
const { push } = useToast();
|
||||
const [catId, setCatId] = useState("");
|
||||
const [subject, setSubject] = useState("");
|
||||
const [desc, setDesc] = useState("");
|
||||
const [priority, setPriority] = useState("medium");
|
||||
const [file, setFile] = useState<string | null>(null);
|
||||
|
||||
const cat = ticketCategories.find((c) => c.id === catId) ?? null;
|
||||
const needAttachment = cat?.requiresAttachment && !file;
|
||||
const canSubmit = catId && subject.trim() && desc.trim() && !needAttachment;
|
||||
|
||||
function submit() {
|
||||
push({ tone: "success", title: "Ticket created", desc: `Routed to ${cat?.department}. We'll reply within ${cat?.sla}.` });
|
||||
onDone();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid-side">
|
||||
<div className="card">
|
||||
<div className="card-head"><h3>Raise a new ticket</h3></div>
|
||||
|
||||
<Field label="What is this about?" required>
|
||||
<select className="ds-select" value={catId} onChange={(e) => { setCatId(e.target.value); setFile(null); }}>
|
||||
<option value="" disabled>Select a category…</option>
|
||||
{ticketCategories.map((c) => <option key={c.id} value={c.id}>{c.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
{cat && (
|
||||
<div className="route-banner">
|
||||
<Icon name="arrow" size={15} />
|
||||
<span>Routes to <strong>{cat.department}</strong></span>
|
||||
<Pill tone="blue"><Icon name="clock" size={12} /> SLA {cat.sla}</Pill>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Field label="Subject" required><input className="ds-input" placeholder="Short summary" value={subject} onChange={(e) => setSubject(e.target.value)} /></Field>
|
||||
<Field label="Describe the issue" required><textarea className="ds-input ds-textarea" rows={4} placeholder="Add as much detail as you can…" value={desc} onChange={(e) => setDesc(e.target.value)} /></Field>
|
||||
|
||||
<Field label="Priority">
|
||||
<Segmented value={priority} onChange={setPriority} options={[
|
||||
{ value: "low", label: "Low" }, { value: "medium", label: "Medium" }, { value: "high", label: "High" }, { value: "urgent", label: "Urgent" },
|
||||
]} />
|
||||
</Field>
|
||||
|
||||
{cat && (
|
||||
<Field label={`Attachment${cat.requiresAttachment ? "" : " (optional)"}`} required={cat.requiresAttachment} error={needAttachment ? "This category requires an attachment." : undefined} hint={cat.attachmentNote}>
|
||||
{file ? (
|
||||
<div className="kyc-file"><Icon name="paperclip" size={14} /><span className="kyc-file-name">{file}</span><button className="ds-iconbtn sm" aria-label="Remove" onClick={() => setFile(null)}><Icon name="trash" size={14} /></button></div>
|
||||
) : (
|
||||
<button className="kyc-drop" onClick={() => setFile("attachment.pdf")}><Icon name="upload" size={16} /> Attach a file</button>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<div className="card-actions"><Btn icon="send" disabled={!canSubmit} onClick={submit}>Submit ticket</Btn></div>
|
||||
</div>
|
||||
|
||||
<div className="card card-muted">
|
||||
<div className="card-head"><h3>How routing works</h3></div>
|
||||
<div className="route-list">
|
||||
{ticketCategories.map((c) => (
|
||||
<div className="route-item" key={c.id}>
|
||||
<div className="route-cat">{c.label}</div>
|
||||
<Icon name="arrow" size={13} />
|
||||
<div className="route-dept">{c.department}</div>
|
||||
{c.requiresAttachment && <Pill tone="orange"><Icon name="paperclip" size={11} /> Attach</Pill>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================ */
|
||||
/* My Tickets — table + status timeline modal */
|
||||
/* ============================================================ */
|
||||
|
||||
function MyTickets() {
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [open, setOpen] = useState<Ticket | null>(null);
|
||||
const list = useMemo(() => filter === "all" ? ticketSeed : ticketSeed.filter((t) => filter === "active" ? !["resolved", "closed"].includes(t.status) : ["resolved", "closed"].includes(t.status)), [filter]);
|
||||
|
||||
return (
|
||||
<div className="card card-pad-0">
|
||||
<div className="card-head pad">
|
||||
<h3>My tickets</h3>
|
||||
<Segmented value={filter} onChange={setFilter} options={[{ value: "all", label: "All" }, { value: "active", label: "Active" }, { value: "closed", label: "Closed" }]} />
|
||||
</div>
|
||||
<div className="ticket-table">
|
||||
<div className="ticket-row ticket-head">
|
||||
<span>Ticket</span><span>Department</span><span>Priority</span><span>Status</span><span>Updated</span>
|
||||
</div>
|
||||
{list.map((t) => (
|
||||
<button key={t.id} className="ticket-row" onClick={() => setOpen(t)}>
|
||||
<span className="t-subject"><span className="t-id">{t.id}</span>{t.subject}</span>
|
||||
<span className="t-dept">{t.department}</span>
|
||||
<span><Pill tone={t.priority === "urgent" ? "red" : t.priority === "high" ? "orange" : t.priority === "medium" ? "blue" : "muted"}>{t.priority}</Pill></span>
|
||||
<span><Pill tone={STATUS_META[t.status].tone}>{STATUS_META[t.status].label}</Pill></span>
|
||||
<span className="t-updated">{t.updatedAt} <Icon name="chevron-right" size={14} /></span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Modal open={open != null} onClose={() => setOpen(null)} icon="ticket" size="md" title={open?.subject ?? ""} subtitle={open ? `${open.id} · ${open.department}` : ""}
|
||||
footer={<><Btn variant="ghost" onClick={() => setOpen(null)}>Close</Btn><Btn icon="chat">Reply in chat</Btn></>}>
|
||||
{open && (
|
||||
<>
|
||||
<div className="ticket-modal-meta">
|
||||
<Pill tone={STATUS_META[open.status].tone}>{STATUS_META[open.status].label}</Pill>
|
||||
<Pill tone="muted">Priority: {open.priority}</Pill>
|
||||
<Pill tone="muted">Opened {open.createdAt}</Pill>
|
||||
</div>
|
||||
<div className="lifecycle">
|
||||
{(["open", "in-progress", "awaiting-customer", "resolved", "closed"] as TicketStatus[]).map((s, i) => {
|
||||
const reached = open.timeline.some((e) => e.status === s) || (s === "open");
|
||||
return <span key={s} className={`lc-step ${reached ? "done" : ""} ${open.status === s ? "current" : ""}`}>{i > 0 && <span className="lc-line" />}<span className="lc-dot" />{STATUS_META[s].label}</span>;
|
||||
})}
|
||||
</div>
|
||||
<div className="timeline">
|
||||
{open.timeline.slice().reverse().map((e, i) => (
|
||||
<div className="tl-item" key={i}>
|
||||
<span className={`tl-dot ev-${e.status}`}><Icon name={e.status === "resolved" || e.status === "closed" ? "check" : e.status === "awaiting-customer" ? "alert" : "chat"} size={12} /></span>
|
||||
<div className="tl-body"><div className="tl-title">{e.label}</div><div className="tl-meta">{e.by}</div><div className="tl-time">{e.at}</div></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================ */
|
||||
/* Help Center — search + browse accordion */
|
||||
/* ============================================================ */
|
||||
|
||||
function HelpCenter() {
|
||||
const [q, setQ] = useState("");
|
||||
const [openId, setOpenId] = useState<string | null>(helpTopics[0].id);
|
||||
const query = q.trim().toLowerCase();
|
||||
|
||||
const results = useMemo(() => {
|
||||
if (!query) return null;
|
||||
const hits: { topic: string; q: string; a: string }[] = [];
|
||||
for (const t of helpTopics) for (const a of t.articles) if ((a.q + a.a).toLowerCase().includes(query)) hits.push({ topic: t.title, ...a });
|
||||
return hits;
|
||||
}, [query]);
|
||||
|
||||
return (
|
||||
<div className="view">
|
||||
<div className="help-search">
|
||||
<Icon name="search" size={18} />
|
||||
<input className="ds-input flush" placeholder="Search guides, FAQs and how-tos…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
{q && <button className="ds-iconbtn sm" aria-label="Clear" onClick={() => setQ("")}><Icon name="x" size={14} /></button>}
|
||||
</div>
|
||||
|
||||
{results ? (
|
||||
<div className="card">
|
||||
<div className="card-head"><h3>{results.length} result{results.length === 1 ? "" : "s"} for “{q}”</h3></div>
|
||||
{results.length === 0 ? <p className="muted-note"><Icon name="info" size={14} /> Nothing matched. Try different keywords or raise a ticket.</p> : (
|
||||
<div className="help-results">
|
||||
{results.map((r, i) => (
|
||||
<details key={i} className="help-acc">
|
||||
<summary><span className="help-q">{r.q}</span><Pill tone="muted">{r.topic}</Pill><Icon name="chevron" size={16} className="acc-chev" /></summary>
|
||||
<p>{r.a}</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="help-topics">
|
||||
{helpTopics.map((t) => (
|
||||
<div className="card help-topic-card" key={t.id} style={{ ["--accent" as string]: t.accent }}>
|
||||
<button className="help-topic-head" onClick={() => setOpenId(openId === t.id ? null : t.id)}>
|
||||
<span className="help-topic-ic"><Icon name={t.icon} size={20} /></span>
|
||||
<div className="help-topic-txt"><div className="help-topic-title">{t.title}</div><div className="help-topic-count">{t.count} articles</div></div>
|
||||
<Icon name="chevron" size={18} className={`acc-chev ${openId === t.id ? "open" : ""}`} />
|
||||
</button>
|
||||
{openId === t.id && (
|
||||
<div className="help-acc-list">
|
||||
{t.articles.map((a, i) => (
|
||||
<details key={i} className="help-acc">
|
||||
<summary><span className="help-q">{a.q}</span><Icon name="chevron" size={15} className="acc-chev" /></summary>
|
||||
<p>{a.a}</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================ */
|
||||
/* Live Chat — connecting → joined handshake */
|
||||
/* ============================================================ */
|
||||
|
||||
function LiveChatModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const [phase, setPhase] = useState<"connecting" | "joined">("connecting");
|
||||
const [msgs, setMsgs] = useState<ChatMsg[]>([]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [agentTyping, setAgentTyping] = useState(false);
|
||||
const agent = supportTeam[0];
|
||||
const endRef = useRef<HTMLDivElement | null>(null);
|
||||
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
|
||||
// run the connecting → joined handshake each time the modal opens.
|
||||
// Resetting + timed phase changes is an animation driven by `open`, so the
|
||||
// synchronous resets here are intentional.
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setPhase("connecting");
|
||||
setMsgs([]);
|
||||
setDraft("");
|
||||
setAgentTyping(false);
|
||||
const t1 = setTimeout(() => {
|
||||
setPhase("joined");
|
||||
setAgentTyping(true);
|
||||
const t2 = setTimeout(() => {
|
||||
setAgentTyping(false);
|
||||
setMsgs([{ id: "g1", from: "agent", text: `Hi! I'm ${agent.name} from ${agent.team}. How can I help you today?`, at: "Now" }]);
|
||||
}, 1400);
|
||||
timers.current.push(t2);
|
||||
}, 2000);
|
||||
timers.current.push(t1);
|
||||
const timersRef = timers.current;
|
||||
return () => { timersRef.forEach(clearTimeout); timers.current = []; };
|
||||
}, [open, agent.name, agent.team]);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
|
||||
function send() {
|
||||
const text = draft.trim();
|
||||
if (!text) return;
|
||||
setMsgs((m) => [...m, { id: `u${Date.now()}`, from: "me", text, at: "Now" }]);
|
||||
setDraft("");
|
||||
setAgentTyping(true);
|
||||
setTimeout(() => {
|
||||
setAgentTyping(false);
|
||||
setMsgs((m) => [...m, { id: `a${Date.now()}`, from: "agent", text: "Got it — let me pull up your account and check that for you.", at: "Now" }]);
|
||||
setTimeout(() => endRef.current?.scrollIntoView({ behavior: "smooth" }), 30);
|
||||
}, 1600);
|
||||
setTimeout(() => endRef.current?.scrollIntoView({ behavior: "smooth" }), 30);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} size="md" icon="chat" title="Live Chat"
|
||||
subtitle={phase === "connecting" ? "Connecting you to the next available agent…" : `Connected · ${agent.name}`}>
|
||||
{phase === "connecting" ? (
|
||||
<div className="chat-connecting">
|
||||
<div className="chat-radar"><Avatar initials={agent.initials} gradient={agent.gradient} size={64} /><span className="radar-ring" /><span className="radar-ring d2" /></div>
|
||||
<div className="chat-connecting-txt">Finding an agent<span className="dots"><span>.</span><span>.</span><span>.</span></span></div>
|
||||
<div className="muted-note">Average wait under 2 minutes</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="livechat">
|
||||
<div className="livechat-joined"><StatusDot status="online" /> {agent.name} joined the chat</div>
|
||||
<div className="msg-stream sm">
|
||||
{msgs.map((m) => (
|
||||
<div key={m.id} className={`bubble-row ${m.from}`}>
|
||||
{m.from === "agent" && <Avatar initials={agent.initials} gradient={agent.gradient} size={26} />}
|
||||
<div className="bubble"><p>{m.text}</p><span className="bubble-time">{m.at}</span></div>
|
||||
</div>
|
||||
))}
|
||||
{agentTyping && <div className="bubble-row agent"><Avatar initials={agent.initials} gradient={agent.gradient} size={26} /><div className="bubble typing"><span className="dot" /><span className="dot" /><span className="dot" /></div></div>}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
<div className="msg-compose">
|
||||
<input className="ds-input flush" placeholder="Type a message…" value={draft} onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => e.key === "Enter" && send()} />
|
||||
<Btn icon="send" onClick={send} disabled={!draft.trim()}>Send</Btn>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================ */
|
||||
/* Callback & Email modals */
|
||||
/* ============================================================ */
|
||||
|
||||
function CallbackModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { push } = useToast();
|
||||
const [cc, setCc] = useState("+91");
|
||||
const [num, setNum] = useState("98765 43012");
|
||||
const [slot, setSlot] = useState(contactTimes.find((t) => t.enabled)?.id ?? contactTimes[0].id);
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} icon="phone" title="Request a callback" subtitle="We'll call within your chosen contact-time window."
|
||||
footer={<><Btn variant="ghost" onClick={onClose}>Cancel</Btn><Btn icon="phone" onClick={() => { push({ tone: "success", title: "Callback scheduled", desc: "An agent will call you in your selected window." }); onClose(); }}>Schedule callback</Btn></>}>
|
||||
<Field label="Phone number">
|
||||
<div className="phone-input">
|
||||
<select className="ds-select cc" value={cc} onChange={(e) => setCc(e.target.value)}>{countryCodes.map((c) => <option key={c.code} value={c.code}>{c.flag} {c.code}</option>)}</select>
|
||||
<input className="ds-input flush" value={num} onChange={(e) => setNum(e.target.value)} />
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Preferred window" hint="Only windows enabled in your notification preferences are offered.">
|
||||
<div className="cb-slots">
|
||||
{contactTimes.filter((t) => t.enabled).map((t) => (
|
||||
<button key={t.id} className={`cb-slot ${slot === t.id ? "active" : ""}`} onClick={() => setSlot(t.id)}>
|
||||
<span className="cb-slot-l">{t.label}</span><span className="cb-slot-r">{t.range}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function EmailModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { push } = useToast();
|
||||
const [subject, setSubject] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} icon="mail" title="Email support" subtitle="Sends to care@lynkeduppro.com · reply within 1 business day."
|
||||
footer={<><Btn variant="ghost" onClick={onClose}>Cancel</Btn><Btn icon="send" disabled={!subject.trim() || !body.trim()} onClick={() => { push({ tone: "success", title: "Email sent", desc: "We'll reply to your registered email." }); onClose(); }}>Send email</Btn></>}>
|
||||
<Field label="Subject"><input className="ds-input" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="How can we help?" /></Field>
|
||||
<Field label="Message"><textarea className="ds-input ds-textarea" rows={5} value={body} onChange={(e) => setBody(e.target.value)} placeholder="Write your message…" /></Field>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { Sun, ChevronDown } from "lucide-react";
|
||||
import { FigIcon } from "./figicon";
|
||||
import { Sun, Moon, ChevronDown } from "lucide-react";
|
||||
import { Icon } from "./ui";
|
||||
import { user } from "./account-data";
|
||||
|
||||
export function Topbar({ theme, onToggle }: { theme: "dark" | "light"; onToggle: () => void }) {
|
||||
export function Topbar({ theme, onToggle, title, subtitle }: { theme: "dark" | "light"; onToggle: () => void; title: string; subtitle: string }) {
|
||||
return (
|
||||
<header className="dash-topbar">
|
||||
<div className="dash-title">
|
||||
<h1>Dashboard Overview</h1>
|
||||
<p>Welcome back, here's what's happening with your territory</p>
|
||||
<h1>{title}</h1>
|
||||
<p>{subtitle}</p>
|
||||
</div>
|
||||
<div className="top-actions">
|
||||
<button className="ic-btn" aria-label="Search"><FigIcon name="i_search" size={18} /></button>
|
||||
<button className="ic-btn" aria-label="Search"><Icon name="search" size={18} /></button>
|
||||
<button className="ic-btn" aria-label="Toggle theme" onClick={onToggle}>
|
||||
{theme === "dark" ? <FigIcon name="i_theme" size={18} /> : <Sun size={18} />}
|
||||
{theme === "dark" ? <Moon size={18} /> : <Sun size={18} />}
|
||||
</button>
|
||||
<button className="ic-btn" aria-label="Notifications" style={{ position: "relative" }}>
|
||||
<FigIcon name="i_bell" size={18} />
|
||||
<Icon name="bell" size={18} />
|
||||
<span style={{ position: "absolute", top: 9, right: 10, width: 7, height: 7, borderRadius: 99, background: "var(--orange)", border: "2px solid var(--panel)" }} />
|
||||
</button>
|
||||
<button className="top-user">
|
||||
<span className="av" style={{ background: "linear-gradient(135deg,#fda913,#fd6d13)", display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>JJ</span>
|
||||
<span className="av" style={{ background: user.avatarGradient, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{user.initials}</span>
|
||||
<div style={{ textAlign: "left" }}>
|
||||
<div className="nm">Justin Johnson</div>
|
||||
<div className="rl">Owner</div>
|
||||
<div className="nm">{user.name}</div>
|
||||
<div className="rl">{user.role}</div>
|
||||
</div>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================
|
||||
// LynkedUp Pro — shared design-system primitives.
|
||||
// Tokens live in dashboard.css (.dash-root). These components
|
||||
// only consume CSS variables so they theme automatically.
|
||||
//
|
||||
// Exports: Icon, Avatar, PageHead, Pill, Toggle, OtpField,
|
||||
// Modal, ToastProvider/useToast, Field, SegTabs,
|
||||
// StatusDot, Segmented.
|
||||
// ============================================================
|
||||
|
||||
import {
|
||||
createContext, useCallback, useContext, useEffect, useId,
|
||||
useRef, useState, type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
MessageCircle, Ticket, Phone, Mail, BookOpen, Rocket, Shield, ShieldCheck,
|
||||
Lock, CreditCard, User, Bell, Eye, EyeOff, Camera, Upload, Plus, Star, Send,
|
||||
Paperclip, Clock, MapPin, Monitor, Smartphone, Tablet, LogOut, ArrowRight,
|
||||
Info, Check, X, Search, ChevronDown, ChevronRight, Trash2, Globe,
|
||||
CheckCircle2, KeyRound, Pencil, Copy, RefreshCw, AlertTriangle, type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* Icon — single named entry point used across the module */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
const ICONS: Record<string, LucideIcon> = {
|
||||
chat: MessageCircle, ticket: Ticket, phone: Phone, mail: Mail, book: BookOpen,
|
||||
rocket: Rocket, shield: Shield, "shield-check": ShieldCheck, lock: Lock,
|
||||
card: CreditCard, user: User, bell: Bell, eye: Eye, "eye-off": EyeOff,
|
||||
camera: Camera, upload: Upload, plus: Plus, star: Star, send: Send,
|
||||
paperclip: Paperclip, clock: Clock, pin: MapPin, monitor: Monitor,
|
||||
mobile: Smartphone, tablet: Tablet, logout: LogOut, arrow: ArrowRight,
|
||||
info: Info, check: Check, x: X, search: Search, chevron: ChevronDown,
|
||||
"chevron-right": ChevronRight, trash: Trash2, globe: Globe,
|
||||
"check-circle": CheckCircle2, key: KeyRound, edit: Pencil, copy: Copy,
|
||||
refresh: RefreshCw, alert: AlertTriangle, privacy: ShieldCheck, devices: Monitor,
|
||||
desktop: Monitor,
|
||||
};
|
||||
|
||||
export function Icon({ name, size = 18, className, strokeWidth = 2 }: { name: string; size?: number; className?: string; strokeWidth?: number }) {
|
||||
const C = ICONS[name] ?? Info;
|
||||
return <C size={size} className={className} strokeWidth={strokeWidth} />;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* Avatar */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
export function Avatar({
|
||||
initials, gradient = "linear-gradient(135deg,#fda913,#fd6d13)", size = 40, status, square = false,
|
||||
}: { initials: string; gradient?: string; size?: number; status?: "online" | "away" | "offline" | "busy"; square?: boolean }) {
|
||||
return (
|
||||
<span className="ds-avatar" style={{ width: size, height: size, borderRadius: square ? size * 0.28 : "50%", fontSize: size * 0.36 }}>
|
||||
<span className="ds-avatar-bg" style={{ background: gradient, borderRadius: "inherit" }}>{initials}</span>
|
||||
{status && <span className={`ds-avatar-dot status-${status}`} style={{ width: size * 0.28, height: size * 0.28 }} />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* PageHead — section title block used at the top of each view */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
export function PageHead({ eyebrow, title, subtitle, icon, actions }: { eyebrow?: string; title: string; subtitle?: string; icon?: string; actions?: ReactNode }) {
|
||||
return (
|
||||
<div className="ds-pagehead">
|
||||
<div className="ds-pagehead-l">
|
||||
{icon && <span className="ds-pagehead-ic"><Icon name={icon} size={22} /></span>}
|
||||
<div>
|
||||
{eyebrow && <div className="ds-eyebrow">{eyebrow}</div>}
|
||||
<h1>{title}</h1>
|
||||
{subtitle && <p>{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="ds-pagehead-actions">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* Pill / StatusDot */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
export function Pill({ children, tone = "muted", style }: { children: ReactNode; tone?: string; style?: React.CSSProperties }) {
|
||||
return <span className={`ds-pill tone-${tone}`} style={style}>{children}</span>;
|
||||
}
|
||||
|
||||
export function StatusDot({ status }: { status: "online" | "away" | "offline" | "busy" }) {
|
||||
return <span className={`ds-statusdot status-${status}`} />;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* Toggle */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
export function Toggle({ checked, onChange, disabled, label }: { checked: boolean; onChange?: (v: boolean) => void; disabled?: boolean; label?: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button" role="switch" aria-checked={checked} aria-label={label}
|
||||
className={`ds-toggle ${checked ? "on" : ""} ${disabled ? "disabled" : ""}`}
|
||||
onClick={() => !disabled && onChange?.(!checked)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className="knob" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* Field — labelled input wrapper */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
export function Field({ label, hint, error, children, required }: { label: string; hint?: string; error?: string; children: ReactNode; required?: boolean }) {
|
||||
return (
|
||||
<label className="ds-field">
|
||||
<span className="ds-field-lbl">{label}{required && <i className="req">*</i>}</span>
|
||||
{children}
|
||||
{error ? <span className="ds-field-err"><Icon name="alert" size={12} /> {error}</span> : hint ? <span className="ds-field-hint">{hint}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* Segmented / SegTabs */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
export function Segmented({ options, value, onChange }: { options: { value: string; label: string; icon?: string }[]; value: string; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<div className="ds-segmented" role="tablist">
|
||||
{options.map((o) => (
|
||||
<button key={o.value} role="tab" aria-selected={value === o.value} className={`seg ${value === o.value ? "active" : ""}`} onClick={() => onChange(o.value)}>
|
||||
{o.icon && <Icon name={o.icon} size={15} />} {o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SegTabs({ tabs, value, onChange }: { tabs: { value: string; label: string; icon?: string; badge?: number }[]; value: string; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<div className="ds-tabs" role="tablist">
|
||||
{tabs.map((t) => (
|
||||
<button key={t.value} role="tab" aria-selected={value === t.value} className={`ds-tab ${value === t.value ? "active" : ""}`} onClick={() => onChange(t.value)}>
|
||||
{t.icon && <Icon name={t.icon} size={16} />}
|
||||
<span>{t.label}</span>
|
||||
{t.badge != null && t.badge > 0 && <span className="ds-tab-badge">{t.badge}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* OtpField — N-digit one-time-code input */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
export function OtpField({ length = 6, value, onChange, autoFocus = true }: { length?: number; value: string; onChange: (v: string) => void; autoFocus?: boolean }) {
|
||||
const refs = useRef<(HTMLInputElement | null)[]>([]);
|
||||
useEffect(() => { if (autoFocus) refs.current[0]?.focus(); }, [autoFocus]);
|
||||
|
||||
function setAt(i: number, char: string) {
|
||||
const next = value.split("");
|
||||
next[i] = char;
|
||||
const joined = next.join("").slice(0, length);
|
||||
onChange(joined);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ds-otp">
|
||||
{Array.from({ length }).map((_, i) => (
|
||||
<input
|
||||
key={i}
|
||||
ref={(el) => { refs.current[i] = el; }}
|
||||
inputMode="numeric"
|
||||
maxLength={1}
|
||||
className="ds-otp-box"
|
||||
value={value[i] ?? ""}
|
||||
onChange={(e) => {
|
||||
const d = e.target.value.replace(/\D/g, "");
|
||||
if (!d) { setAt(i, ""); return; }
|
||||
// support paste of full code
|
||||
if (d.length > 1) {
|
||||
onChange((value.slice(0, i) + d).slice(0, length));
|
||||
refs.current[Math.min(i + d.length, length - 1)]?.focus();
|
||||
return;
|
||||
}
|
||||
setAt(i, d);
|
||||
refs.current[Math.min(i + 1, length - 1)]?.focus();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Backspace" && !value[i] && i > 0) refs.current[i - 1]?.focus();
|
||||
if (e.key === "ArrowLeft" && i > 0) refs.current[i - 1]?.focus();
|
||||
if (e.key === "ArrowRight" && i < length - 1) refs.current[i + 1]?.focus();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* Modal */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
export function Modal({ open, onClose, title, subtitle, icon, children, footer, size = "md" }: {
|
||||
open: boolean; onClose: () => void; title: string; subtitle?: string; icon?: string;
|
||||
children: ReactNode; footer?: ReactNode; size?: "sm" | "md" | "lg";
|
||||
}) {
|
||||
const titleId = useId();
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="ds-modal-overlay" onMouseDown={onClose}>
|
||||
<div className={`ds-modal size-${size}`} role="dialog" aria-modal="true" aria-labelledby={titleId} onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div className="ds-modal-head">
|
||||
<div className="ds-modal-head-l">
|
||||
{icon && <span className="ds-modal-ic"><Icon name={icon} size={18} /></span>}
|
||||
<div>
|
||||
<h3 id={titleId}>{title}</h3>
|
||||
{subtitle && <p>{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<button className="ds-iconbtn" aria-label="Close" onClick={onClose}><Icon name="x" size={18} /></button>
|
||||
</div>
|
||||
<div className="ds-modal-body">{children}</div>
|
||||
{footer && <div className="ds-modal-foot">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* Toast */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
type Toast = { id: number; tone: "success" | "info" | "error"; title: string; desc?: string };
|
||||
type ToastCtx = { push: (t: Omit<Toast, "id">) => void };
|
||||
const ToastContext = createContext<ToastCtx | null>(null);
|
||||
|
||||
export function useToast() {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) return { push: () => {} };
|
||||
return ctx;
|
||||
}
|
||||
|
||||
let toastSeq = 1;
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [items, setItems] = useState<Toast[]>([]);
|
||||
const push = useCallback((t: Omit<Toast, "id">) => {
|
||||
const id = toastSeq++;
|
||||
setItems((s) => [...s, { ...t, id }]);
|
||||
setTimeout(() => setItems((s) => s.filter((x) => x.id !== id)), 3800);
|
||||
}, []);
|
||||
return (
|
||||
<ToastContext.Provider value={{ push }}>
|
||||
{children}
|
||||
<div className="ds-toasts">
|
||||
{items.map((t) => (
|
||||
<div key={t.id} className={`ds-toast tone-${t.tone}`}>
|
||||
<Icon name={t.tone === "success" ? "check-circle" : t.tone === "error" ? "alert" : "info"} size={18} />
|
||||
<div className="ds-toast-body">
|
||||
<div className="ds-toast-title">{t.title}</div>
|
||||
{t.desc && <div className="ds-toast-desc">{t.desc}</div>}
|
||||
</div>
|
||||
<button className="ds-toast-x" aria-label="Dismiss" onClick={() => setItems((s) => s.filter((x) => x.id !== t.id))}><Icon name="x" size={14} /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* Button — light helper so call sites stay terse */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
export function Btn({ children, variant = "primary", icon, iconRight, onClick, disabled, type = "button", full, size = "md" }: {
|
||||
children: ReactNode; variant?: "primary" | "ghost" | "soft" | "danger" | "outline";
|
||||
icon?: string; iconRight?: string; onClick?: () => void; disabled?: boolean;
|
||||
type?: "button" | "submit"; full?: boolean; size?: "sm" | "md";
|
||||
}) {
|
||||
return (
|
||||
<button type={type} className={`ds-btn v-${variant} s-${size} ${full ? "full" : ""}`} onClick={onClick} disabled={disabled}>
|
||||
{icon && <Icon name={icon} size={size === "sm" ? 14 : 16} />}
|
||||
{children}
|
||||
{iconRight && <Icon name={iconRight} size={size === "sm" ? 14 : 16} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user