feat: Premium UI component library + 3D logo + interactive views

UI Components (src/components/ui/):
- input.tsx: Floating label, +966 phone, password toggle, Arabic errors
- modal.tsx: Framer Motion scale+fade, backdrop blur, 4 sizes
- sidebar.tsx: RTL right-side, collapsible, glass effect, 4 sections
- index.ts: Barrel export for all components

3D & Interactive (src/components/dealix/):
- dealix-3d-logo.tsx: 3D handshake logo with particles, mouse-tracking tilt
- stats-counter.tsx: Animated counter with Arabic/SAR formatting
- pipeline-kanban.tsx: 5-column deal pipeline with drag animations
- unified-inbox.tsx: WhatsApp-style multi-channel inbox (AR/EN)
- lead-score-card.tsx: AI score visualization with breakdown bars

https://claude.ai/code/session_01LsnvBa7HwF5hs99VZbgLGj
This commit is contained in:
Claude 2026-04-11 08:44:12 +00:00
parent 15906b343c
commit 3e8cd100d4
No known key found for this signature in database
9 changed files with 1947 additions and 0 deletions

View File

@ -0,0 +1,196 @@
'use client';
import { Suspense, useRef, useMemo, useState, useEffect } from 'react';
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import { Float, MeshTransmissionMaterial, Environment } from '@react-three/drei';
import * as THREE from 'three';
import { clsx } from 'clsx';
function useIsMobile() {
const [mobile, setMobile] = useState(false);
useEffect(() => {
const check = () => setMobile(window.innerWidth < 768);
check();
window.addEventListener('resize', check);
return () => window.removeEventListener('resize', check);
}, []);
return mobile;
}
function HandShape({ position, rotation, color }: {
position: [number, number, number];
rotation: [number, number, number];
color: string;
}) {
const group = useRef<THREE.Group>(null);
return (
<group ref={group} position={position} rotation={rotation}>
{/* Palm */}
<mesh>
<boxGeometry args={[0.7, 0.15, 0.5]} />
<meshStandardMaterial color={color} metalness={0.7} roughness={0.2} />
</mesh>
{/* Fingers - four cylinders */}
{[0, 1, 2, 3].map((i) => (
<mesh key={i} position={[0.25, 0.05, -0.15 + i * 0.1]} rotation={[0, 0, 0.3]}>
<capsuleGeometry args={[0.035, 0.3, 4, 8]} />
<meshStandardMaterial color={color} metalness={0.7} roughness={0.2} />
</mesh>
))}
{/* Thumb */}
<mesh position={[-0.25, 0.05, -0.2]} rotation={[0.4, 0, -0.5]}>
<capsuleGeometry args={[0.04, 0.2, 4, 8]} />
<meshStandardMaterial color={color} metalness={0.7} roughness={0.2} />
</mesh>
</group>
);
}
function GlowSphere() {
const ref = useRef<THREE.Mesh>(null);
useFrame(({ clock }) => {
if (!ref.current) return;
const s = 1 + Math.sin(clock.elapsedTime * 2) * 0.15;
ref.current.scale.setScalar(s);
});
return (
<mesh ref={ref} position={[0, 0, 0]}>
<sphereGeometry args={[0.18, 16, 16]} />
<meshStandardMaterial
color="#14b8a6"
emissive="#14b8a6"
emissiveIntensity={2}
transparent
opacity={0.6}
/>
</mesh>
);
}
function Particles({ count }: { count: number }) {
const ref = useRef<THREE.Points>(null);
const positions = useMemo(() => {
const arr = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
arr[i * 3] = (Math.random() - 0.5) * 3;
arr[i * 3 + 1] = (Math.random() - 0.5) * 3;
arr[i * 3 + 2] = (Math.random() - 0.5) * 3;
}
return arr;
}, [count]);
useFrame(({ clock }) => {
if (!ref.current) return;
ref.current.rotation.y = clock.elapsedTime * 0.05;
ref.current.rotation.x = Math.sin(clock.elapsedTime * 0.03) * 0.1;
});
return (
<points ref={ref}>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
args={[positions, 3]}
/>
</bufferGeometry>
<pointsMaterial color="#5eead4" size={0.02} transparent opacity={0.6} sizeAttenuation />
</points>
);
}
function HandshakeScene({ isMobile }: { isMobile: boolean }) {
const groupRef = useRef<THREE.Group>(null);
const mouse = useRef({ x: 0, y: 0 });
const { viewport } = useThree();
useEffect(() => {
const handle = (e: MouseEvent) => {
mouse.current.x = (e.clientX / window.innerWidth) * 2 - 1;
mouse.current.y = -(e.clientY / window.innerHeight) * 2 + 1;
};
window.addEventListener('mousemove', handle);
return () => window.removeEventListener('mousemove', handle);
}, []);
useFrame(({ clock }) => {
if (!groupRef.current) return;
groupRef.current.rotation.y = clock.elapsedTime * 0.15 + mouse.current.x * 0.3;
groupRef.current.rotation.x = Math.sin(clock.elapsedTime * 0.2) * 0.05 + mouse.current.y * 0.15;
});
return (
<group ref={groupRef}>
{/* Left hand reaching right */}
<HandShape
position={[-0.35, 0, 0]}
rotation={[0, 0, 0.1]}
color="#0d9488"
/>
{/* Right hand reaching left */}
<HandShape
position={[0.35, 0, 0]}
rotation={[0, Math.PI, -0.1]}
color="#14b8a6"
/>
{/* Glow at handshake point */}
<GlowSphere />
{/* Particles */}
<Particles count={isMobile ? 40 : 120} />
</group>
);
}
function LoadingShimmer() {
return (
<div className="flex items-center justify-center w-full h-full">
<div className="relative h-16 w-16">
<div className="absolute inset-0 rounded-full bg-teal-500/20 animate-ping" />
<div className="absolute inset-2 rounded-full bg-teal-500/40 animate-pulse" />
<div className="absolute inset-4 rounded-full bg-teal-400/60" />
</div>
</div>
);
}
interface DealixLogo3DProps {
size?: number;
className?: string;
}
function DealixLogo3D({ size = 300, className }: DealixLogo3DProps) {
const isMobile = useIsMobile();
return (
<div
className={clsx('relative', className)}
style={{ width: size, height: size }}
>
<Suspense fallback={<LoadingShimmer />}>
<Canvas
camera={{ position: [0, 0, 3], fov: 40 }}
dpr={isMobile ? 1 : [1, 2]}
gl={{ alpha: true, antialias: !isMobile }}
style={{ background: 'transparent' }}
>
<ambientLight intensity={0.4} />
<directionalLight position={[5, 5, 5]} intensity={1} color="#ffffff" />
<pointLight position={[0, 0, 2]} intensity={0.8} color="#14b8a6" />
<Float speed={1.5} rotationIntensity={0.2} floatIntensity={0.3}>
<HandshakeScene isMobile={isMobile} />
</Float>
</Canvas>
</Suspense>
{/* Ambient glow behind the canvas */}
<div className="absolute inset-0 -z-10 rounded-full bg-teal-500/10 blur-3xl" />
</div>
);
}
export { DealixLogo3D };
export type { DealixLogo3DProps };

