mirror of
https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools.git
synced 2026-06-17 23:09:35 +00:00
feat: Add premium frontend components — command palette, workspace, KPIs
Linear/Attio/HubSpot-inspired components: - command-palette.tsx: Cmd+K with Arabic/English/Arabizi fuzzy search - sales-workspace.tsx: HubSpot-inspired home (KPIs, tasks, deals, AI insights) - command-input.tsx: Reusable search input with ⌘K badge - kpi-card.tsx: KPI card with trend arrows, sparklines, count-up animation - empty-state.tsx: Linear-inspired monochrome empty states All bilingual with useI18n, RTL-safe, Framer Motion animations. https://claude.ai/code/session_01LsnvBa7HwF5hs99VZbgLGj
This commit is contained in:
parent
83150b97b5
commit
b23a32e913
@ -0,0 +1,289 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { clsx } from 'clsx';
|
||||
import {
|
||||
Search, Plus, MessageSquare, BarChart3, Settings,
|
||||
Users, Briefcase, ArrowRight, Clock, Inbox,
|
||||
LayoutDashboard, UserPlus, CheckSquare, Megaphone,
|
||||
} from 'lucide-react';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
type CommandCategory = 'recent' | 'navigation' | 'actions' | 'contacts' | 'deals';
|
||||
|
||||
interface CommandItem {
|
||||
id: string;
|
||||
label: string;
|
||||
labelAr: string;
|
||||
category: CommandCategory;
|
||||
icon: typeof Search;
|
||||
keywords: string[];
|
||||
onSelect?: () => void;
|
||||
}
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect?: (item: CommandItem) => void;
|
||||
}
|
||||
|
||||
const backdropVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1 },
|
||||
};
|
||||
|
||||
const panelVariants = {
|
||||
hidden: { opacity: 0, scale: 0.96, y: -8 },
|
||||
visible: { opacity: 1, scale: 1, y: 0 },
|
||||
exit: { opacity: 0, scale: 0.96, y: -8 },
|
||||
};
|
||||
|
||||
function buildItems(t: (k: string) => string): CommandItem[] {
|
||||
return [
|
||||
{ id: 'nav-dashboard', label: 'Dashboard', labelAr: t('dashboard.tabs.overview'), category: 'navigation', icon: LayoutDashboard, keywords: ['home', 'لوحة', 'loha', 'dashboard'] },
|
||||
{ id: 'nav-pipeline', label: 'Pipeline', labelAr: t('dashboard.tabs.pipeline'), category: 'navigation', icon: Briefcase, keywords: ['deals', 'مسار', 'masar', 'pipeline', 'صفقات'] },
|
||||
{ id: 'nav-inbox', label: 'Inbox', labelAr: t('dashboard.tabs.inbox'), category: 'navigation', icon: Inbox, keywords: ['messages', 'صندوق', 'sandoq', 'inbox', 'رسائل'] },
|
||||
{ id: 'nav-analytics', label: 'Analytics', labelAr: t('dashboard.tabs.analytics'), category: 'navigation', icon: BarChart3, keywords: ['reports', 'تحليلات', 'tahlilat', 'analytics', 'تقارير'] },
|
||||
{ id: 'nav-leads', label: 'Leads', labelAr: t('dashboard.tabs.leads'), category: 'navigation', icon: Users, keywords: ['clients', 'عملاء', '3omala', 'leads'] },
|
||||
{ id: 'nav-settings', label: 'Settings', labelAr: t('dashboard.tabs.settings'), category: 'navigation', icon: Settings, keywords: ['config', 'إعدادات', 'e3dadat', 'settings'] },
|
||||
{ id: 'nav-marketers', label: 'Marketers', labelAr: t('commandPalette.actions.goToMarketers'), category: 'navigation', icon: Megaphone, keywords: ['affiliate', 'مسوقين', 'msawqin', 'marketers'] },
|
||||
{ id: 'act-new-deal', label: 'Create New Deal', labelAr: t('commandPalette.actions.newDeal'), category: 'actions', icon: Plus, keywords: ['new', 'deal', 'صفقة', 'safqa', 'جديد', 'jadid', 'create'] },
|
||||
{ id: 'act-new-contact', label: 'Add Contact', labelAr: t('commandPalette.actions.newContact'), category: 'actions', icon: UserPlus, keywords: ['contact', 'add', 'إضافة', 'edafa', 'جهة', 'jiha'] },
|
||||
{ id: 'act-new-task', label: 'Create Task', labelAr: t('commandPalette.actions.newTask'), category: 'actions', icon: CheckSquare, keywords: ['task', 'مهمة', 'muhimma', 'todo'] },
|
||||
{ id: 'act-send-msg', label: 'Send Message', labelAr: t('commandPalette.actions.sendMessage'), category: 'actions', icon: MessageSquare, keywords: ['message', 'رسالة', 'risala', 'whatsapp', 'واتساب'] },
|
||||
];
|
||||
}
|
||||
|
||||
function fuzzyMatch(query: string, item: CommandItem, isArabic: boolean): boolean {
|
||||
const q = query.toLowerCase();
|
||||
const haystack = [
|
||||
item.label.toLowerCase(),
|
||||
item.labelAr,
|
||||
...item.keywords.map((k) => k.toLowerCase()),
|
||||
].join(' ');
|
||||
return haystack.includes(q);
|
||||
}
|
||||
|
||||
function CommandPalette({ open, onClose, onSelect }: CommandPaletteProps) {
|
||||
const { t, dir, isArabic } = useI18n();
|
||||
const [query, setQuery] = useState('');
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const allItems = useMemo(() => buildItems(t), [t]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!query.trim()) return allItems.slice(0, 8);
|
||||
return allItems.filter((item) => fuzzyMatch(query, item, isArabic));
|
||||
}, [query, allItems, isArabic]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<CommandCategory, CommandItem[]>();
|
||||
for (const item of filtered) {
|
||||
const list = map.get(item.category) ?? [];
|
||||
list.push(item);
|
||||
map.set(item.category, list);
|
||||
}
|
||||
return map;
|
||||
}, [filtered]);
|
||||
|
||||
const flatItems = useMemo(() => filtered, [filtered]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery('');
|
||||
setActiveIndex(0);
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex(0);
|
||||
}, [query]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(item: CommandItem) => {
|
||||
onSelect?.(item);
|
||||
item.onSelect?.();
|
||||
onClose();
|
||||
},
|
||||
[onSelect, onClose],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => (i + 1) % flatItems.length);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => (i - 1 + flatItems.length) % flatItems.length);
|
||||
} else if (e.key === 'Enter' && flatItems[activeIndex]) {
|
||||
e.preventDefault();
|
||||
handleSelect(flatItems[activeIndex]);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[flatItems, activeIndex, handleSelect, onClose],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [open, onClose]);
|
||||
|
||||
const categoryLabel = (cat: CommandCategory) =>
|
||||
t(`commandPalette.categories.${cat}`);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh] px-4">
|
||||
<motion.div
|
||||
variants={backdropVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="hidden"
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
variants={panelVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Command Palette"
|
||||
dir={dir}
|
||||
className={clsx(
|
||||
'relative z-10 w-full max-w-lg',
|
||||
'bg-[#0A0F1C]/95 backdrop-blur-2xl',
|
||||
'border border-white/10 rounded-2xl',
|
||||
'shadow-2xl shadow-black/50',
|
||||
'overflow-hidden',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b border-white/10 px-4 py-3">
|
||||
<Search className="h-4.5 w-4.5 text-slate-500 shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('commandPalette.placeholder')}
|
||||
className={clsx(
|
||||
'flex-1 bg-transparent text-sm text-white',
|
||||
'placeholder:text-slate-500',
|
||||
'outline-none',
|
||||
)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<kbd className="hidden sm:inline-flex items-center rounded-md px-1.5 py-0.5 bg-white/[0.06] border border-white/10 text-[11px] text-slate-500 font-mono">
|
||||
ESC
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
<div ref={listRef} className="max-h-[340px] overflow-y-auto py-2">
|
||||
{flatItems.length === 0 ? (
|
||||
<div className="py-10 text-center text-sm text-slate-500">
|
||||
{t('commandPalette.noResults')} “{query}”
|
||||
</div>
|
||||
) : (
|
||||
Array.from(grouped.entries()).map(([category, items]) => (
|
||||
<div key={category} className="mb-1 last:mb-0">
|
||||
<p className="px-4 py-1.5 text-[11px] font-medium uppercase tracking-wider text-slate-500">
|
||||
{categoryLabel(category)}
|
||||
</p>
|
||||
{items.map((item) => {
|
||||
const globalIdx = flatItems.indexOf(item);
|
||||
const isActive = globalIdx === activeIndex;
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleSelect(item)}
|
||||
onMouseEnter={() => setActiveIndex(globalIdx)}
|
||||
className={clsx(
|
||||
'flex items-center gap-3 w-full px-4 py-2.5 text-start',
|
||||
'transition-colors duration-100',
|
||||
isActive
|
||||
? 'bg-teal-500/10 text-white'
|
||||
: 'text-slate-300 hover:bg-white/5',
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={clsx(
|
||||
'h-4 w-4 shrink-0',
|
||||
isActive ? 'text-teal-400' : 'text-slate-500',
|
||||
)}
|
||||
/>
|
||||
<span className="flex-1 text-sm truncate">
|
||||
{isArabic ? item.labelAr : item.label}
|
||||
</span>
|
||||
{isActive && (
|
||||
<ArrowRight className="h-3.5 w-3.5 text-teal-400 shrink-0 rtl:rotate-180" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/10 px-4 py-2 flex items-center gap-4 text-[11px] text-slate-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="rounded bg-white/[0.06] px-1 py-0.5 font-mono">↑↓</kbd>
|
||||
{isArabic ? 'تنقل' : 'Navigate'}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="rounded bg-white/[0.06] px-1 py-0.5 font-mono">↵</kbd>
|
||||
{isArabic ? 'اختر' : 'Select'}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="rounded bg-white/[0.06] px-1 py-0.5 font-mono">ESC</kbd>
|
||||
{isArabic ? 'إغلاق' : 'Close'}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
function useCommandPalette() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, []);
|
||||
|
||||
return { open, setOpen, onClose: () => setOpen(false) };
|
||||
}
|
||||
|
||||
export { CommandPalette, useCommandPalette };
|
||||
export type { CommandPaletteProps, CommandItem };
|
||||
@ -0,0 +1,364 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { clsx } from 'clsx';
|
||||
import {
|
||||
Users, CalendarPlus, Briefcase, TrendingUp, Clock, Zap,
|
||||
CheckCircle2, Circle, AlertTriangle, MessageSquare, Phone,
|
||||
ArrowUpRight, Sparkles, ChevronLeft, ChevronRight,
|
||||
FileText, InboxIcon,
|
||||
} from 'lucide-react';
|
||||
import { useI18n } from '@/i18n';
|
||||
import { KpiCard } from '@/components/ui/kpi-card';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
|
||||
/* ---------- Types ---------- */
|
||||
interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
dueStatus: 'overdue' | 'today' | 'upcoming';
|
||||
time?: string;
|
||||
}
|
||||
|
||||
interface Deal {
|
||||
id: string;
|
||||
name: string;
|
||||
value: number;
|
||||
stage: string;
|
||||
stageColor: string;
|
||||
}
|
||||
|
||||
interface Activity {
|
||||
id: string;
|
||||
type: 'message' | 'call' | 'dealUpdate' | 'noteAdded';
|
||||
text: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
interface AiInsight {
|
||||
id: string;
|
||||
type: 'followUp' | 'closing' | 'risk';
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface SalesWorkspaceProps {
|
||||
userName?: string;
|
||||
kpis?: {
|
||||
totalLeads: number;
|
||||
newToday: number;
|
||||
openDeals: number;
|
||||
wonValue: number;
|
||||
conversionRate: number;
|
||||
responseTime: number;
|
||||
};
|
||||
tasks?: Task[];
|
||||
deals?: Deal[];
|
||||
activities?: Activity[];
|
||||
insights?: AiInsight[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/* ---------- Demo data ---------- */
|
||||
const demoKpis = {
|
||||
totalLeads: 1247,
|
||||
newToday: 18,
|
||||
openDeals: 43,
|
||||
wonValue: 892500,
|
||||
conversionRate: 34,
|
||||
responseTime: 12,
|
||||
};
|
||||
|
||||
const demoTasks: Task[] = [
|
||||
{ id: '1', title: 'متابعة أحمد الشمري — عرض عقار', dueStatus: 'overdue', time: 'أمس' },
|
||||
{ id: '2', title: 'اتصال مع نورة — عرض سعر', dueStatus: 'today', time: '2:00 م' },
|
||||
{ id: '3', title: 'إرسال عقد لشركة المستقبل', dueStatus: 'today', time: '4:30 م' },
|
||||
{ id: '4', title: 'جدولة عرض تقديمي', dueStatus: 'upcoming', time: 'غداً' },
|
||||
];
|
||||
|
||||
const demoDeals: Deal[] = [
|
||||
{ id: '1', name: 'صفقة أبراج الرياض', value: 2500000, stage: 'تفاوض', stageColor: 'bg-amber-500' },
|
||||
{ id: '2', name: 'مشروع المجمع التجاري', value: 1800000, stage: 'عرض سعر', stageColor: 'bg-teal-500' },
|
||||
{ id: '3', name: 'فيلا حي النرجس', value: 950000, stage: 'مؤهّل', stageColor: 'bg-blue-500' },
|
||||
{ id: '4', name: 'مكاتب طريق الملك', value: 780000, stage: 'تفاوض', stageColor: 'bg-amber-500' },
|
||||
{ id: '5', name: 'شقق حي الملقا', value: 650000, stage: 'عرض سعر', stageColor: 'bg-teal-500' },
|
||||
];
|
||||
|
||||
const demoActivities: Activity[] = [
|
||||
{ id: '1', type: 'message', text: 'رسالة من أحمد: "ابي تفاصيل العرض"', time: 'منذ 5 دقائق' },
|
||||
{ id: '2', type: 'call', text: 'مكالمة مع نورة — 8 دقائق', time: 'منذ 30 دقيقة' },
|
||||
{ id: '3', type: 'dealUpdate', text: 'صفقة أبراج الرياض انتقلت لمرحلة التفاوض', time: 'منذ ساعة' },
|
||||
{ id: '4', type: 'noteAdded', text: 'ملاحظة على فيلا النرجس: العميل يبي جراج إضافي', time: 'منذ 2 ساعة' },
|
||||
];
|
||||
|
||||
const demoInsights: AiInsight[] = [
|
||||
{ id: '1', type: 'followUp', count: 3 },
|
||||
{ id: '2', type: 'closing', count: 2 },
|
||||
{ id: '3', type: 'risk', count: 1 },
|
||||
];
|
||||
|
||||
/* ---------- Sub-components ---------- */
|
||||
const stagger = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.06 } },
|
||||
};
|
||||
|
||||
const fadeUp = {
|
||||
hidden: { opacity: 0, y: 12 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.35 } },
|
||||
};
|
||||
|
||||
function GlassCard({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<motion.div
|
||||
variants={fadeUp}
|
||||
className={clsx(
|
||||
'rounded-xl bg-white/5 backdrop-blur-xl border border-white/10 p-5',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeader({ icon: Icon, title }: { icon: typeof Users; title: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Icon className="h-4 w-4 text-teal-400" />
|
||||
<h2 className="text-sm font-semibold text-slate-300">{title}</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const activityIcons: Record<Activity['type'], typeof MessageSquare> = {
|
||||
message: MessageSquare,
|
||||
call: Phone,
|
||||
dealUpdate: ArrowUpRight,
|
||||
noteAdded: FileText,
|
||||
};
|
||||
|
||||
const taskStatusStyles: Record<Task['dueStatus'], { dot: string; text: string }> = {
|
||||
overdue: { dot: 'bg-rose-500', text: 'text-rose-400' },
|
||||
today: { dot: 'bg-amber-500', text: 'text-amber-400' },
|
||||
upcoming: { dot: 'bg-slate-500', text: 'text-slate-400' },
|
||||
};
|
||||
|
||||
const insightIcons: Record<AiInsight['type'], { icon: typeof Sparkles; color: string }> = {
|
||||
followUp: { icon: Clock, color: 'text-amber-400' },
|
||||
closing: { icon: TrendingUp, color: 'text-emerald-400' },
|
||||
risk: { icon: AlertTriangle, color: 'text-rose-400' },
|
||||
};
|
||||
|
||||
/* ---------- Main ---------- */
|
||||
function SalesWorkspace({
|
||||
userName,
|
||||
kpis: kpisProp,
|
||||
tasks: tasksProp,
|
||||
deals: dealsProp,
|
||||
activities: activitiesProp,
|
||||
insights: insightsProp,
|
||||
className,
|
||||
}: SalesWorkspaceProps) {
|
||||
const { t, dir, locale, isArabic } = useI18n();
|
||||
|
||||
const kpis = kpisProp ?? demoKpis;
|
||||
const tasks = tasksProp ?? demoTasks;
|
||||
const deals = dealsProp ?? demoDeals;
|
||||
const activities = activitiesProp ?? demoActivities;
|
||||
const insights = insightsProp ?? demoInsights;
|
||||
|
||||
const greeting = useMemo(() => {
|
||||
const hour = new Date().getHours();
|
||||
const base = hour < 17 ? t('workspace.greeting') : t('workspace.greetingEvening');
|
||||
return userName ? `${base}، ${userName}` : base;
|
||||
}, [t, userName]);
|
||||
|
||||
const formatCurrency = (val: number) =>
|
||||
new Intl.NumberFormat(locale === 'ar' ? 'ar-SA' : 'en-US', {
|
||||
style: 'currency',
|
||||
currency: 'SAR',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(val);
|
||||
|
||||
const kpiDefs = [
|
||||
{ key: 'totalLeads', value: kpis.totalLeads, label: t('dashboard.kpis.totalLeads'), icon: Users, trend: { direction: 'up' as const, percentage: 12 }, sparkline: [30, 42, 38, 55, 52, 68, 62] },
|
||||
{ key: 'newToday', value: kpis.newToday, label: t('dashboard.kpis.newToday'), icon: CalendarPlus, trend: { direction: 'up' as const, percentage: 8 }, sparkline: [5, 8, 12, 9, 15, 11, 18] },
|
||||
{ key: 'openDeals', value: kpis.openDeals, label: t('dashboard.kpis.openDeals'), icon: Briefcase, trend: { direction: 'up' as const, percentage: 5 }, sparkline: [28, 35, 31, 40, 38, 42, 43] },
|
||||
{ key: 'wonValue', value: kpis.wonValue, label: t('dashboard.kpis.wonValue'), prefix: isArabic ? 'ر.س' : 'SAR', trend: { direction: 'up' as const, percentage: 22 }, sparkline: [400, 520, 480, 650, 720, 810, 892] },
|
||||
{ key: 'conversionRate', value: kpis.conversionRate, label: t('dashboard.kpis.conversionRate'), suffix: '%', trend: { direction: 'down' as const, percentage: 3 }, sparkline: [38, 36, 35, 37, 34, 33, 34] },
|
||||
{ key: 'responseTime', value: kpis.responseTime, label: t('dashboard.kpis.responseTime'), suffix: t('workspace.kpiResponseUnit'), trend: { direction: 'up' as const, percentage: 15 }, sparkline: [20, 18, 15, 14, 13, 12, 12] },
|
||||
];
|
||||
|
||||
const insightLabel = (i: AiInsight) => {
|
||||
const labels: Record<AiInsight['type'], string> = {
|
||||
followUp: t('workspace.aiInsightFollowUp'),
|
||||
closing: t('workspace.aiInsightClosing'),
|
||||
risk: t('workspace.aiInsightRisk'),
|
||||
};
|
||||
return `${i.count} ${labels[i.type]}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={stagger}
|
||||
dir={dir}
|
||||
className={clsx('space-y-6', className)}
|
||||
>
|
||||
{/* Greeting */}
|
||||
<motion.h1
|
||||
variants={fadeUp}
|
||||
className="text-2xl font-bold text-white"
|
||||
>
|
||||
{greeting}
|
||||
</motion.h1>
|
||||
|
||||
{/* KPI Bar */}
|
||||
<motion.div variants={fadeUp} className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
{kpiDefs.map((k) => (
|
||||
<KpiCard
|
||||
key={k.key}
|
||||
value={k.value}
|
||||
label={k.label}
|
||||
prefix={k.prefix}
|
||||
suffix={k.suffix}
|
||||
trend={k.trend}
|
||||
sparklineData={k.sparkline}
|
||||
variant="compact"
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* 3-column body */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4">
|
||||
{/* LEFT: Tasks */}
|
||||
<GlassCard className="lg:col-span-3">
|
||||
<SectionHeader icon={CheckCircle2} title={t('workspace.todaysTasks')} />
|
||||
{tasks.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={CheckCircle2}
|
||||
title={t('workspace.noTasks')}
|
||||
className="py-8"
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{tasks.map((task) => {
|
||||
const style = taskStatusStyles[task.dueStatus];
|
||||
return (
|
||||
<li
|
||||
key={task.id}
|
||||
className="flex items-start gap-2.5 py-2 border-b border-white/5 last:border-0"
|
||||
>
|
||||
<span className={clsx('mt-1.5 h-2 w-2 rounded-full shrink-0', style.dot)} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-slate-200 truncate">{task.title}</p>
|
||||
<p className={clsx('text-xs mt-0.5', style.text)}>{task.time}</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</GlassCard>
|
||||
|
||||
{/* CENTER: Hot Deals */}
|
||||
<GlassCard className="lg:col-span-5">
|
||||
<SectionHeader icon={Briefcase} title={t('workspace.hotDeals')} />
|
||||
{deals.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Briefcase}
|
||||
title={t('workspace.noDeals')}
|
||||
className="py-8"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{deals.map((deal, idx) => (
|
||||
<div
|
||||
key={deal.id}
|
||||
className="flex items-center gap-3 py-2.5 border-b border-white/5 last:border-0"
|
||||
>
|
||||
<span className="text-xs text-slate-500 w-5 text-center tabular-nums">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-white truncate">{deal.name}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={clsx('h-1.5 w-1.5 rounded-full', deal.stageColor)} />
|
||||
<span className="text-xs text-slate-400">{deal.stage}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-teal-400 tabular-nums whitespace-nowrap">
|
||||
{formatCurrency(deal.value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</GlassCard>
|
||||
|
||||
{/* RIGHT: Activity */}
|
||||
<GlassCard className="lg:col-span-4">
|
||||
<SectionHeader icon={Clock} title={t('workspace.recentActivity')} />
|
||||
{activities.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={InboxIcon}
|
||||
title={t('workspace.noActivity')}
|
||||
className="py-8"
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{activities.map((act) => {
|
||||
const Icon = activityIcons[act.type];
|
||||
return (
|
||||
<li key={act.id} className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-lg bg-white/5 p-1.5">
|
||||
<Icon className="h-3.5 w-3.5 text-slate-400" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-slate-200 leading-snug">{act.text}</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">{act.time}</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
{/* AI Insights */}
|
||||
<GlassCard className="border-teal-500/20 bg-gradient-to-l from-teal-500/5 to-transparent">
|
||||
<SectionHeader icon={Sparkles} title={t('workspace.aiInsights')} />
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{insights.map((insight) => {
|
||||
const { icon: Icon, color } = insightIcons[insight.type];
|
||||
return (
|
||||
<motion.div
|
||||
key={insight.id}
|
||||
whileHover={{ scale: 1.03 }}
|
||||
className={clsx(
|
||||
'flex items-center gap-2.5 px-4 py-2.5 rounded-lg',
|
||||
'bg-white/5 border border-white/10',
|
||||
'cursor-pointer hover:bg-white/[0.08] transition-colors',
|
||||
)}
|
||||
>
|
||||
<Icon className={clsx('h-4 w-4', color)} />
|
||||
<span className="text-sm text-slate-200">{insightLabel(insight)}</span>
|
||||
{isArabic ? (
|
||||
<ChevronLeft className="h-3.5 w-3.5 text-slate-500" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 text-slate-500" />
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</GlassCard>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export { SalesWorkspace };
|
||||
export type { SalesWorkspaceProps, Task, Deal, Activity, AiInsight };
|
||||
60
salesflow-saas/frontend/src/components/ui/command-input.tsx
Normal file
60
salesflow-saas/frontend/src/components/ui/command-input.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { forwardRef, type InputHTMLAttributes } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Search } from 'lucide-react';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
interface CommandInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
||||
onCommandClick?: () => void;
|
||||
}
|
||||
|
||||
const CommandInput = forwardRef<HTMLInputElement, CommandInputProps>(
|
||||
({ onCommandClick, className, placeholder, ...props }, ref) => {
|
||||
const { t, dir } = useI18n();
|
||||
|
||||
const resolvedPlaceholder = placeholder ?? t('commandPalette.placeholder');
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCommandClick}
|
||||
className={clsx(
|
||||
'group flex items-center w-full gap-3',
|
||||
'rounded-xl px-4 py-2.5',
|
||||
'bg-white/5 backdrop-blur-sm',
|
||||
'border border-white/10',
|
||||
'hover:bg-white/[0.08] hover:border-white/15',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-400/50',
|
||||
'transition-all duration-200 cursor-pointer',
|
||||
className,
|
||||
)}
|
||||
dir={dir}
|
||||
>
|
||||
<Search className="h-4 w-4 text-slate-500 shrink-0" />
|
||||
|
||||
<span className="flex-1 text-start text-sm text-slate-500 truncate">
|
||||
{resolvedPlaceholder}
|
||||
</span>
|
||||
|
||||
<kbd
|
||||
className={clsx(
|
||||
'hidden sm:inline-flex items-center gap-0.5',
|
||||
'rounded-md px-1.5 py-0.5',
|
||||
'bg-white/[0.06] border border-white/10',
|
||||
'text-[11px] text-slate-500 font-mono',
|
||||
'group-hover:bg-white/10 group-hover:text-slate-400',
|
||||
'transition-colors duration-200',
|
||||
)}
|
||||
>
|
||||
<span className="text-xs">⌘</span>K
|
||||
</kbd>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CommandInput.displayName = 'CommandInput';
|
||||
|
||||
export { CommandInput };
|
||||
export type { CommandInputProps };
|
||||
75
salesflow-saas/frontend/src/components/ui/empty-state.tsx
Normal file
75
salesflow-saas/frontend/src/components/ui/empty-state.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import { type ReactNode } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { clsx } from 'clsx';
|
||||
import { useI18n } from '@/i18n';
|
||||
import { type LucideIcon } from 'lucide-react';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
onAction,
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
const { dir } = useI18n();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: 'easeOut' }}
|
||||
dir={dir}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center text-center',
|
||||
'py-16 px-8',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mb-5 rounded-2xl bg-white/5 p-4 border border-white/10">
|
||||
<Icon className="h-8 w-8 text-slate-500" strokeWidth={1.5} />
|
||||
</div>
|
||||
|
||||
<h3 className="text-base font-semibold text-slate-300 mb-1.5">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{description && (
|
||||
<p className="text-sm text-slate-500 max-w-xs mb-6 leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{actionLabel && onAction && (
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.04 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
onClick={onAction}
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-2 px-5 py-2.5 rounded-lg',
|
||||
'bg-teal-500/15 text-teal-400 text-sm font-medium',
|
||||
'border border-teal-500/25',
|
||||
'hover:bg-teal-500/25 transition-colors duration-200',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-400',
|
||||
)}
|
||||
>
|
||||
{actionLabel}
|
||||
</motion.button>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export { EmptyState };
|
||||
export type { EmptyStateProps };
|
||||
162
salesflow-saas/frontend/src/components/ui/kpi-card.tsx
Normal file
162
salesflow-saas/frontend/src/components/ui/kpi-card.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { clsx } from 'clsx';
|
||||
import { TrendingUp, TrendingDown } from 'lucide-react';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
interface KpiCardProps {
|
||||
value: number;
|
||||
label: string;
|
||||
trend?: { direction: 'up' | 'down'; percentage: number };
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
sparklineData?: number[];
|
||||
variant?: 'compact' | 'full';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function useCountUp(target: number, duration: number = 1200) {
|
||||
const [current, setCurrent] = useState(0);
|
||||
const frameRef = useRef<number>();
|
||||
|
||||
useEffect(() => {
|
||||
const start = performance.now();
|
||||
const animate = (now: number) => {
|
||||
const elapsed = now - start;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
setCurrent(Math.round(target * eased));
|
||||
if (progress < 1) {
|
||||
frameRef.current = requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
frameRef.current = requestAnimationFrame(animate);
|
||||
return () => {
|
||||
if (frameRef.current) cancelAnimationFrame(frameRef.current);
|
||||
};
|
||||
}, [target, duration]);
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
function Sparkline({ data, className }: { data: number[]; className?: string }) {
|
||||
const max = Math.max(...data);
|
||||
const min = Math.min(...data);
|
||||
const range = max - min || 1;
|
||||
const h = 28;
|
||||
const w = 72;
|
||||
const step = w / (data.length - 1);
|
||||
|
||||
const points = data
|
||||
.map((v, i) => `${i * step},${h - ((v - min) / range) * h}`)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
className={clsx('overflow-visible', className)}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiCard({
|
||||
value,
|
||||
label,
|
||||
trend,
|
||||
prefix,
|
||||
suffix,
|
||||
sparklineData,
|
||||
variant = 'full',
|
||||
className,
|
||||
}: KpiCardProps) {
|
||||
const { locale } = useI18n();
|
||||
const animatedValue = useCountUp(value);
|
||||
|
||||
const formatted = useMemo(() => {
|
||||
return new Intl.NumberFormat(locale === 'ar' ? 'ar-SA' : 'en-US').format(
|
||||
animatedValue,
|
||||
);
|
||||
}, [animatedValue, locale]);
|
||||
|
||||
const isCompact = variant === 'compact';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: 'easeOut' }}
|
||||
className={clsx(
|
||||
'relative rounded-xl overflow-hidden',
|
||||
'bg-white/5 backdrop-blur-xl',
|
||||
'border border-white/10',
|
||||
isCompact ? 'p-3' : 'p-5',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={clsx(
|
||||
'text-slate-400 truncate',
|
||||
isCompact ? 'text-xs mb-1' : 'text-sm mb-2',
|
||||
)}>
|
||||
{label}
|
||||
</p>
|
||||
<p className={clsx(
|
||||
'font-semibold text-white tabular-nums',
|
||||
isCompact ? 'text-lg' : 'text-2xl',
|
||||
)}>
|
||||
{prefix && <span className="text-slate-400 me-1">{prefix}</span>}
|
||||
{formatted}
|
||||
{suffix && <span className="text-slate-400 ms-1 text-base">{suffix}</span>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{sparklineData && sparklineData.length > 1 && (
|
||||
<div className={clsx(
|
||||
'shrink-0',
|
||||
isCompact ? 'w-14' : 'w-[72px]',
|
||||
trend?.direction === 'up' ? 'text-emerald-400' : 'text-rose-400',
|
||||
)}>
|
||||
<Sparkline data={sparklineData} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{trend && (
|
||||
<div className={clsx(
|
||||
'flex items-center gap-1',
|
||||
isCompact ? 'mt-1.5' : 'mt-3',
|
||||
)}>
|
||||
{trend.direction === 'up' ? (
|
||||
<TrendingUp className="h-3.5 w-3.5 text-emerald-400" />
|
||||
) : (
|
||||
<TrendingDown className="h-3.5 w-3.5 text-rose-400" />
|
||||
)}
|
||||
<span
|
||||
className={clsx(
|
||||
'text-xs font-medium tabular-nums',
|
||||
trend.direction === 'up' ? 'text-emerald-400' : 'text-rose-400',
|
||||
)}
|
||||
>
|
||||
{trend.percentage}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export { KpiCard };
|
||||
export type { KpiCardProps };
|
||||
Loading…
Reference in New Issue
Block a user