feat(analytics): Implement Analytics Dashboard and Token Management

This commit is contained in:
Michael committed 2026-01-20 13:49:24 +01:00
1 parent b38f082d6e
commit f8ddb44e38
822 files changed
+132545 -51

No files matched your search

+7 -1
View File
@@ -60,9 +60,14 @@ const UsersAdmin = createResponsiveRoute(
() => import("@/pages/mobile/users-admin")
);
const Analytics = createResponsiveRoute(
() => import("@/pages/AnalyticsPage"),
() => import("@/pages/mobile/analytics")
);
function ProtectedRoute({ component: Component, adminOnly = false }: { component: React.ComponentType; adminOnly?: boolean }) {
const [, setLocation] = useLocation();
const { data: session, isLoading } = useQuery({
queryKey: ["/api/auth/session"],
retry: false,
@@ -118,6 +123,7 @@ function Router() {
<Route path="/settings">{() => <ProtectedRoute component={Settings} adminOnly />}</Route>
<Route path="/sql">{() => <ProtectedRoute component={SqlAdmin} adminOnly />}</Route>
<Route path="/users">{() => <ProtectedRoute component={UsersAdmin} adminOnly />}</Route>
<Route path="/analytics">{() => <ProtectedRoute component={Analytics} />}</Route>
<Route component={NotFound} />
</Switch>
);
@@ -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>
);
}
+16 -15
View File
@@ -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)}
+38
View File
@@ -0,0 +1,38 @@
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { AnalyticsDashboard } from '@/components/analytics/AnalyticsDashboard';
import { useState } from "react";
export default function AnalyticsPage() {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
<div className="p-4 sm:p-6 lg:p-8 space-y-6 sm:space-y-8 max-w-[1600px] mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold tracking-tight mb-2">Social Analytics</h1>
<p className="text-muted-foreground">Monitor your Facebook & Instagram performance.</p>
</div>
<AnalyticsDashboard />
</div>
</main>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { AnalyticsDashboard } from '@/components/analytics/AnalyticsDashboard';
import { useState } from "react";
export default function AnalyticsPageMobile() {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
<div className="p-4 space-y-6">
<div className="mb-4">
<h1 className="text-2xl font-bold tracking-tight mb-2">Social Analytics</h1>
<p className="text-sm text-muted-foreground">Monitor your performance.</p>
</div>
<AnalyticsDashboard />
</div>
</main>
</div>
);
}