View File

@ -0,0 +1,286 @@
"use client";
import { useState, useEffect } from "react";
import { motion, useMotionValue, useTransform, animate } from "framer-motion";
import { TrendingUp, Sparkles, UserCheck, MousePointerClick, Target } from "lucide-react";
/* ───────────── types ───────────── */
interface BreakdownItem {
key: string;
label: string;
value: number; // 0-25
icon: typeof TrendingUp;
}
interface LeadScoreData {
score: number; // 0-100
breakdown: BreakdownItem[];
recommendation: string;
}
/* ───────────── helpers ───────────── */
function getGrade(score: number): string {
if (score >= 90) return "A+";
if (score >= 80) return "A";
if (score >= 70) return "B";
if (score >= 55) return "C";
if (score >= 40) return "D";
return "F";
}
function getScoreColor(score: number): string {
if (score >= 75) return "#10b981"; // green
if (score >= 50) return "#eab308"; // yellow
return "#ef4444"; // red
}
function getGradientId(score: number): string {
return `score-gradient-${score}`;
}
/* ───────────── sample data ───────────── */
const sampleData: LeadScoreData = {
score: 78,
breakdown: [
{ key: "engagement", label: "التفاعل", value: 22, icon: MousePointerClick },
{ key: "profile", label: "الملف الشخصي", value: 18, icon: UserCheck },
{ key: "behavior", label: "السلوك", value: 20, icon: TrendingUp },
{ key: "intent", label: "نية الشراء", value: 18, icon: Target },
],
recommendation: "عميل واعد — تابع خلال ٢٤ ساعة",
};
/* ───────────── circular ring ───────────── */
function ScoreRing({
score,
size = 160,
strokeWidth = 10,
}: {
score: number;
size?: number;
strokeWidth?: number;
}) {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const motionProgress = useMotionValue(0);
const strokeDashoffset = useTransform(
motionProgress,
(v) => circumference - (v / 100) * circumference
);
const displayScore = useMotionValue(0);
const [displayed, setDisplayed] = useState(0);
useEffect(() => {
const anim = animate(motionProgress, score, { duration: 1.4, ease: "easeOut" });
const anim2 = animate(displayScore, score, {
duration: 1.4,
ease: "easeOut",
onUpdate: (v) => setDisplayed(Math.round(v)),
});
return () => {
anim.stop();
anim2.stop();
};
}, [score, motionProgress, displayScore]);
const color = getScoreColor(score);
const grade = getGrade(score);
const gradientId = getGradientId(score);
return (
<div className="relative" style={{ width: size, height: size }}>
<svg width={size} height={size} className="transform -rotate-90">
<defs>
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#ef4444" />
<stop offset="50%" stopColor="#eab308" />
<stop offset="100%" stopColor="#10b981" />
</linearGradient>
</defs>
{/* background ring */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="rgba(255,255,255,0.06)"
strokeWidth={strokeWidth}
/>
{/* progress ring */}
<motion.circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={`url(#${gradientId})`}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
style={{ strokeDashoffset }}
/>
</svg>
{/* center content */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-4xl font-black tabular-nums" style={{ color }}>
{displayed}
</span>
<span
className="text-sm font-bold mt-0.5 px-2 py-0.5 rounded-md"
style={{ backgroundColor: `${color}20`, color }}
>
{grade}
</span>
</div>
</div>
);
}
/* ───────────── breakdown bar ───────────── */
function BreakdownBar({
item,
delay,
}: {
item: BreakdownItem;
delay: number;
}) {
const Icon = item.icon;
const pct = (item.value / 25) * 100;
const color = getScoreColor(item.value * 4); // scale 0-25 -> 0-100
return (
<motion.div
initial={{ opacity: 0, x: 12 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay, duration: 0.4 }}
className="space-y-1.5"
>
<div className="flex items-center justify-between text-sm">
<span className="font-bold tabular-nums" style={{ color }}>
{item.value}/٢٥
</span>
<div className="flex items-center gap-2">
<span className="font-medium text-white/70">{item.label}</span>
<div className="p-1 rounded-md bg-white/5">
<Icon className="w-3.5 h-3.5 text-white/40" />
</div>
</div>
</div>
<div className="w-full h-2 rounded-full bg-white/[0.06] overflow-hidden">
<motion.div
className="h-full rounded-full"
style={{ backgroundColor: color }}
initial={{ width: 0 }}
animate={{ width: `${pct}%` }}
transition={{ delay: delay + 0.2, duration: 0.8, ease: "easeOut" }}
/>
</div>
</motion.div>
);
}
/* ───────────── full variant ───────────── */
export function LeadScoreCard({
data = sampleData,
variant = "full",
}: {
data?: LeadScoreData;
variant?: "full" | "compact";
}) {
if (variant === "compact") {
return <LeadScoreCompact data={data} />;
}
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="rounded-3xl bg-white/[0.04] backdrop-blur-xl border border-white/[0.08] p-6 max-w-sm w-full"
dir="rtl"
>
{/* header */}
<div className="flex items-center justify-between mb-6">
<div className="p-2 rounded-xl bg-teal-500/10">
<Sparkles className="w-5 h-5 text-teal-400" />
</div>
<h3 className="font-black text-base">تقييم العميل الذكي</h3>
</div>
{/* ring */}
<div className="flex justify-center mb-6">
<ScoreRing score={data.score} />
</div>
{/* breakdown */}
<div className="space-y-4 mb-6">
{data.breakdown.map((item, i) => (
<BreakdownBar key={item.key} item={item} delay={0.3 + i * 0.1} />
))}
</div>
{/* recommendation */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 1, duration: 0.4 }}
className="rounded-2xl bg-teal-500/10 border border-teal-500/20 p-4 text-center"
>
<div className="flex items-center justify-center gap-2 mb-1">
<Sparkles className="w-4 h-4 text-teal-400" />
<span className="text-xs font-bold text-teal-300">توصية الذكاء الاصطناعي</span>
</div>
<p className="text-sm font-medium text-white/80">{data.recommendation}</p>
</motion.div>
</motion.div>
);
}
/* ───────────── compact variant ───────────── */
function LeadScoreCompact({ data }: { data: LeadScoreData }) {
const color = getScoreColor(data.score);
const grade = getGrade(data.score);
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3 }}
className="flex items-center gap-3 rounded-2xl bg-white/[0.04] backdrop-blur-xl border border-white/[0.08] p-3"
dir="rtl"
>
{/* mini ring */}
<ScoreRing score={data.score} size={56} strokeWidth={5} />
{/* info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-black text-lg tabular-nums" style={{ color }}>
{data.score}
</span>
<span
className="text-[10px] font-bold px-1.5 py-0.5 rounded"
style={{ backgroundColor: `${color}20`, color }}
>
{grade}
</span>
</div>
<p className="text-xs text-white/50 truncate mt-0.5">{data.recommendation}</p>
</div>
{/* mini bars */}
<div className="flex gap-1 items-end h-8">
{data.breakdown.map((item) => (
<motion.div
key={item.key}
className="w-2 rounded-full"
style={{ backgroundColor: getScoreColor(item.value * 4) }}
initial={{ height: 0 }}
animate={{ height: `${(item.value / 25) * 100}%` }}
transition={{ duration: 0.6, ease: "easeOut" }}
/>
))}
</div>
</motion.div>
);
}

