mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-17 04:09:51 +02:00
feat(analytics): Implement Analytics Dashboard and Token Management
This commit is contained in:
1 parent
b38f082d6e
commit
f8ddb44e38
822 files changed
+132545
-51
No files matched your search
@@ -0,0 +1,158 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { MetricsCard } from './MetricsCard';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, Users, BarChart3, Eye } from 'lucide-react';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { format } from 'date-fns';
|
||||
import { apiRequest } from '@/lib/queryClient';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
export function AnalyticsDashboard() {
|
||||
const [selectedPageId, setSelectedPageId] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Fetch Pages
|
||||
const { data: pages = [], isLoading: isLoadingPages } = useQuery({
|
||||
queryKey: ['/api/pages'],
|
||||
});
|
||||
|
||||
// Set default selection
|
||||
useEffect(() => {
|
||||
if (pages.length > 0 && !selectedPageId) {
|
||||
setSelectedPageId(pages[0].id);
|
||||
}
|
||||
}, [pages, selectedPageId]);
|
||||
|
||||
// Fetch History for selected page
|
||||
const { data: history = [], isLoading: isLoadingHistory } = useQuery({
|
||||
queryKey: [`/api/analytics/pages/${selectedPageId}/history`],
|
||||
enabled: !!selectedPageId,
|
||||
});
|
||||
|
||||
// Refresh Mutation
|
||||
const refreshMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selectedPageId) return;
|
||||
await apiRequest('POST', `/api/analytics/pages/${selectedPageId}/refresh`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [`/api/analytics/pages/${selectedPageId}/history`] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/pages'] });
|
||||
toast({
|
||||
title: "Analytics refreshed",
|
||||
description: "Latest data has been fetched from Facebook.",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Refresh failed",
|
||||
description: "Could not fetch latest data.",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const selectedPage = pages.find((p: any) => p.id === selectedPageId);
|
||||
|
||||
// Prepare chart data (reverse to show chronological)
|
||||
const chartData = [...history].reverse().map((entry: any) => ({
|
||||
date: format(new Date(entry.date), 'MM/dd'),
|
||||
followers: entry.followersCount,
|
||||
reach: entry.pageReach
|
||||
}));
|
||||
|
||||
// Calculate trends (simple comparison with previous entry)
|
||||
const latest = history[0];
|
||||
const previous = history[1];
|
||||
|
||||
const getTrend = (current: number, prev: number) => {
|
||||
if (!prev) return 0;
|
||||
return Math.round(((current - prev) / prev) * 100);
|
||||
};
|
||||
|
||||
if (isLoadingPages) return <div>Loading pages...</div>;
|
||||
if (!pages.length) return <div>No pages connected. Please connect a Facebook page in Settings.</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Select value={selectedPageId || ''} onValueChange={setSelectedPageId}>
|
||||
<SelectTrigger className="w-[250px]">
|
||||
<SelectValue placeholder="Select Page" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pages.map((page: any) => (
|
||||
<SelectItem key={page.id} value={page.id}>
|
||||
{page.pageName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{selectedPage && (
|
||||
<span className={`px-2 py-1 rounded text-xs ${selectedPage.tokenStatus === 'valid' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||
Token: {selectedPage.tokenStatus || 'Unknown'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => refreshMutation.mutate()}
|
||||
disabled={refreshMutation.isPending || !selectedPageId}
|
||||
>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${refreshMutation.isPending ? 'animate-spin' : ''}`} />
|
||||
Refresh Data
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selectedPageId && (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<MetricsCard
|
||||
title="Total Followers"
|
||||
value={latest?.followersCount?.toLocaleString() || 0}
|
||||
icon={<Users className="h-4 w-4 text-muted-foreground" />}
|
||||
trend={latest && previous ? getTrend(latest.followersCount, previous.followersCount) : undefined}
|
||||
description="from last update"
|
||||
/>
|
||||
<MetricsCard
|
||||
title="Page Reach"
|
||||
value={latest?.pageReach?.toLocaleString() || 0}
|
||||
icon={<BarChart3 className="h-4 w-4 text-muted-foreground" />}
|
||||
trend={latest && previous ? getTrend(latest.pageReach, previous.pageReach) : undefined}
|
||||
description="from last update"
|
||||
/>
|
||||
{/* Add more metrics if available */}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Growth & Reach History</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis yAxisId="left" />
|
||||
<YAxis yAxisId="right" orientation="right" />
|
||||
<Tooltip />
|
||||
<Line yAxisId="left" type="monotone" dataKey="followers" stroke="#8884d8" name="Followers" />
|
||||
<Line yAxisId="right" type="monotone" dataKey="reach" stroke="#82ca9d" name="Reach" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ArrowDownIcon, ArrowUpIcon, MinusIcon } from "lucide-react";
|
||||
|
||||
interface MetricsCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
description?: string;
|
||||
trend?: number; // percent change
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MetricsCard({ title, value, description, trend, icon }: MetricsCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{title}
|
||||
</CardTitle>
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
{(trend !== undefined || description) && (
|
||||
<p className="text-xs text-muted-foreground mt-1 flex items-center">
|
||||
{trend !== undefined && (
|
||||
<span className={`flex items-center mr-2 ${trend > 0 ? 'text-green-500' : trend < 0 ? 'text-red-500' : ''}`}>
|
||||
{trend > 0 ? <ArrowUpIcon className="w-3 h-3 mr-1" /> : trend < 0 ? <ArrowDownIcon className="w-3 h-3 mr-1" /> : <MinusIcon className="w-3 h-3 mr-1" />}
|
||||
{Math.abs(trend)}%
|
||||
</span>
|
||||
)}
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Home, PlusCircle, Calendar, Images, Users, Bot, Clock, Settings, Database, UserCog, LogOut, ChevronLeft, ChevronRight, Wand2 } from "lucide-react";
|
||||
import { Home, PlusCircle, Calendar, Images, Users, Bot, Clock, Settings, Database, UserCog, LogOut, ChevronLeft, ChevronRight, Wand2, BarChart3 } from "lucide-react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
@@ -64,6 +64,7 @@ export default function Sidebar({ onLinkClick }: SidebarProps = {}) {
|
||||
{ icon: Calendar, label: "Calendrier", href: "/calendar", badge: null },
|
||||
{ icon: Images, label: "Médiathèque", href: "/media", badge: null },
|
||||
{ icon: Wand2, label: "Éditeur d'images", href: "/image-editor", badge: null },
|
||||
{ icon: BarChart3, label: "Analytics", href: "/analytics", badge: "New" },
|
||||
];
|
||||
|
||||
const statsItems = [
|
||||
@@ -95,7 +96,7 @@ export default function Sidebar({ onLinkClick }: SidebarProps = {}) {
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = location === item.href;
|
||||
|
||||
|
||||
return (
|
||||
<li key={item.href} className="relative group">
|
||||
<a
|
||||
@@ -103,8 +104,8 @@ export default function Sidebar({ onLinkClick }: SidebarProps = {}) {
|
||||
onClick={(e) => handleLinkClick(item.href, e)}
|
||||
className={`
|
||||
flex items-center gap-3 px-4 py-3 rounded-xl font-medium transition-all relative cursor-pointer
|
||||
${isActive
|
||||
? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm'
|
||||
${isActive
|
||||
? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-sidebar-accent hover:text-foreground'
|
||||
}
|
||||
${isCollapsed ? 'justify-center' : ''}
|
||||
@@ -145,7 +146,7 @@ export default function Sidebar({ onLinkClick }: SidebarProps = {}) {
|
||||
{statsItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = location === item.href;
|
||||
|
||||
|
||||
return (
|
||||
<li key={item.href} className="relative group">
|
||||
<a
|
||||
@@ -153,8 +154,8 @@ export default function Sidebar({ onLinkClick }: SidebarProps = {}) {
|
||||
onClick={(e) => handleLinkClick(item.href, e)}
|
||||
className={`
|
||||
flex items-center gap-3 px-4 py-3 rounded-xl transition-all relative cursor-pointer
|
||||
${isActive
|
||||
? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm'
|
||||
${isActive
|
||||
? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-sidebar-accent hover:text-foreground'
|
||||
}
|
||||
${isCollapsed ? 'justify-center' : ''}
|
||||
@@ -194,7 +195,7 @@ export default function Sidebar({ onLinkClick }: SidebarProps = {}) {
|
||||
className={`
|
||||
flex items-center gap-3 px-4 py-3 rounded-xl transition-all relative group cursor-pointer
|
||||
${location === "/pages"
|
||||
? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm'
|
||||
? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-sidebar-accent hover:text-foreground'
|
||||
}
|
||||
${isCollapsed ? 'justify-center' : ''}
|
||||
@@ -218,7 +219,7 @@ export default function Sidebar({ onLinkClick }: SidebarProps = {}) {
|
||||
className={`
|
||||
flex items-center gap-3 px-4 py-3 rounded-xl transition-all relative group cursor-pointer
|
||||
${location === "/ai"
|
||||
? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm'
|
||||
? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-sidebar-accent hover:text-foreground'
|
||||
}
|
||||
${isCollapsed ? 'justify-center' : ''}
|
||||
@@ -242,7 +243,7 @@ export default function Sidebar({ onLinkClick }: SidebarProps = {}) {
|
||||
className={`
|
||||
flex items-center gap-3 px-4 py-3 rounded-xl transition-all relative group cursor-pointer
|
||||
${location === "/users"
|
||||
? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm'
|
||||
? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-sidebar-accent hover:text-foreground'
|
||||
}
|
||||
${isCollapsed ? 'justify-center' : ''}
|
||||
@@ -266,7 +267,7 @@ export default function Sidebar({ onLinkClick }: SidebarProps = {}) {
|
||||
className={`
|
||||
flex items-center gap-3 px-4 py-3 rounded-xl transition-all relative group cursor-pointer
|
||||
${location === "/sql"
|
||||
? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm'
|
||||
? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-sidebar-accent hover:text-foreground'
|
||||
}
|
||||
${isCollapsed ? 'justify-center' : ''}
|
||||
@@ -290,7 +291,7 @@ export default function Sidebar({ onLinkClick }: SidebarProps = {}) {
|
||||
className={`
|
||||
flex items-center gap-3 px-4 py-3 rounded-xl transition-all relative group cursor-pointer
|
||||
${location === "/settings"
|
||||
? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm'
|
||||
? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-sidebar-accent hover:text-foreground'
|
||||
}
|
||||
${isCollapsed ? 'justify-center' : ''}
|
||||
@@ -310,7 +311,7 @@ export default function Sidebar({ onLinkClick }: SidebarProps = {}) {
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
<div className="pt-2 relative group">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -328,7 +329,7 @@ export default function Sidebar({ onLinkClick }: SidebarProps = {}) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* User Profile Card */}
|
||||
{session && (
|
||||
<div className={`mt-4 bg-gradient-to-br from-primary/5 to-secondary/5 border border-primary/10 rounded-xl ${isCollapsed ? 'p-2' : 'p-4'}`}>
|
||||
@@ -347,7 +348,7 @@ export default function Sidebar({ onLinkClick }: SidebarProps = {}) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Toggle Button (Desktop only) */}
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
|
||||
Reference in new issue
Block a user