feat: plugin analytics avec custom field kirbyup + Chart.js
Refactoring complet du plugin analytics : remplacement de la section avec template Vue inline par un custom field compilé avec kirbyup. Dashboard avec KPIs, line chart Chart.js et filtres par date. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7371e66ec1
commit
8a73da920f
15 changed files with 873 additions and 0 deletions
12
public/site/plugins/analytics/blueprints/pages/analytics.yml
Normal file
12
public/site/plugins/analytics/blueprints/pages/analytics.yml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
title: Analytics
|
||||
icon: chart
|
||||
|
||||
columns:
|
||||
- width: 1/1
|
||||
sections:
|
||||
dashboard:
|
||||
type: fields
|
||||
fields:
|
||||
analytics:
|
||||
type: analytics-dashboard
|
||||
label: false
|
||||
37
public/site/plugins/analytics/classes/AnalyticsPage.php
Normal file
37
public/site/plugins/analytics/classes/AnalyticsPage.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace adrienpayet\analytics;
|
||||
|
||||
use Kirby\Cms\Page;
|
||||
|
||||
class AnalyticsPage extends Page
|
||||
{
|
||||
public function getAnalyticsData(array $filters = []): array
|
||||
{
|
||||
$user = kirby()->user();
|
||||
|
||||
if (!$user) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($user->isAdmin()) {
|
||||
return AnalyticsStore::getAggregatedData($filters);
|
||||
}
|
||||
|
||||
$allowedProjects = $user->currentProjects();
|
||||
$allowedEmails = [];
|
||||
foreach ($allowedProjects as $project) {
|
||||
$users = kirby()->users();
|
||||
foreach ($users as $u) {
|
||||
if ($u->currentProjects()->has($project)) {
|
||||
$allowedEmails[] = $u->email()->value();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$filters['emails'] = array_unique($allowedEmails);
|
||||
|
||||
return AnalyticsStore::getAggregatedData($filters);
|
||||
}
|
||||
|
||||
}
|
||||
148
public/site/plugins/analytics/classes/AnalyticsStore.php
Normal file
148
public/site/plugins/analytics/classes/AnalyticsStore.php
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
<?php
|
||||
|
||||
namespace adrienpayet\analytics;
|
||||
|
||||
use Kirby\Data\Yaml;
|
||||
use Kirby\Toolkit\F;
|
||||
|
||||
class AnalyticsStore
|
||||
{
|
||||
private static function getFilePath(): string
|
||||
{
|
||||
return kirby()->root('content') . '/analytics/visits.yml';
|
||||
}
|
||||
|
||||
private static function ensureFileExists(): void
|
||||
{
|
||||
$filePath = self::getFilePath();
|
||||
$dirPath = dirname($filePath);
|
||||
|
||||
if (!is_dir($dirPath)) {
|
||||
F::mkdir($dirPath, true);
|
||||
}
|
||||
|
||||
if (!F::exists($filePath)) {
|
||||
F::write($filePath, Yaml::encode(['visits' => []]));
|
||||
}
|
||||
}
|
||||
|
||||
public static function addVisit(Visit $visit): void
|
||||
{
|
||||
self::ensureFileExists();
|
||||
|
||||
$filePath = self::getFilePath();
|
||||
$data = Yaml::decode(F::read($filePath));
|
||||
|
||||
$visits = $data['visits'] ?? [];
|
||||
$visits[] = $visit->toArray();
|
||||
|
||||
// Limiter à 10000 visites max
|
||||
if (count($visits) > 10000) {
|
||||
$visits = array_slice($visits, -10000);
|
||||
}
|
||||
|
||||
$data['visits'] = $visits;
|
||||
F::write($filePath, Yaml::encode($data));
|
||||
}
|
||||
|
||||
public static function getVisits(array $filters = []): array
|
||||
{
|
||||
self::ensureFileExists();
|
||||
|
||||
$filePath = self::getFilePath();
|
||||
$data = Yaml::decode(F::read($filePath));
|
||||
$visits = $data['visits'] ?? [];
|
||||
|
||||
// Convertir en objets Visit
|
||||
$visits = array_map(fn($v) => Visit::fromArray($v), $visits);
|
||||
|
||||
// Filtrer par daterange
|
||||
if (!empty($filters['startDate']) || !empty($filters['endDate'])) {
|
||||
$visits = array_filter($visits, function($visit) use ($filters) {
|
||||
$timestamp = strtotime($visit->timestamp);
|
||||
|
||||
if (!empty($filters['startDate'])) {
|
||||
$startDate = strtotime($filters['startDate'] . ' 00:00:00');
|
||||
if ($timestamp < $startDate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($filters['endDate'])) {
|
||||
$endDate = strtotime($filters['endDate'] . ' 23:59:59');
|
||||
if ($timestamp > $endDate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Filtrer par projet
|
||||
if (!empty($filters['project'])) {
|
||||
$projectId = $filters['project'];
|
||||
$visits = array_filter($visits, function($visit) use ($projectId) {
|
||||
return str_contains($visit->pageUrl, "/projects/{$projectId}");
|
||||
});
|
||||
}
|
||||
|
||||
// Filtrer par email (permissions)
|
||||
if (!empty($filters['emails'])) {
|
||||
$allowedEmails = $filters['emails'];
|
||||
$visits = array_filter($visits, function($visit) use ($allowedEmails) {
|
||||
return in_array($visit->email, $allowedEmails);
|
||||
});
|
||||
}
|
||||
|
||||
return array_values($visits);
|
||||
}
|
||||
|
||||
public static function getAggregatedData(array $filters = []): array
|
||||
{
|
||||
self::ensureFileExists();
|
||||
$visits = self::getVisits($filters);
|
||||
|
||||
// Visites par jour
|
||||
$visitsByDay = [];
|
||||
foreach ($visits as $visit) {
|
||||
$day = date('Y-m-d', strtotime($visit->timestamp));
|
||||
$visitsByDay[$day] = ($visitsByDay[$day] ?? 0) + 1;
|
||||
}
|
||||
ksort($visitsByDay);
|
||||
|
||||
// Visites par page
|
||||
$visitsByPage = [];
|
||||
foreach ($visits as $visit) {
|
||||
$page = $visit->pageName ?: $visit->pageUrl;
|
||||
$visitsByPage[$page] = ($visitsByPage[$page] ?? 0) + 1;
|
||||
}
|
||||
arsort($visitsByPage);
|
||||
|
||||
// Visites par utilisateur
|
||||
$visitsByUser = [];
|
||||
foreach ($visits as $visit) {
|
||||
$visitsByUser[$visit->email] = ($visitsByUser[$visit->email] ?? 0) + 1;
|
||||
}
|
||||
arsort($visitsByUser);
|
||||
|
||||
// Nombre de sessions uniques
|
||||
$uniqueSessions = count(array_unique(array_map(fn($v) => $v->sessionId, $visits)));
|
||||
|
||||
// Visites par type de page
|
||||
$visitsByType = [];
|
||||
foreach ($visits as $visit) {
|
||||
$visitsByType[$visit->pageType] = ($visitsByType[$visit->pageType] ?? 0) + 1;
|
||||
}
|
||||
arsort($visitsByType);
|
||||
|
||||
return [
|
||||
'totalVisits' => count($visits),
|
||||
'uniqueSessions' => $uniqueSessions,
|
||||
'visitsByDay' => $visitsByDay,
|
||||
'visitsByPage' => array_slice($visitsByPage, 0, 10),
|
||||
'visitsByUser' => $visitsByUser,
|
||||
'visitsByType' => $visitsByType,
|
||||
];
|
||||
}
|
||||
}
|
||||
46
public/site/plugins/analytics/classes/Visit.php
Normal file
46
public/site/plugins/analytics/classes/Visit.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace adrienpayet\analytics;
|
||||
|
||||
class Visit
|
||||
{
|
||||
public string $id;
|
||||
public string $email;
|
||||
public ?string $country;
|
||||
public string $timestamp;
|
||||
public string $sessionId;
|
||||
public string $pageUrl;
|
||||
public string $pageType;
|
||||
public ?string $pageName;
|
||||
|
||||
public function __construct(array $data)
|
||||
{
|
||||
$this->id = $data['id'] ?? uniqid('visit_', true);
|
||||
$this->email = $data['email'];
|
||||
$this->country = $data['country'] ?? null;
|
||||
$this->timestamp = $data['timestamp'] ?? date('Y-m-d H:i:s');
|
||||
$this->sessionId = $data['sessionId'];
|
||||
$this->pageUrl = $data['pageUrl'];
|
||||
$this->pageType = $data['pageType'];
|
||||
$this->pageName = $data['pageName'] ?? null;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'email' => $this->email,
|
||||
'country' => $this->country,
|
||||
'timestamp' => $this->timestamp,
|
||||
'sessionId' => $this->sessionId,
|
||||
'pageUrl' => $this->pageUrl,
|
||||
'pageType' => $this->pageType,
|
||||
'pageName' => $this->pageName,
|
||||
];
|
||||
}
|
||||
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
return new self($data);
|
||||
}
|
||||
}
|
||||
18
public/site/plugins/analytics/fields/dashboard.php
Normal file
18
public/site/plugins/analytics/fields/dashboard.php
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'props' => [
|
||||
'value' => function ($value = null) {
|
||||
return null;
|
||||
}
|
||||
],
|
||||
'computed' => [
|
||||
'analyticsData' => function () {
|
||||
$page = $this->model();
|
||||
if (method_exists($page, 'getAnalyticsData')) {
|
||||
return $page->getAnalyticsData();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
]
|
||||
];
|
||||
1
public/site/plugins/analytics/index.css
Normal file
1
public/site/plugins/analytics/index.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
.k-analytics-dashboard{padding:1.5rem 0}.k-analytics-filters{display:flex;gap:1rem;margin-bottom:1.5rem}.k-analytics-filters label{display:flex;align-items:center;gap:.5rem;font-size:.875rem;color:var(--color-text-light)}.k-analytics-filters input[type=date]{padding:.375rem .5rem;border:1px solid var(--color-border);border-radius:var(--rounded);font-size:.875rem;background:var(--color-background)}.k-analytics-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1.5rem;margin-bottom:1.5rem}.k-analytics-grid--2col{grid-template-columns:repeat(2,1fr)}.k-analytics-card{background:var(--color-background);border-radius:var(--rounded);padding:1.5rem;box-shadow:var(--shadow)}.k-analytics-card h3{font-size:.75rem;font-weight:600;color:var(--color-text-light);margin:0 0 .5rem;text-transform:uppercase;letter-spacing:.5px}.k-analytics-metric{font-size:2.5rem;font-weight:700;color:var(--color-text);line-height:1}.k-analytics-chart-container{background:var(--color-background);border-radius:var(--rounded);padding:1.5rem;margin-bottom:1.5rem;box-shadow:var(--shadow)}.k-analytics-chart-container h3{font-size:.875rem;font-weight:600;color:var(--color-text);margin:0 0 1rem}.k-analytics-chart-container canvas{max-height:300px}.k-analytics-empty{background:var(--color-background);border-radius:var(--rounded);padding:3rem;text-align:center;box-shadow:var(--shadow)}.k-analytics-empty p{margin:0;color:var(--color-text-light)}.k-analytics-list{list-style:none;margin:0;padding:0}.k-analytics-list li{display:flex;justify-content:space-between;padding:.375rem 0;border-bottom:1px solid var(--color-border);font-size:.875rem}.k-analytics-list li:last-child{border-bottom:none}.k-analytics-list-label{color:var(--color-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-right:1rem}.k-analytics-list-value{font-weight:600;color:var(--color-text);flex-shrink:0}
|
||||
18
public/site/plugins/analytics/index.js
Normal file
18
public/site/plugins/analytics/index.js
Normal file
File diff suppressed because one or more lines are too long
27
public/site/plugins/analytics/index.php
Normal file
27
public/site/plugins/analytics/index.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
use adrienpayet\analytics\Visit;
|
||||
use adrienpayet\analytics\AnalyticsPage;
|
||||
use adrienpayet\analytics\AnalyticsStore;
|
||||
|
||||
F::loadClasses([
|
||||
"adrienpayet\\analytics\\Visit" => __DIR__ . "/classes/Visit.php",
|
||||
"adrienpayet\\analytics\\AnalyticsPage" => __DIR__ . "/classes/AnalyticsPage.php",
|
||||
"adrienpayet\\analytics\\AnalyticsStore" => __DIR__ . "/classes/AnalyticsStore.php",
|
||||
]);
|
||||
|
||||
Kirby::plugin("adrienpayet/analytics", [
|
||||
"pageModels" => [
|
||||
"analytics" => AnalyticsPage::class,
|
||||
],
|
||||
"blueprints" => [
|
||||
"pages/analytics" => __DIR__ . "/blueprints/pages/analytics.yml",
|
||||
],
|
||||
"routes" => [
|
||||
require(__DIR__ . "/routes/track.php"),
|
||||
require(__DIR__ . "/routes/get-data.php"),
|
||||
],
|
||||
"fields" => [
|
||||
"analytics-dashboard" => require(__DIR__ . "/fields/dashboard.php"),
|
||||
],
|
||||
]);
|
||||
30
public/site/plugins/analytics/package-lock.json
generated
Normal file
30
public/site/plugins/analytics/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "analytics",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"chart.js": "^4.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
public/site/plugins/analytics/package.json
Normal file
9
public/site/plugins/analytics/package.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"scripts": {
|
||||
"dev": "npx -y kirbyup src/index.js --watch",
|
||||
"build": "npx -y kirbyup src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"chart.js": "^4.4.0"
|
||||
}
|
||||
}
|
||||
50
public/site/plugins/analytics/routes/get-data.php
Normal file
50
public/site/plugins/analytics/routes/get-data.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'pattern' => 'analytics-data.json',
|
||||
'method' => 'GET',
|
||||
'action' => function () {
|
||||
$kirby = kirby();
|
||||
$user = $kirby->user();
|
||||
|
||||
// Seuls les admins peuvent accéder aux données analytics
|
||||
if (!$user || !$user->isAdmin()) {
|
||||
return [
|
||||
'status' => 'error',
|
||||
'message' => 'Unauthorized'
|
||||
];
|
||||
}
|
||||
|
||||
$analyticsPage = $kirby->page('analytics');
|
||||
|
||||
if (!$analyticsPage) {
|
||||
return [
|
||||
'status' => 'error',
|
||||
'message' => 'Analytics page not found'
|
||||
];
|
||||
}
|
||||
|
||||
$request = $kirby->request();
|
||||
$filters = [];
|
||||
|
||||
// Récupérer les filtres depuis query params
|
||||
if ($startDate = $request->query()->get('startDate')) {
|
||||
$filters['startDate'] = $startDate;
|
||||
}
|
||||
|
||||
if ($endDate = $request->query()->get('endDate')) {
|
||||
$filters['endDate'] = $endDate;
|
||||
}
|
||||
|
||||
if ($project = $request->query()->get('project')) {
|
||||
$filters['project'] = $project;
|
||||
}
|
||||
|
||||
$data = $analyticsPage->getAnalyticsData($filters);
|
||||
|
||||
return [
|
||||
'status' => 'success',
|
||||
'data' => $data
|
||||
];
|
||||
}
|
||||
];
|
||||
61
public/site/plugins/analytics/routes/track.php
Normal file
61
public/site/plugins/analytics/routes/track.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
use adrienpayet\analytics\Visit;
|
||||
use adrienpayet\analytics\AnalyticsStore;
|
||||
|
||||
return [
|
||||
'pattern' => 'track-visit.json',
|
||||
'method' => 'POST',
|
||||
'action' => function () {
|
||||
$kirby = kirby();
|
||||
$user = $kirby->user();
|
||||
|
||||
// Seuls les utilisateurs connectés peuvent tracker
|
||||
if (!$user) {
|
||||
return [
|
||||
'status' => 'error',
|
||||
'message' => 'Unauthorized'
|
||||
];
|
||||
}
|
||||
|
||||
$data = $kirby->request()->body()->toArray();
|
||||
|
||||
// Détection du pays
|
||||
$country = null;
|
||||
|
||||
// 1. Header Cloudflare
|
||||
if (isset($_SERVER['HTTP_CF_IPCOUNTRY'])) {
|
||||
$country = $_SERVER['HTTP_CF_IPCOUNTRY'];
|
||||
}
|
||||
// 2. Fallback : API ipapi.co (optionnel, peut être désactivé)
|
||||
elseif (!empty($_SERVER['REMOTE_ADDR']) && $_SERVER['REMOTE_ADDR'] !== '127.0.0.1') {
|
||||
try {
|
||||
$ip = $_SERVER['REMOTE_ADDR'];
|
||||
$response = @file_get_contents("https://ipapi.co/{$ip}/country/", false, stream_context_create([
|
||||
'http' => ['timeout' => 1]
|
||||
]));
|
||||
if ($response) {
|
||||
$country = trim($response);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// Ignorer les erreurs de géolocalisation
|
||||
}
|
||||
}
|
||||
|
||||
$visit = new Visit([
|
||||
'email' => $user->email()->value(),
|
||||
'country' => $country,
|
||||
'sessionId' => $data['sessionId'] ?? '',
|
||||
'pageUrl' => $data['pageUrl'] ?? '',
|
||||
'pageType' => $data['pageType'] ?? 'unknown',
|
||||
'pageName' => $data['pageName'] ?? null,
|
||||
]);
|
||||
|
||||
AnalyticsStore::addVisit($visit);
|
||||
|
||||
return [
|
||||
'status' => 'success',
|
||||
'message' => 'Visit tracked'
|
||||
];
|
||||
}
|
||||
];
|
||||
|
|
@ -0,0 +1,355 @@
|
|||
<template>
|
||||
<div class="k-analytics-dashboard">
|
||||
<div class="k-analytics-filters">
|
||||
<label>
|
||||
Du
|
||||
<input type="date" v-model="startDate" @change="fetchData" />
|
||||
</label>
|
||||
<label>
|
||||
Au
|
||||
<input type="date" v-model="endDate" @change="fetchData" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="!hasData" class="k-analytics-empty">
|
||||
<p>Aucune donnée à afficher</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="k-analytics-grid">
|
||||
<div class="k-analytics-card">
|
||||
<h3>Sessions uniques</h3>
|
||||
<div class="k-analytics-metric">{{ data.uniqueSessions }}</div>
|
||||
</div>
|
||||
<div class="k-analytics-card">
|
||||
<h3>Pages vues</h3>
|
||||
<div class="k-analytics-metric">{{ data.totalVisits }}</div>
|
||||
</div>
|
||||
<div class="k-analytics-card">
|
||||
<h3>Pages / session</h3>
|
||||
<div class="k-analytics-metric">{{ pagesPerSession }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="k-analytics-chart-container">
|
||||
<h3>Visites par jour</h3>
|
||||
<canvas ref="chartCanvas"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="k-analytics-grid k-analytics-grid--2col">
|
||||
<div
|
||||
class="k-analytics-card"
|
||||
v-if="data.visitsByPage && Object.keys(data.visitsByPage).length"
|
||||
>
|
||||
<h3>Pages les plus visitées</h3>
|
||||
<ul class="k-analytics-list">
|
||||
<li v-for="(count, page) in data.visitsByPage" :key="page">
|
||||
<span class="k-analytics-list-label">{{ page }}</span>
|
||||
<span class="k-analytics-list-value">{{ count }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
class="k-analytics-card"
|
||||
v-if="data.visitsByUser && Object.keys(data.visitsByUser).length"
|
||||
>
|
||||
<h3>Utilisateurs les plus actifs</h3>
|
||||
<ul class="k-analytics-list">
|
||||
<li v-for="(count, user) in data.visitsByUser" :key="user">
|
||||
<span class="k-analytics-list-label">{{ user }}</span>
|
||||
<span class="k-analytics-list-value">{{ count }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
Chart,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
LinearScale,
|
||||
CategoryScale,
|
||||
Filler,
|
||||
Tooltip,
|
||||
} from 'chart.js';
|
||||
|
||||
Chart.register(
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
LinearScale,
|
||||
CategoryScale,
|
||||
Filler,
|
||||
Tooltip
|
||||
);
|
||||
|
||||
export default {
|
||||
props: {
|
||||
analyticsData: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
data: this.analyticsData || {},
|
||||
chart: null,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
hasData() {
|
||||
return this.data && this.data.totalVisits > 0;
|
||||
},
|
||||
pagesPerSession() {
|
||||
if (!this.data?.uniqueSessions) return '0';
|
||||
return (this.data.totalVisits / this.data.uniqueSessions).toFixed(1);
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
analyticsData(newVal) {
|
||||
this.data = newVal || {};
|
||||
this.renderChart();
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.setDefaultDates();
|
||||
this.fetchData();
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
this.destroyChart();
|
||||
},
|
||||
|
||||
methods: {
|
||||
setDefaultDates() {
|
||||
const now = new Date();
|
||||
const thirtyDaysAgo = new Date(now);
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
this.endDate = now.toISOString().split('T')[0];
|
||||
this.startDate = thirtyDaysAgo.toISOString().split('T')[0];
|
||||
},
|
||||
|
||||
async fetchData() {
|
||||
const params = new URLSearchParams();
|
||||
if (this.startDate) params.set('startDate', this.startDate);
|
||||
if (this.endDate) params.set('endDate', this.endDate);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/analytics-data.json?${params}`);
|
||||
const json = await response.json();
|
||||
|
||||
if (json.status === 'success') {
|
||||
this.data = json.data;
|
||||
}
|
||||
|
||||
this.renderChart();
|
||||
} catch (e) {
|
||||
console.error('Analytics fetch error:', e);
|
||||
this.renderChart();
|
||||
}
|
||||
},
|
||||
|
||||
destroyChart() {
|
||||
if (this.chart) {
|
||||
this.chart.destroy();
|
||||
this.chart = null;
|
||||
}
|
||||
},
|
||||
|
||||
formatDateFR(isoDate) {
|
||||
const [y, m, d] = isoDate.split('-');
|
||||
return `${d}/${m}/${y}`;
|
||||
},
|
||||
|
||||
renderChart() {
|
||||
this.destroyChart();
|
||||
|
||||
const canvas = this.$refs.chartCanvas;
|
||||
if (!canvas || !this.data?.visitsByDay) return;
|
||||
|
||||
const labels = Object.keys(this.data.visitsByDay).map((d) =>
|
||||
this.formatDateFR(d)
|
||||
);
|
||||
const values = Object.values(this.data.visitsByDay);
|
||||
|
||||
if (!labels.length) return;
|
||||
|
||||
const maxValue = Math.max(...values);
|
||||
|
||||
this.chart = new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Visites',
|
||||
data: values,
|
||||
borderColor: '#4271ae',
|
||||
backgroundColor: 'rgba(66, 113, 174, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
max: maxValue + 3,
|
||||
ticks: { precision: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.k-analytics-dashboard {
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
.k-analytics-filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.k-analytics-filters label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
.k-analytics-filters input[type='date'] {
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--rounded);
|
||||
font-size: 0.875rem;
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.k-analytics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.k-analytics-grid--2col {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.k-analytics-card {
|
||||
background: var(--color-background);
|
||||
border-radius: var(--rounded);
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.k-analytics-card h3 {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-light);
|
||||
margin: 0 0 0.5rem 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.k-analytics-metric {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.k-analytics-chart-container {
|
||||
background: var(--color-background);
|
||||
border-radius: var(--rounded);
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.k-analytics-chart-container h3 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.k-analytics-chart-container canvas {
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.k-analytics-empty {
|
||||
background: var(--color-background);
|
||||
border-radius: var(--rounded);
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.k-analytics-empty p {
|
||||
margin: 0;
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
.k-analytics-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.k-analytics-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.375rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.k-analytics-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.k-analytics-list-label {
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.k-analytics-list-value {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
7
public/site/plugins/analytics/src/index.js
Normal file
7
public/site/plugins/analytics/src/index.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import AnalyticsDashboard from "./components/AnalyticsDashboard.vue";
|
||||
|
||||
window.panel.plugin("adrienpayet/analytics", {
|
||||
fields: {
|
||||
"analytics-dashboard": AnalyticsDashboard
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue