Files
lynkeduppro-crm/src/components/dashboard/ui.tsx
T
Goutam 0b76c97445 Redesign Team, Support, AI & Rules; solid-orange brand system
- Team Management: crew hero, member gallery + list, role rings, invites
- Support Center: command-center Overview (live status, pulse ribbon, signal)
- AI Assistant: animated orb rings + textured stage
- Rules: rulebook cards (watermark, uniform orange), 3-up responsive grid
- Brand: --grad-brand now solid rgba(253,169,19,.92), white text/icons on
  orange, no orange gradients; dark surfaces (--card-grad/--panel-2) #060608
- Segmented tabs get an on-brand orange active state

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:40:29 +05:30

310 lines
14 KiB
TypeScript

"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,
LayoutDashboard, Building2, FolderKanban, UserPlus, BadgeCheck, Filter,
Truck, CloudLightning, Map as MapIcon, PenTool, Calculator, CalendarDays,
Trophy, ListChecks, Users, Settings, Sparkles, MoreHorizontal,
UsersRound, 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,
// sidebar / workspace nav
dashboard: LayoutDashboard, owners: Building2, projects: FolderKanban,
leads: UserPlus, verify: BadgeCheck, pipeline: Filter, dispatch: Truck,
storm: CloudLightning, territory: MapIcon, procanvas: PenTool,
estimates: Calculator, schedule: CalendarDays, leaderboard: Trophy,
subtasks: ListChecks, people: Users, settings: Settings, ai: Sparkles,
team: UsersRound, dots: MoreHorizontal,
};
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>
);
}