View File

@ -0,0 +1,301 @@
"use client";
import { useState } from "react";
import { motion, AnimatePresence, Reorder } from "framer-motion";
import {
GripVertical,
Building2,
User,
Clock,
ChevronDown,
ChevronUp,
TrendingUp,
X,
} from "lucide-react";
/* ───────────── types ───────────── */
interface Deal {
id: string;
company: string;
value: number;
rep: string;
daysInStage: number;
note?: string;
probability: number;
}
interface Stage {
id: string;
label: string;
color: string; // tailwind ring/border colour
headerBg: string; // gradient header
dotColor: string;
deals: Deal[];
}
/* ───────────── sample data ───────────── */
const initialStages: Stage[] = [
{
id: "new",
label: "جديد",
color: "border-blue-500",
headerBg: "from-blue-600 to-blue-400",
dotColor: "bg-blue-500",
deals: [
{ id: "d1", company: "شركة الأفق التقنية", value: 45_000, rep: "سالم", daysInStage: 2, probability: 10, note: "تواصل أولي عبر واتساب" },
{ id: "d2", company: "مؤسسة الوفاء", value: 22_000, rep: "نورة", daysInStage: 5, probability: 15 },
],
},
{
id: "negotiation",
label: "تفاوض",
color: "border-yellow-500",
headerBg: "from-yellow-500 to-amber-400",
dotColor: "bg-yellow-500",
deals: [
{ id: "d3", company: "مجموعة الرواد", value: 125_000, rep: "فهد", daysInStage: 8, probability: 45, note: "اجتماع مع المدير التنفيذي يوم الأحد" },
],
},
{
id: "proposal",
label: "عرض سعر",
color: "border-orange-500",
headerBg: "from-orange-500 to-orange-400",
dotColor: "bg-orange-500",
deals: [
{ id: "d4", company: "مصنع الشرق", value: 310_000, rep: "سالم", daysInStage: 3, probability: 60 },
{ id: "d5", company: "حلول البيانات", value: 88_000, rep: "نورة", daysInStage: 12, probability: 55, note: "بانتظار موافقة المشتريات" },
],
},
{
id: "won",
label: "فوز",
color: "border-emerald-500",
headerBg: "from-emerald-500 to-green-400",
dotColor: "bg-emerald-500",
deals: [
{ id: "d6", company: "شركة النخبة", value: 200_000, rep: "فهد", daysInStage: 0, probability: 100 },
],
},
{
id: "lost",
label: "خسارة",
color: "border-red-500",
headerBg: "from-red-500 to-rose-400",
dotColor: "bg-red-500",
deals: [
{ id: "d7", company: "مؤسسة السلام", value: 60_000, rep: "نورة", daysInStage: 0, probability: 0, note: "اختاروا منافس أرخص" },
],
},
];
const fmt = (n: number) =>
new Intl.NumberFormat("ar-SA", { maximumFractionDigits: 0 }).format(n);
/* ───────────── progress dots ───────────── */
const stageOrder = ["new", "negotiation", "proposal", "won", "lost"];
function ProgressDots({ stageId }: { stageId: string }) {
const idx = stageOrder.indexOf(stageId);
const isLost = stageId === "lost";
return (
<div className="flex gap-1 mt-2">
{stageOrder.slice(0, 4).map((_, i) => (
<span
key={i}
className={`h-1.5 rounded-full transition-all ${
isLost
? "w-3 bg-red-500/40"
: i <= idx
? "w-5 bg-emerald-400"
: "w-3 bg-white/10"
}`}
/>
))}
</div>
);
}
/* ───────────── deal card ───────────── */
function DealCard({ deal, stageId }: { deal: Deal; stageId: string }) {
const [expanded, setExpanded] = useState(false);
return (
<motion.div
layout
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
drag="y"
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={0.12}
className="group relative cursor-grab active:cursor-grabbing rounded-2xl bg-white/[0.04] backdrop-blur-xl border border-white/[0.08] p-4 shadow-lg hover:border-white/20 transition-colors"
>
{/* drag handle */}
<GripVertical className="absolute top-4 left-2 w-4 h-4 text-white/20 group-hover:text-white/40 transition-colors" />
{/* header */}
<div className="flex items-start justify-between gap-2 pr-0 pl-5">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4 text-white/40 shrink-0" />
<h4 className="font-bold text-sm truncate">{deal.company}</h4>
</div>
<p className="text-lg font-black mt-1 tracking-tight text-teal-400">
{fmt(deal.value)} <span className="text-xs font-medium text-white/40">ر.س</span>
</p>
</div>
<button
onClick={() => setExpanded(!expanded)}
className="p-1 rounded-lg hover:bg-white/10 transition-colors"
>
{expanded ? (
<ChevronUp className="w-4 h-4 text-white/50" />
) : (
<ChevronDown className="w-4 h-4 text-white/50" />
)}
</button>
</div>
{/* meta row */}
<div className="flex items-center gap-3 mt-3 text-[11px] text-white/50">
<span className="flex items-center gap-1">
<User className="w-3 h-3" />
{deal.rep}
</span>
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{deal.daysInStage} يوم
</span>
<span className="flex items-center gap-1">
<TrendingUp className="w-3 h-3" />
{deal.probability}٪
</span>
</div>
<ProgressDots stageId={stageId} />
{/* expanded details */}
<AnimatePresence>
{expanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="mt-3 pt-3 border-t border-white/10 text-xs text-white/60 space-y-2">
{deal.note && <p>{deal.note}</p>}
<div className="flex gap-2">
<button className="flex-1 py-1.5 rounded-lg bg-teal-500/20 text-teal-300 hover:bg-teal-500/30 transition-colors font-medium">
فتح الصفقة
</button>
<button className="flex-1 py-1.5 rounded-lg bg-white/5 hover:bg-white/10 transition-colors font-medium">
تعديل
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}
/* ───────────── empty state ───────────── */
function EmptyColumn() {
return (
<div className="flex-1 flex items-center justify-center rounded-2xl border-2 border-dashed border-white/10 p-6 text-center">
<p className="text-sm text-white/30 font-medium">لا توجد صفقات</p>
</div>
);
}
/* ───────────── column ───────────── */
function StageColumn({ stage }: { stage: Stage }) {
const total = stage.deals.reduce((s, d) => s + d.value, 0);
const [deals, setDeals] = useState(stage.deals);
return (
<div className="flex flex-col min-w-[280px] w-[280px] shrink-0 lg:min-w-0 lg:w-auto lg:flex-1">
{/* header */}
<div
className={`rounded-2xl bg-gradient-to-l ${stage.headerBg} p-4 mb-3 shadow-lg`}
>
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-black/60 bg-black/10 px-2 py-0.5 rounded-full">
{deals.length}
</span>
<h3 className="font-black text-black text-base">{stage.label}</h3>
</div>
<p className="text-left text-sm font-bold text-black/70 mt-1">
{fmt(total)} <span className="text-[10px]">ر.س</span>
</p>
</div>
{/* cards */}
<div className="flex-1 space-y-3 overflow-y-auto max-h-[calc(100vh-260px)] pe-1 scrollbar-thin">
{deals.length === 0 ? (
<EmptyColumn />
) : (
<Reorder.Group
axis="y"
values={deals}
onReorder={setDeals}
className="space-y-3"
>
{deals.map((deal) => (
<Reorder.Item key={deal.id} value={deal}>
<DealCard deal={deal} stageId={stage.id} />
</Reorder.Item>
))}
</Reorder.Group>
)}
</div>
</div>
);
}
/* ───────────── main component ───────────── */
export function PipelineKanban() {
const [stages] = useState<Stage[]>(initialStages);
return (
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: "easeOut" }}
className="w-full"
>
{/* title bar */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-black">خط الصفقات</h2>
<p className="text-sm text-white/40 mt-0.5">
إجمالي: {fmt(stages.flatMap((s) => s.deals).reduce((a, d) => a + d.value, 0))} ر.س
&nbsp;|&nbsp; {stages.flatMap((s) => s.deals).length} صفقة
</p>
</div>
<button className="px-4 py-2 rounded-xl bg-teal-500/20 text-teal-300 text-sm font-bold hover:bg-teal-500/30 transition-colors">
+ صفقة جديدة
</button>
</div>
{/* kanban board */}
<div className="flex gap-4 overflow-x-auto pb-4 -mx-2 px-2 snap-x snap-mandatory lg:snap-none">
{stages.map((stage, i) => (
<motion.div
key={stage.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.08, duration: 0.4 }}
className="snap-start flex flex-col min-w-[280px] w-[280px] shrink-0 lg:min-w-0 lg:w-auto lg:flex-1"
>
<StageColumn stage={stage} />
</motion.div>
))}
</div>
</motion.section>
);
}

