Compare commits

...

2 commits

Author SHA1 Message Date
isUnknown
de104dc7dd feat: filtre utilisateurs analytics, améliorations dashboard + autres modifs
All checks were successful
Deploy Preprod / Build and Deploy to Preprod (push) Successful in 34s
- Multiselect Kirby pour filtrer par utilisateur(s)
- Données de test alignées sur les vrais comptes
- Suppression bloc utilisateurs les plus actifs
- Route get-data supporte le filtre emails
- Améliorations UI filtres (layout dates + users)
- Autres modifs : menu, router, dialog, deploy workflow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 11:27:27 +01:00
isUnknown
8a73da920f 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>
2026-03-03 10:33:15 +01:00
20 changed files with 1074 additions and 0 deletions

View file

@ -0,0 +1,53 @@
name: Deploy Production
on:
push:
branches:
- main
jobs:
build-and-deploy:
name: Build and Deploy to Production
runs-on: docker
container:
image: forgejo-ci-node:latest
steps:
- name: Checkout code
run: |
git clone --depth 1 --branch main https://forge.studio-variable.com/${{ github.repository }}.git .
ls -la
- name: Install npm dependencies
run: npm install
- name: Build frontend (production)
run: npm run build
- name: Install composer dependencies
run: |
cd dist
composer install --no-dev --optimize-autoloader
- name: Deploy via rsync
env:
USERNAME: ${{ secrets.DEMO_USERNAME }}
PASSWORD: ${{ secrets.DEMO_PASSWORD }}
HOST: ${{ secrets.DEMO_HOST }}
run: |
cd dist
lftp -c "
set ftp:ssl-allow no;
open -u $USERNAME,$PASSWORD $PRODUCTION_HOST;
mirror --reverse --delete --verbose --ignore-time --parallel=10 \
assets assets;
mirror --reverse --delete --verbose --ignore-time --parallel=10 \
-x 'accounts/' \
-x 'cache/' \
-x 'sessions/' \
site site;
mirror --reverse --delete --verbose --ignore-time --parallel=10 \
kirby kirby;
mirror --reverse --delete --verbose --ignore-time --parallel=10 \
vendor vendor;
put index.php -o index.php;
quit"

View file

View file

@ -60,6 +60,16 @@ $menu = [
],
'-',
'-',
'analytics' => [
'label' => 'Analytics',
'icon' => 'chart',
'link' => 'pages/analytics',
'current' => function (string $current): bool {
$path = Kirby\Cms\App::instance()->path();
return Str::contains($path, 'pages/analytics');
}
],
'-',
'users',
'system'
];

View file

@ -0,0 +1,12 @@
title: Analytics
icon: chart
columns:
- width: 1/1
sections:
dashboard:
type: fields
fields:
analytics:
type: analytics-dashboard
label: false

View 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);
}
}

View 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,
];
}
}

View 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);
}
}

View 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 [];
}
]
];

View 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-date-inputs-wrapper{display:flex;column-gap:1rem}.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-user-filter{display:flex;align-items:center;gap:.5rem;font-size:.875rem;color:var(--color-text-light);margin-left:2rem}.k-field-name-user{min-width:15rem}.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}

File diff suppressed because one or more lines are too long

View 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"),
],
]);

View 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"
}
}
}
}

View 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"
}
}

View file

@ -0,0 +1,76 @@
<?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 = [];
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;
}
if (!empty($_GET['emails'])) {
$filters['emails'] = explode(',', $_GET['emails']);
}
$data = $analyticsPage->getAnalyticsData($filters);
$users = [];
foreach ($kirby->users() as $u) {
$email = (string) $u->email();
$name = $u->name()->isNotEmpty() ? (string) $u->name() : $email;
$label = $name;
$clientField = $u->content()->get('client');
if ($clientField && $clientField->isNotEmpty()) {
$clientPage = $clientField->toPage();
if ($clientPage) {
$label .= ' (' . $clientPage->title() . ')';
}
}
$users[] = [
'email' => $email,
'label' => $label,
];
}
usort($users, fn($a, $b) => strcasecmp($a['label'], $b['label']));
return [
'status' => 'success',
'data' => $data,
'users' => $users
];
}
];

View 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'
];
}
];

View file

