mirror of
https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools.git
synced 2026-06-17 23:09:35 +00:00
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:
parent
15906b343c
commit
3e8cd100d4
196
salesflow-saas/frontend/src/components/dealix/dealix-3d-logo.tsx
Normal file
196
salesflow-saas/frontend/src/components/dealix/dealix-3d-logo.tsx
Normal 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 };
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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))} ر.س
|
||||
| {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>
|
||||
);
|
||||
}
|
||||
125
salesflow-saas/frontend/src/components/dealix/stats-counter.tsx
Normal file
125
salesflow-saas/frontend/src/components/dealix/stats-counter.tsx
Normal 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 };
|
||||
450
salesflow-saas/frontend/src/components/dealix/unified-inbox.tsx
Normal file
450
salesflow-saas/frontend/src/components/dealix/unified-inbox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
salesflow-saas/frontend/src/components/ui/index.ts
Normal file
17
salesflow-saas/frontend/src/components/ui/index.ts
Normal 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';
|
||||
212
salesflow-saas/frontend/src/components/ui/input.tsx
Normal file
212
salesflow-saas/frontend/src/components/ui/input.tsx
Normal 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 };
|
||||
139
salesflow-saas/frontend/src/components/ui/modal.tsx
Normal file
139
salesflow-saas/frontend/src/components/ui/modal.tsx
Normal 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 };
|
||||
221
salesflow-saas/frontend/src/components/ui/sidebar.tsx
Normal file
221
salesflow-saas/frontend/src/components/ui/sidebar.tsx
Normal 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 };
|
||||
Loading…
Reference in New Issue
Block a user