View File

@ -0,0 +1,125 @@
'use client';
import { useEffect, useRef, useState, useCallback } from 'react';
import { motion, useSpring, useTransform, useInView } from 'framer-motion';
import { clsx } from 'clsx';
type NumberLocale = 'ar' | 'en';
interface StatsCounterProps {
target: number;
label: string;
prefix?: string;
suffix?: string;
currency?: boolean;
locale?: NumberLocale;
duration?: number;
className?: string;
}
function formatNumber(value: number, locale: NumberLocale, currency: boolean): string {
const opts: Intl.NumberFormatOptions = currency
? { style: 'currency', currency: 'SAR', maximumFractionDigits: 0 }
: { maximumFractionDigits: 0 };
const loc = locale === 'ar' ? 'ar-SA' : 'en-SA';
return new Intl.NumberFormat(loc, opts).format(value);
}
function AnimatedNumber({
target,
locale,
currency,
duration,
}: {
target: number;
locale: NumberLocale;
currency: boolean;
duration: number;
}) {
const ref = useRef<HTMLSpanElement>(null);
const isInView = useInView(ref, { once: true, margin: '-50px' });
const springValue = useSpring(0, {
stiffness: 50,
damping: 20,
duration: duration * 1000,
});
const display = useTransform(springValue, (v) => formatNumber(Math.round(v), locale, currency));
useEffect(() => {
if (isInView) {
springValue.set(target);
}
}, [isInView, target, springValue]);
useEffect(() => {
const unsubscribe = display.on('change', (v) => {
if (ref.current) {
ref.current.textContent = v;
}
});
return unsubscribe;
}, [display]);
return <span ref={ref}>0</span>;
}
function StatsCounter({
target,
label,
prefix,
suffix,
currency = false,
locale = 'ar',
duration = 2,
className,
}: StatsCounterProps) {
return (
<div className={clsx('text-center', className)}>
<div className="text-3xl font-bold text-white md:text-4xl">
{prefix && <span className="text-teal-400">{prefix}</span>}
<AnimatedNumber
target={target}
locale={locale}
currency={currency}
duration={duration}
/>
{suffix && <span className="text-teal-400 ms-1">{suffix}</span>}
</div>
<p className="mt-2 text-sm text-slate-400 md:text-base">{label}</p>
</div>
);
}
interface StatsGridProps {
stats: StatsCounterProps[];
className?: string;
}
function StatsGrid({ stats, className }: StatsGridProps) {
return (
<div
className={clsx(
'grid grid-cols-2 gap-8 md:grid-cols-4',
className,
)}
>
{stats.map((stat) => (
<motion.div
key={stat.label}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-30px' }}
transition={{ type: 'spring', stiffness: 100, damping: 15 }}
>
<StatsCounter {...stat} />
</motion.div>
))}
</div>
);
}
export { StatsCounter, StatsGrid };
export type { StatsCounterProps, StatsGridProps, NumberLocale };

View File