@ -0,0 +1,393 @@
<template>
<div class="k-analytics-dashboard">
<div class="k-analytics-filters">
<div class="k-analytics-date-filter">
<header class="k-field-header">
<label class="k-label k-field-label" title="Filtrer par dates">
<span class="k-label-text">Filtrer par dates </span>
</label>
</header>
<div class="k-date-inputs-wrapper">
<label>
Du
<input type="date" v-model="startDate" @change="fetchData" />
</label>
<label>
Au
<input type="date" v-model="endDate" @change="fetchData" />
</label>
</div>
</div>
<div class="k-analytics-user-filter">
<k-multiselect-field
:options="userOptions"
:value="selectedEmails"
label="Filtrer par utilisateur(s)"
search="true"
name="user"
@input="onUserSelectionChange"
/>
</div>
</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-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>
</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,
users: [],
selectedEmails: [],
};
},
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);
},
userOptions() {
return this.users.map((u) => ({ value: u.email, text: u.label }));
},
},
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];
},
onUserSelectionChange(emails) {
this.selectedEmails = emails;
this.fetchData();
},
async fetchData() {
const params = new URLSearchParams();
if (this.startDate) params.set('startDate', this.startDate);
if (this.endDate) params.set('endDate', this.endDate);
if (this.selectedEmails.length) {
params.set('emails', this.selectedEmails.join(','));
}
try {
const response = await fetch(`/analytics-data.json?${params}`);
const json = await response.json();
console.log(json.data);
if (json.status === 'success') {
this.data = json.data;
}
if (json.users && !this.users.length) {
this.users = json.users;
}
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-date-inputs-wrapper {
display: flex;
column-gap: 1rem;
}
.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-user-filter {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--color-text-light);
margin-left: 2rem;
}
.k-field-name-user {
min-width: 15rem;
}
.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>

View file

@ -0,0 +1,7 @@
import AnalyticsDashboard from "./components/AnalyticsDashboard.vue";
window.panel.plugin("adrienpayet/analytics", {
fields: {
"analytics-dashboard": AnalyticsDashboard
}
});

View file

@ -4,6 +4,7 @@ import { useApiStore } from '../stores/api';
import { usePageStore } from '../stores/page';
import { useUserStore } from '../stores/user';
import { useLocaleStore } from '../stores/locale';
import { useAnalyticsStore } from '../stores/analytics';
const router = createRouter({
history: createWebHistory(),
@ -41,6 +42,65 @@ router.beforeEach(async (to, from, next) => {
}
});
router.afterEach((to) => {
const userStore = useUserStore();
const pageStore = usePageStore();
if (userStore.user) {
const analytics = useAnalyticsStore();
analytics.initSession();
const { pageType, pageName } = getPageInfo(to, pageStore.page);
analytics.trackVisit(to.path, pageType, pageName);
}
});
function getPageInfo(route, page) {
const path = route.path;
if (path === '/' || path === '/en') {
return { pageType: 'home', pageName: 'Accueil' };
}
if (path.includes('/login')) {
return { pageType: 'login', pageName: 'Connexion' };
}
if (path.includes('/account')) {
return { pageType: 'account', pageName: 'Compte' };
}
if (path.includes('/notifications')) {
return { pageType: 'notifications', pageName: 'Notifications' };
}
if (path.includes('/reunions')) {
return { pageType: 'reunions', pageName: 'Réunions' };
}
if (path.includes('/inspirations')) {
return { pageType: 'inspirations', pageName: 'Inspirations' };
}
if (path.includes('/design-to-light')) {
return { pageType: 'design-to-light', pageName: 'Design to Light' };
}
if (path.includes('/client-brief')) {
return { pageType: 'client-brief', pageName: page?.title || 'Brief Client' };
}
if (path.includes('/extended-brief')) {
return { pageType: 'extended-brief', pageName: page?.title || 'Brief Étendu' };
}
if (path.includes('/projects/')) {
return { pageType: 'project', pageName: page?.title || 'Projet' };
}
return { pageType: 'unknown', pageName: path };
}
export function setI18nLocale(i18n) {
router.afterEach(() => {
const localeStore = useLocaleStore();

54
src/stores/analytics.js Normal file
View file

@ -0,0 +1,54 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
export const useAnalyticsStore = defineStore('analytics', () => {
const sessionId = ref(null);
function initSession() {
// Récupérer sessionId depuis sessionStorage ou en créer un nouveau
const storedSessionId = sessionStorage.getItem('analyticsSessionId');
if (storedSessionId) {
sessionId.value = storedSessionId;
} else {
sessionId.value = generateSessionId();
sessionStorage.setItem('analyticsSessionId', sessionId.value);
}
}
function generateSessionId() {
return `session_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
}
async function trackVisit(pageUrl, pageType, pageName = null) {
if (!sessionId.value) {
initSession();
}
const data = {
sessionId: sessionId.value,
pageUrl,
pageType,
pageName,
};
try {
await fetch('/track-visit.json', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
} catch (error) {
// Tracking silencieux : ne pas bloquer l'app si ça échoue
console.debug('Analytics tracking failed:', error);
}
}
return {
sessionId,
initSession,
trackVisit,
};
});

View file

@ -1,6 +1,7 @@
import { defineStore } from 'pinia';
import { ref, computed, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useAnalyticsStore } from './analytics';
export const useDialogStore = defineStore('dialog', () => {
const content = ref(null);
@ -148,6 +149,19 @@ export const useDialogStore = defineStore('dialog', () => {
}
}
// Analytics tracking pour ouverture de fichiers
watch(openedFile, (newFile) => {
if (newFile) {
const analytics = useAnalyticsStore();
const currentPath = route.path;
analytics.trackVisit(
`${currentPath}#file-${newFile.uuid}`,
'modal-file',
newFile.name || newFile.filename
);
}
});
return {
content,
activeTracks,