feat: implement php tracking endpoint and footer metrics widget
This commit is contained in:
parent
82a8e6ec10
commit
28ebee571a
3 changed files with 179 additions and 4 deletions
107
backend/analytics.php
Normal file
107
backend/analytics.php
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
$startTime = microtime(true);
|
||||
|
||||
// CORS Config
|
||||
$allowedOrigins = ['https://andre-kempf.com', 'https://www.andre-kempf.com', 'http://localhost:3000'];
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
|
||||
if (in_array($origin, $allowedOrigins)) {
|
||||
header("Access-Control-Allow-Origin: " . $origin);
|
||||
} else {
|
||||
header("Access-Control-Allow-Origin: https://andre-kempf.com");
|
||||
}
|
||||
|
||||
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
|
||||
header("Access-Control-Allow-Headers: Content-Type");
|
||||
header("Content-Type: application/json; charset=UTF-8");
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
$dataFile = __DIR__ . '/analytics.json';
|
||||
|
||||
// Essential data structure + lastPing
|
||||
$data = [
|
||||
'totalViews' => 0,
|
||||
'todayViews' => 0,
|
||||
'lastDate' => date('Y-m-d'),
|
||||
'lastPing' => time(),
|
||||
'visitors' => [],
|
||||
'devices' => ['Desktop' => 0, 'Mobile' => 0]
|
||||
];
|
||||
|
||||
// Load existing data
|
||||
if (file_exists($dataFile)) {
|
||||
$content = @file_get_contents($dataFile);
|
||||
if ($content) {
|
||||
$loadedData = @json_decode($content, true);
|
||||
if (is_array($loadedData)) {
|
||||
$data = array_merge($data, $loadedData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset daily stats if day changed
|
||||
$today = date('Y-m-d');
|
||||
if ($data['lastDate'] !== $today) {
|
||||
$data['todayViews'] = 0;
|
||||
$data['lastDate'] = $today;
|
||||
$data['visitors'] = [];
|
||||
}
|
||||
|
||||
// Localhost / Development check
|
||||
$referer = $_SERVER['HTTP_REFERER'] ?? '';
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
|
||||
$isLocalhost = (
|
||||
strpos($origin, 'localhost') !== false ||
|
||||
strpos($referer, 'localhost') !== false ||
|
||||
$ip === '127.0.0.1' ||
|
||||
$ip === '::1'
|
||||
);
|
||||
|
||||
$lastTrackTime = $_SESSION['last_track_time'] ?? 0;
|
||||
$currentTime = time();
|
||||
$isSpam = ($currentTime - $lastTrackTime) < 30;
|
||||
|
||||
// Track view
|
||||
if (isset($_GET['track']) && $_GET['track'] === 'true' && !$isLocalhost && !$isSpam) {
|
||||
$_SESSION['last_track_time'] = $currentTime;
|
||||
|
||||
$data['totalViews']++;
|
||||
$data['todayViews']++;
|
||||
$data['lastPing'] = $currentTime;
|
||||
|
||||
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
||||
$isMobile = preg_match('/mobile|android|iphone|ipad|tablet/i', $ua);
|
||||
$deviceType = $isMobile ? 'Mobile' : 'Desktop';
|
||||
$data['devices'][$deviceType] = ($data['devices'][$deviceType] ?? 0) + 1;
|
||||
|
||||
$visitorHash = md5($ip . $today);
|
||||
if (!in_array($visitorHash, $data['visitors'])) {
|
||||
$data['visitors'][] = $visitorHash;
|
||||
}
|
||||
|
||||
@file_put_contents($dataFile, json_encode($data), LOCK_EX);
|
||||
}
|
||||
|
||||
// Calculate server execution time (ms)
|
||||
$responseTime = round((microtime(true) - $startTime) * 1000, 2);
|
||||
|
||||
echo json_encode([
|
||||
'totalViews' => $data['totalViews'],
|
||||
'todayViews' => $data['todayViews'],
|
||||
'uniqueVisitors' => count($data['visitors']),
|
||||
'devices' => $data['devices'],
|
||||
'lastPing' => $data['lastPing'] ?? time(),
|
||||
'latencyMs' => $responseTime
|
||||
]);
|
||||
55
src/components/AnalyticsWidget.tsx
Normal file
55
src/components/AnalyticsWidget.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface AnalyticsData {
|
||||
totalViews: number;
|
||||
todayViews: number;
|
||||
uniqueVisitors: number;
|
||||
devices: {
|
||||
Desktop: number;
|
||||
Mobile: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function AnalyticsWidget() {
|
||||
const [stats, setStats] = useState<AnalyticsData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("https://andre-kempf.com/backend/analytics.php?track=true")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (!data.error) setStats(data);
|
||||
})
|
||||
.catch(() => null);
|
||||
}, []);
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<div className="h-6 w-52.5 font-mono text-xs text-slate-500/80 bg-slate-900/40 rounded-md border border-slate-800/60 flex items-center px-2.5 gap-1.5 select-none">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-400/80 animate-pulse" />
|
||||
<span className="text-blue-400 font-semibold">$</span>
|
||||
<span className="text-slate-400 animate-pulse">loading_stats...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-6 font-mono text-xs text-slate-500 flex items-center gap-1.5 bg-slate-900/40 px-2.5 py-1 rounded-md border border-slate-800/60 select-none shadow-xs">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
<span className="text-blue-400 font-semibold">$</span>
|
||||
<span className="text-slate-400">sys.metrics</span>
|
||||
<span className="text-slate-600">[</span>
|
||||
<span className="text-slate-300">
|
||||
today:
|
||||
<span className="text-emerald-400 font-medium">{stats.todayViews}</span>
|
||||
</span>
|
||||
<span className="text-slate-600">|</span>
|
||||
<span className="text-slate-300">
|
||||
total:
|
||||
<span className="text-blue-400 font-medium">{stats.totalViews}</span>
|
||||
</span>
|
||||
<span className="text-slate-600">]</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,18 +1,31 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import AnalyticsWidget from "./AnalyticsWidget";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="w-full bg-slate-950/50 text-center text-xs text-slate-500 px-4 sm:px-4 py-4">
|
||||
<footer className="w-full bg-slate-950/50 text-xs text-slate-500 px-4 py-4 border-t border-slate-900/40">
|
||||
<div className="max-w-5xl mx-auto flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<p>© {new Date().getFullYear()} André Kempf. All rights reserved.</p>
|
||||
<div className="flex gap-4">
|
||||
{/* Left: Copyright */}
|
||||
<p className="order-1 md:order-1">
|
||||
© {new Date().getFullYear()} André Kempf. All rights reserved.
|
||||
</p>
|
||||
|
||||
{/* Center: Analytics */}
|
||||
<div className="order-3 md:order-2 opacity-70 hover:opacity-100 transition-opacity">
|
||||
<AnalyticsWidget />
|
||||
</div>
|
||||
|
||||
{/* Right: Links */}
|
||||
<div className="flex gap-4 order-2 md:order-3">
|
||||
<Link
|
||||
href="/imprint"
|
||||
className="hover:text-slate-300 transition-colors"
|
||||
>
|
||||
Legal Notice
|
||||
</Link>
|
||||
<span>|</span>
|
||||
<span className="text-slate-800">|</span>
|
||||
<Link
|
||||
href="/privacy"
|
||||
className="hover:text-slate-300 transition-colors"
|
||||
|
|
|
|||
Loading…
Reference in a new issue