@ -0,0 +1,450 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
Search,
Send,
Paperclip,
ArrowRight,
Phone,
MoreVertical,
Sparkles,
Check,
CheckCheck,
MessageSquare,
Mail,
Smartphone,
} from "lucide-react";
/* ───────────── types ───────────── */
type Channel = "whatsapp" | "email" | "sms";
type FilterTab = "all" | "whatsapp" | "email" | "sms";
interface Message {
id: string;
text: string;
sent: boolean; // true = we sent, false = received
time: string;
read?: boolean;
}
interface Conversation {
id: string;
name: string;
avatar: string; // initials
avatarColor: string;
channel: Channel;
lastMessage: string;
time: string;
unread: number;
messages: Message[];
}
/* ───────────── channel config ───────────── */
const channelConfig: Record<Channel, { icon: typeof MessageSquare; color: string; label: string }> = {
whatsapp: { icon: MessageSquare, color: "text-green-400 bg-green-400/20", label: "واتساب" },
email: { icon: Mail, color: "text-blue-400 bg-blue-400/20", label: "إيميل" },
sms: { icon: Smartphone, color: "text-purple-400 bg-purple-400/20", label: "رسائل" },
};
const filterTabs: { key: FilterTab; label: string }[] = [
{ key: "all", label: "الكل" },
{ key: "whatsapp", label: "واتساب" },
{ key: "email", label: "إيميل" },
{ key: "sms", label: "رسائل" },
];
/* ───────────── sample data ───────────── */
const sampleConversations: Conversation[] = [
{
id: "c1",
name: "أحمد الغامدي",
avatar: "أغ",
avatarColor: "bg-green-600",
channel: "whatsapp",
lastMessage: "تمام، أرسل لي العرض على الإيميل",
time: "١٠:٣٢",
unread: 2,
messages: [
{ id: "m1", text: "السلام عليكم، عندكم حل CRM يدعم العربي؟", sent: false, time: "١٠:١٥" },
{ id: "m2", text: "وعليكم السلام أحمد! أكيد، Dealix مصمم بالكامل للسوق السعودي", sent: true, time: "١٠:١٨", read: true },
{ id: "m3", text: "كم السعر للباقة الاحترافية؟", sent: false, time: "١٠:٢٠" },
{ id: "m4", text: "١٤٩ ر.س شهرياً مع تجربة مجانية ١٤ يوم", sent: true, time: "١٠:٢٥", read: true },
{ id: "m5", text: "تمام، أرسل لي العرض على الإيميل", sent: false, time: "١٠:٣٢" },
],
},
{
id: "c2",
name: "سارة المطيري",
avatar: "سم",
avatarColor: "bg-blue-600",
channel: "email",
lastMessage: "شكراً على العرض التقديمي، سأرجع لكم بعد الاجتماع",
time: "أمس",
unread: 0,
messages: [
{ id: "m6", text: "مرحباً، أرغب بمعرفة المزيد عن خدمات تقييم العملاء بالذكاء الاصطناعي", sent: false, time: "أمس ٠٩:٠٠" },
{ id: "m7", text: "أهلاً سارة! نظام تقييم العملاء يعتمد على ٤ محاور: التفاعل، الملف الشخصي، السلوك، ونية الشراء", sent: true, time: "أمس ٠٩:٤٥", read: true },
{ id: "m8", text: "ممتاز، هل يمكنكم تقديم عرض لفريق من ١٥ شخص؟", sent: false, time: "أمس ١١:٣٠" },
{ id: "m9", text: "بالتأكيد! أرفقت عرض الأسعار للباقة المؤسسية", sent: true, time: "أمس ١٤:٠٠", read: true },
{ id: "m10", text: "شكراً على العرض التقديمي، سأرجع لكم بعد الاجتماع", sent: false, time: "أمس ١٦:٢٠" },
],
},
{
id: "c3",
name: "خالد العتيبي",
avatar: "خع",
avatarColor: "bg-purple-600",
channel: "sms",
lastMessage: "موعدنا يوم الأحد الساعة ١١ صباحاً",
time: "١٢:٠٠",
unread: 1,
messages: [
{ id: "m11", text: "خالد، تذكير بموعد العرض التقديمي", sent: true, time: "١١:٣٠", read: true },
{ id: "m12", text: "موعدنا يوم الأحد الساعة ١١ صباحاً", sent: false, time: "١٢:٠٠" },
],
},
{
id: "c4",
name: "منيرة القحطاني",
avatar: "مق",
avatarColor: "bg-amber-600",
channel: "whatsapp",
lastMessage: "ودي أجرب النظام قبل ما نقرر",
time: "٠٩:١٥",
unread: 3,
messages: [
{ id: "m13", text: "مرحباً، محتاجين نظام CRM لشركة عقارية", sent: false, time: "٠٨:٤٥" },
{ id: "m14", text: "أهلاً منيرة! Dealix يخدم أكثر من ٥٠ شركة عقارية في المملكة", sent: true, time: "٠٩:٠٠", read: true },
{ id: "m15", text: "ودي أجرب النظام قبل ما نقرر", sent: false, time: "٠٩:١٥" },
],
},
];
/* ───────────── typing indicator ───────────── */
function TypingIndicator() {
return (
<div className="flex gap-1 items-center px-4 py-3 rounded-2xl rounded-br-sm bg-slate-700/60 w-fit">
{[0, 1, 2].map((i) => (
<motion.span
key={i}
className="w-2 h-2 rounded-full bg-white/40"
animate={{ y: [0, -5, 0] }}
transition={{ duration: 0.6, repeat: Infinity, delay: i * 0.15 }}
/>
))}
</div>
);
}
/* ───────────── conversation list item ───────────── */
function ConversationItem({
convo,
isActive,
onClick,
}: {
convo: Conversation;
isActive: boolean;
onClick: () => void;
}) {
const ch = channelConfig[convo.channel];
const Icon = ch.icon;
return (
<motion.button
onClick={onClick}
whileTap={{ scale: 0.98 }}
className={`w-full flex items-center gap-3 p-3 rounded-xl text-right transition-all ${
isActive
? "bg-white/10 border border-white/10"
: "hover:bg-white/[0.04] border border-transparent"
}`}
>
{/* avatar */}
<div className={`relative shrink-0 w-11 h-11 rounded-full ${convo.avatarColor} flex items-center justify-center text-xs font-bold text-white`}>
{convo.avatar}
<span className={`absolute -bottom-0.5 -left-0.5 p-0.5 rounded-full ${ch.color.split(" ")[1]}`}>
<Icon className={`w-2.5 h-2.5 ${ch.color.split(" ")[0]}`} />
</span>
</div>
{/* text */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="text-[11px] text-white/40">{convo.time}</span>
<h4 className="font-bold text-sm truncate">{convo.name}</h4>
</div>
<p className="text-xs text-white/50 truncate mt-0.5">{convo.lastMessage}</p>
</div>
{/* unread badge */}
{convo.unread > 0 && (
<span className="shrink-0 w-5 h-5 rounded-full bg-teal-500 text-[10px] font-bold text-black flex items-center justify-center">
{convo.unread}
</span>
)}
</motion.button>
);
}
/* ───────────── chat panel ───────────── */
function ChatPanel({
convo,
onBack,
}: {
convo: Conversation;
onBack: () => void;
}) {
const [messages, setMessages] = useState(convo.messages);
const [input, setInput] = useState("");
const [showTyping, setShowTyping] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const ch = channelConfig[convo.channel];
useEffect(() => {
setMessages(convo.messages);
}, [convo.id, convo.messages]);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
}, [messages, showTyping]);
const handleSend = () => {
if (!input.trim()) return;
const newMsg: Message = {
id: `m-${Date.now()}`,
text: input,
sent: true,
time: new Date().toLocaleTimeString("ar-SA", { hour: "2-digit", minute: "2-digit" }),
read: false,
};
setMessages((prev) => [...prev, newMsg]);
setInput("");
setShowTyping(true);
setTimeout(() => {
setShowTyping(false);
setMessages((prev) => [
...prev,
{
id: `m-${Date.now()}-r`,
text: "شكراً لتواصلك! سأرد عليك في أقرب وقت",
sent: false,
time: new Date().toLocaleTimeString("ar-SA", { hour: "2-digit", minute: "2-digit" }),
},
]);
}, 2000);
};
return (
<div className="flex flex-col h-full">
{/* chat header */}
<div className="shrink-0 flex items-center justify-between gap-3 p-4 border-b border-white/10 bg-white/[0.02] backdrop-blur-xl">
<div className="flex items-center gap-2">
<button className="p-1.5 rounded-lg hover:bg-white/10 transition-colors">
<Phone className="w-4 h-4 text-white/50" />
</button>
<button className="p-1.5 rounded-lg hover:bg-white/10 transition-colors">
<MoreVertical className="w-4 h-4 text-white/50" />
</button>
</div>
<div className="flex items-center gap-3 flex-1 justify-end">
<div className="text-right">
<h3 className="font-bold text-sm">{convo.name}</h3>
<span className={`text-[10px] font-medium ${ch.color.split(" ")[0]}`}>
{ch.label}
</span>
</div>
<div className={`w-10 h-10 rounded-full ${convo.avatarColor} flex items-center justify-center text-xs font-bold text-white`}>
{convo.avatar}
</div>
{/* back button mobile */}
<button
onClick={onBack}
className="lg:hidden p-1.5 rounded-lg hover:bg-white/10 transition-colors"
>
<ArrowRight className="w-5 h-5" />
</button>
</div>
</div>
{/* messages */}
<div ref={scrollRef} className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.map((msg) => (
<motion.div
key={msg.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className={`flex ${msg.sent ? "justify-start" : "justify-end"}`}
>
<div
className={`max-w-[75%] px-4 py-2.5 rounded-2xl text-sm leading-relaxed ${
msg.sent
? "bg-teal-600/80 text-white rounded-bl-sm"
: "bg-slate-700/60 text-white/90 rounded-br-sm"
}`}
>
<p>{msg.text}</p>
<div className={`flex items-center gap-1 mt-1 ${msg.sent ? "justify-start" : "justify-end"}`}>
<span className="text-[10px] text-white/40">{msg.time}</span>
{msg.sent &&
(msg.read ? (
<CheckCheck className="w-3 h-3 text-teal-300" />
) : (
<Check className="w-3 h-3 text-white/30" />
))}
</div>
</div>
</motion.div>
))}
<AnimatePresence>
{showTyping && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex justify-end"
>
<TypingIndicator />
</motion.div>
)}
</AnimatePresence>
</div>
{/* AI suggestion chip */}
<div className="shrink-0 px-4 pb-2">
<button className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-teal-500/10 border border-teal-500/20 text-teal-300 text-xs font-medium hover:bg-teal-500/20 transition-colors">
<Sparkles className="w-3 h-3" />
اقتراح الرد الذكي
</button>
</div>
{/* input bar */}
<div className="shrink-0 p-3 border-t border-white/10 bg-white/[0.02] backdrop-blur-xl flex items-center gap-2">
<button className="p-2 rounded-xl hover:bg-white/10 transition-colors text-white/40 hover:text-white/60">
<Paperclip className="w-5 h-5" />
</button>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
placeholder="اكتب رسالتك..."
className="flex-1 bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-sm placeholder:text-white/30 focus:outline-none focus:border-teal-500/50 transition-colors"
/>
<button
onClick={handleSend}
className="p-2.5 rounded-xl bg-teal-500 text-black hover:bg-teal-400 transition-colors"
>
<Send className="w-4 h-4" />
</button>
</div>
</div>
);
}
/* ───────────── main component ───────────── */
export function UnifiedInbox() {
const [activeId, setActiveId] = useState<string | null>(null);
const [filter, setFilter] = useState<FilterTab>("all");
const [search, setSearch] = useState("");
const filtered = sampleConversations.filter((c) => {
if (filter !== "all" && c.channel !== filter) return false;
if (search && !c.name.includes(search) && !c.lastMessage.includes(search)) return false;
return true;
});
const activeConvo = sampleConversations.find((c) => c.id === activeId) ?? null;
return (
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="w-full h-[calc(100vh-120px)] min-h-[500px] rounded-3xl overflow-hidden border border-white/10 bg-white/[0.02] backdrop-blur-xl flex"
dir="rtl"
>
{/* ─── right panel: conversation list ─── */}
<div
className={`w-full lg:w-[340px] shrink-0 border-l border-white/10 flex flex-col ${
activeConvo ? "hidden lg:flex" : "flex"
}`}
>
{/* search */}
<div className="p-3 border-b border-white/10">
<div className="relative">
<Search className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/30" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="بحث..."
className="w-full bg-white/5 border border-white/10 rounded-xl pr-10 pl-4 py-2 text-sm placeholder:text-white/30 focus:outline-none focus:border-teal-500/40 transition-colors"
/>
</div>
</div>
{/* filter tabs */}
<div className="flex gap-1 p-2 border-b border-white/10">
{filterTabs.map((tab) => (
<button
key={tab.key}
onClick={() => setFilter(tab.key)}
className={`flex-1 py-1.5 rounded-lg text-xs font-bold transition-colors ${
filter === tab.key
? "bg-teal-500/20 text-teal-300"
: "text-white/40 hover:bg-white/5"
}`}
>
{tab.label}
</button>
))}
</div>
{/* list */}
<div className="flex-1 overflow-y-auto p-2 space-y-1">
<AnimatePresence>
{filtered.map((convo, i) => (
<motion.div
key={convo.id}
initial={{ opacity: 0, x: 12 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.05 }}
>
<ConversationItem
convo={convo}
isActive={activeId === convo.id}
onClick={() => setActiveId(convo.id)}
/>
</motion.div>
))}
</AnimatePresence>
{filtered.length === 0 && (
<div className="flex items-center justify-center h-32 text-sm text-white/30">
لا توجد محادثات
</div>
)}
</div>
</div>
{/* ─── left panel: chat thread ─── */}
<div
className={`flex-1 flex flex-col ${
!activeConvo ? "hidden lg:flex" : "flex"
}`}
>
{activeConvo ? (
<ChatPanel convo={activeConvo} onBack={() => setActiveId(null)} />
) : (
<div className="flex-1 flex flex-col items-center justify-center text-center gap-3">
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center">
<MessageSquare className="w-8 h-8 text-white/20" />
</div>
<p className="text-white/30 text-sm font-medium">اختر محادثة للبدء</p>
</div>
)}
</div>
</motion.section>
);
}

