forked from Goutam/lynkeduppro-crm
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,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