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