View File

@ -0,0 +1,17 @@
export { Button } from './button';
export type { ButtonProps, ButtonVariant, ButtonSize } from './button';
export { Card, CardTitle, CardDescription } from './card';
export type { CardProps, CardVariant } from './card';
export { Input } from './input';
export type { InputProps, InputType } from './input';
export { Modal } from './modal';
export type { ModalProps, ModalSize } from './modal';
export { Badge } from './badge';
export type { BadgeProps, BadgeVariant } from './badge';
export { Sidebar, useSidebar } from './sidebar';
export type { SidebarProps, NavItem, NavSection } from './sidebar';

View File

@ -0,0 +1,212 @@
'use client';
import {
forwardRef,
useState,
useId,
type InputHTMLAttributes,
type TextareaHTMLAttributes,
} from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { clsx } from 'clsx';
import { Search, Eye, EyeOff } from 'lucide-react';
type InputType = 'text' | 'email' | 'phone' | 'password' | 'search' | 'textarea';
interface InputProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'size'> {
inputType?: InputType;
label?: string;
error?: string;
rows?: number;
}
const baseStyles = clsx(
'w-full bg-white/5 backdrop-blur-sm text-white placeholder-transparent',
'border border-white/10 rounded-lg',
'transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-teal-400/50 focus:border-teal-400',
'disabled:opacity-50 disabled:cursor-not-allowed',
'text-base ps-4 pe-4 pt-5 pb-2',
'peer',
);
const labelStyles = clsx(
'absolute text-sm text-slate-400 duration-200 transform',
'top-4 start-4 z-10 origin-[right]',
'peer-placeholder-shown:scale-100 peer-placeholder-shown:translate-y-0',
'peer-focus:scale-75 peer-focus:-translate-y-2.5',
'peer-[:not(:placeholder-shown)]:scale-75 peer-[:not(:placeholder-shown)]:-translate-y-2.5',
'pointer-events-none',
);
const errorLabelStyles = 'text-red-400';
const DealixInput = forwardRef<HTMLInputElement, InputProps>(
({ inputType = 'text', label, error, className, rows = 4, id, ...props }, ref) => {
const generatedId = useId();
const inputId = id ?? generatedId;
const [showPassword, setShowPassword] = useState(false);
const errorId = error ? `${inputId}-error` : undefined;
const wrapperClass = 'relative w-full';
if (inputType === 'textarea') {
return (
<div className={wrapperClass}>
<textarea
id={inputId}
rows={rows}
dir="auto"
placeholder=" "
aria-invalid={!!error}
aria-describedby={errorId}
className={clsx(baseStyles, 'resize-y min-h-[80px]', error && 'border-red-400/60', className)}
{...(props as TextareaHTMLAttributes<HTMLTextAreaElement>)}
/>
{label && (
<label htmlFor={inputId} className={clsx(labelStyles, error && errorLabelStyles)}>
{label}
</label>
)}
<ErrorMessage id={errorId} message={error} />
</div>
);
}
if (inputType === 'phone') {
return (
<div className={wrapperClass}>
<div className="relative flex items-center">
<span className="absolute start-4 text-sm text-teal-400 font-medium z-10 pointer-events-none">
966+
</span>
<input
ref={ref}
id={inputId}
type="tel"
dir="ltr"
placeholder=" "
aria-invalid={!!error}
aria-describedby={errorId}
className={clsx(baseStyles, 'ps-16', error && 'border-red-400/60', className)}
{...props}
/>
{label && (
<label htmlFor={inputId} className={clsx(labelStyles, 'start-16', error && errorLabelStyles)}>
{label}
</label>
)}
</div>
<ErrorMessage id={errorId} message={error} />
</div>
);
}
if (inputType === 'search') {
return (
<div className={wrapperClass}>
<Search className="absolute start-4 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 pointer-events-none" />
<input
ref={ref}
id={inputId}
type="search"
dir="auto"
placeholder={label ?? '...بحث'}
className={clsx(
'w-full bg-white/5 backdrop-blur-sm text-white placeholder-slate-500',
'border border-white/10 rounded-lg',
'transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-teal-400/50 focus:border-teal-400',
'text-base ps-11 pe-4 py-2.5',
error && 'border-red-400/60',
className,
)}
{...props}
/>
<ErrorMessage id={errorId} message={error} />
</div>
);
}
if (inputType === 'password') {
return (
<div className={wrapperClass}>
<input
ref={ref}
id={inputId}
type={showPassword ? 'text' : 'password'}
dir="auto"
placeholder=" "
aria-invalid={!!error}
aria-describedby={errorId}
className={clsx(baseStyles, 'pe-12', error && 'border-red-400/60', className)}
{...props}
/>
{label && (
<label htmlFor={inputId} className={clsx(labelStyles, error && errorLabelStyles)}>
{label}
</label>
)}
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute end-4 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white transition-colors"
tabIndex={-1}
aria-label={showPassword ? 'إخفاء كلمة المرور' : 'إظهار كلمة المرور'}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
<ErrorMessage id={errorId} message={error} />
</div>
);
}
return (
<div className={wrapperClass}>
<input
ref={ref}
id={inputId}
type={inputType}
dir="auto"
placeholder=" "
aria-invalid={!!error}
aria-describedby={errorId}
className={clsx(baseStyles, error && 'border-red-400/60', className)}
{...props}
/>
{label && (
<label htmlFor={inputId} className={clsx(labelStyles, error && errorLabelStyles)}>
{label}
</label>
)}
<ErrorMessage id={errorId} message={error} />
</div>
);
},
);
DealixInput.displayName = 'DealixInput';
function ErrorMessage({ id, message }: { id?: string; message?: string }) {
return (
<AnimatePresence>
{message && (
<motion.p
id={id}
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
className="mt-1.5 text-sm text-red-400 ps-1"
role="alert"
>
{message}
</motion.p>
)}
</AnimatePresence>
);
}
export { DealixInput as Input };
export type { InputProps, InputType };

View File

@ -0,0 +1,139 @@
'use client';
import { useEffect, useCallback, type ReactNode } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { clsx } from 'clsx';
import { X } from 'lucide-react';
type ModalSize = 'sm' | 'md' | 'lg' | 'full';
interface ModalProps {
open: boolean;
onClose: () => void;
size?: ModalSize;
title?: ReactNode;
footer?: ReactNode;
children: ReactNode;
closeOnBackdrop?: boolean;
className?: string;
}
const sizeStyles: Record<ModalSize, string> = {
sm: 'max-w-sm',
md: 'max-w-lg',
lg: 'max-w-3xl',
full: 'max-w-[calc(100vw-2rem)] max-h-[calc(100vh-2rem)] h-full',
};
const backdropVariants = {
hidden: { opacity: 0 },
visible: { opacity: 1 },
};
const modalVariants = {
hidden: { opacity: 0, scale: 0.95, y: 10 },
visible: { opacity: 1, scale: 1, y: 0 },
exit: { opacity: 0, scale: 0.95, y: 10 },
};
function Modal({
open,
onClose,
size = 'md',
title,
footer,
children,
closeOnBackdrop = true,
className,
}: ModalProps) {
const handleEscape = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
},
[onClose],
);
useEffect(() => {
if (!open) return;
document.addEventListener('keydown', handleEscape);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = '';
};
}, [open, handleEscape]);
return (
<AnimatePresence>
{open && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
variants={backdropVariants}
initial="hidden"
animate="visible"
exit="hidden"
transition={{ duration: 0.2 }}
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={closeOnBackdrop ? onClose : undefined}
aria-hidden="true"
/>
<motion.div
role="dialog"
aria-modal="true"
aria-label={typeof title === 'string' ? title : undefined}
variants={modalVariants}
initial="hidden"
animate="visible"
exit="exit"
transition={{ type: 'spring', stiffness: 350, damping: 25 }}
className={clsx(
'relative z-10 w-full',
'bg-slate-900/95 backdrop-blur-xl',
'border border-white/10 rounded-2xl',
'shadow-2xl shadow-black/40',
'flex flex-col overflow-hidden',
sizeStyles[size],
className,
)}
>
{title && (
<div className="flex items-center justify-between border-b border-white/10 px-6 py-4">
<h2 className="text-lg font-semibold text-white">{title}</h2>
<button
onClick={onClose}
className="rounded-lg p-1.5 text-slate-400 hover:text-white hover:bg-white/10 transition-colors"
aria-label="إغلاق"
>
<X className="h-5 w-5" />
</button>
</div>
)}
<div className={clsx('flex-1 overflow-y-auto p-6', !title && 'pt-10')}>
{!title && (
<button
onClick={onClose}
className="absolute top-3 end-3 rounded-lg p-1.5 text-slate-400 hover:text-white hover:bg-white/10 transition-colors z-10"
aria-label="إغلاق"
>
<X className="h-5 w-5" />
</button>
)}
{children}
</div>
{footer && (
<div className="border-t border-white/10 px-6 py-4 flex items-center justify-end gap-3">
{footer}
</div>
)}
</motion.div>
</div>
)}
</AnimatePresence>
);
}
export { Modal };
export type { ModalProps, ModalSize };

View File

@ -0,0 +1,221 @@
'use client';
import { useState, createContext, useContext, type ReactNode } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { clsx } from 'clsx';
import {
LayoutDashboard, Users, MessageSquare, TrendingUp,
Brain, Bot, Sparkles, Settings, ChevronLeft, ChevronRight,
X, Menu, Phone, BarChart3, Shield,
} from 'lucide-react';
interface SidebarContextValue {
collapsed: boolean;
toggle: () => void;
mobileOpen: boolean;
setMobileOpen: (v: boolean) => void;
}
const SidebarContext = createContext<SidebarContextValue>({
collapsed: false,
toggle: () => {},
mobileOpen: false,
setMobileOpen: () => {},
});
export const useSidebar = () => useContext(SidebarContext);
interface NavItem {
label: string;
icon: ReactNode;
href: string;
badge?: string;
}
interface NavSection {
title: string;
items: NavItem[];
}
const navigation: NavSection[] = [
{
title: 'الرئيسية',
items: [
{ label: 'لوحة التحكم', icon: <LayoutDashboard className="h-5 w-5" />, href: '/dashboard' },
{ label: 'التحليلات', icon: <BarChart3 className="h-5 w-5" />, href: '/analytics' },
],
},
{
title: 'إدارة العملاء',
items: [
{ label: 'العملاء المحتملين', icon: <Users className="h-5 w-5" />, href: '/leads', badge: '12' },
{ label: 'الصفقات', icon: <TrendingUp className="h-5 w-5" />, href: '/deals' },
{ label: 'المحادثات', icon: <MessageSquare className="h-5 w-5" />, href: '/conversations', badge: '3' },
{ label: 'المكالمات', icon: <Phone className="h-5 w-5" />, href: '/calls' },
],
},
{
title: 'الذكاء الاصطناعي',
items: [
{ label: 'مساعد الذكاء', icon: <Brain className="h-5 w-5" />, href: '/ai-assistant' },
{ label: 'الأتمتة', icon: <Bot className="h-5 w-5" />, href: '/automation' },
{ label: 'التوصيات', icon: <Sparkles className="h-5 w-5" />, href: '/recommendations' },
],
},
{
title: 'الإعدادات',
items: [
{ label: 'الإعدادات', icon: <Settings className="h-5 w-5" />, href: '/settings' },
{ label: 'الخصوصية', icon: <Shield className="h-5 w-5" />, href: '/privacy' },
],
},
];
function SidebarItem({ item, collapsed, active }: { item: NavItem; collapsed: boolean; active: boolean }) {
return (
<a
href={item.href}
className={clsx(
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200',
'hover:bg-white/10',
active
? 'bg-teal-500/15 text-teal-400 shadow-[inset_0_0_12px_rgba(20,184,166,0.1)]'
: 'text-slate-400 hover:text-white',
collapsed && 'justify-center px-2',
)}
>
<span className="shrink-0">{item.icon}</span>
{!collapsed && (
<>
<span className="flex-1 truncate">{item.label}</span>
{item.badge && (
<span className="rounded-full bg-teal-500/20 px-2 py-0.5 text-xs text-teal-400">
{item.badge}
</span>
)}
</>
)}
</a>
);
}
function SidebarContent({ collapsed, activePath }: { collapsed: boolean; activePath: string }) {
return (
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-6">
{navigation.map((section) => (
<div key={section.title}>
{!collapsed && (
<p className="mb-2 ps-3 text-xs font-semibold uppercase tracking-wider text-slate-500">
{section.title}
</p>
)}
<div className="space-y-1">
{section.items.map((item) => (
<SidebarItem
key={item.href}
item={item}
collapsed={collapsed}
active={activePath === item.href}
/>
))}
</div>
</div>
))}
</nav>
);
}
interface SidebarProps {
activePath?: string;
children?: ReactNode;
}
function Sidebar({ activePath = '/dashboard', children }: SidebarProps) {
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
return (
<SidebarContext.Provider
value={{ collapsed, toggle: () => setCollapsed((v) => !v), mobileOpen, setMobileOpen }}
>
<div className="flex min-h-screen">
{/* Desktop sidebar */}
<motion.aside
animate={{ width: collapsed ? 72 : 280 }}
transition={{ type: 'spring', stiffness: 300, damping: 28 }}
className={clsx(
'hidden lg:flex flex-col fixed end-0 top-0 bottom-0 z-40',
'bg-slate-900/80 backdrop-blur-xl border-s border-white/10',
)}
>
<div className={clsx('flex items-center border-b border-white/10 h-16', collapsed ? 'justify-center px-2' : 'justify-between px-4')}>
{!collapsed && <span className="text-lg font-bold text-teal-400">Dealix</span>}
<button
onClick={() => setCollapsed((v) => !v)}
className="rounded-lg p-1.5 text-slate-400 hover:text-white hover:bg-white/10 transition-colors"
aria-label={collapsed ? 'توسيع القائمة' : 'طي القائمة'}
>
{collapsed ? <ChevronLeft className="h-5 w-5" /> : <ChevronRight className="h-5 w-5" />}
</button>
</div>
<SidebarContent collapsed={collapsed} activePath={activePath} />
</motion.aside>
{/* Mobile trigger */}
<button
onClick={() => setMobileOpen(true)}
className="fixed top-4 end-4 z-50 lg:hidden rounded-lg bg-slate-800/90 backdrop-blur p-2.5 text-white border border-white/10"
aria-label="فتح القائمة"
>
<Menu className="h-5 w-5" />
</button>
{/* Mobile drawer */}
<AnimatePresence>
{mobileOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm lg:hidden"
onClick={() => setMobileOpen(false)}
/>
<motion.aside
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', stiffness: 300, damping: 28 }}
className="fixed end-0 top-0 bottom-0 z-50 w-72 bg-slate-900/95 backdrop-blur-xl border-s border-white/10 lg:hidden"
>
<div className="flex items-center justify-between border-b border-white/10 h-16 px-4">
<span className="text-lg font-bold text-teal-400">Dealix</span>
<button
onClick={() => setMobileOpen(false)}
className="rounded-lg p-1.5 text-slate-400 hover:text-white hover:bg-white/10 transition-colors"
aria-label="إغلاق القائمة"
>
<X className="h-5 w-5" />
</button>
</div>
<SidebarContent collapsed={false} activePath={activePath} />
</motion.aside>
</>
)}
</AnimatePresence>
{/* Main content area */}
<motion.main
animate={{ marginInlineEnd: collapsed ? 72 : 280 }}
transition={{ type: 'spring', stiffness: 300, damping: 28 }}
className="flex-1 lg:me-0"
>
{children}
</motion.main>
</div>
</SidebarContext.Provider>
);
}
export { Sidebar };
export type { SidebarProps, NavItem, NavSection };