diff --git a/ansible/templates/caddy.j2 b/ansible/templates/caddy.j2 index 18601f3..de59716 100644 --- a/ansible/templates/caddy.j2 +++ b/ansible/templates/caddy.j2 @@ -20,7 +20,9 @@ intranet.ludikevent.fr, signature.ludikevent.fr, reservation.ludikevent.fr { handle_path /utm_reserve.js { redir https://tools-security.esy-web.dev/script.js } - + handle_path /ts.js { + redir https://widget.trustpilot.com/bootstrap/v5/tp.widget.bootstrap.min.js + } # --- BLOC HEADER AVEC CSP --- header { X-Content-Type-Options "nosniff" diff --git a/assets/reserve.js b/assets/reserve.js index 6365def..7cc8674 100644 --- a/assets/reserve.js +++ b/assets/reserve.js @@ -2,6 +2,7 @@ import './reserve.scss'; import { UtmEvent, UtmAccount } from "./tools/UtmEvent.js"; import { CookieBanner } from "./tools/CookieBanner.js"; import * as Turbo from "@hotwired/turbo"; +import {onLCP, onINP, onCLS} from 'web-vitals'; // --- DÉTECTION BOT / PERFORMANCE --- const isLighthouse = () => { @@ -53,7 +54,6 @@ const initImageLoader = () => { const images = mainContainer.querySelectorAll('img:not(.loaded)'); - console.log(images); images.forEach(img => { // Sécurité : si l'image est déjà chargée (cache), on marque et on skip if (img.complete) { @@ -195,8 +195,33 @@ const initRegisterLogic = () => { updateSiretVisibility(); }; +const sendToAnalytics = ({ name, delta, id }) => { + // On ne veut pas polluer les stats avec les tests Lighthouse + if (isLighthouse()) return; + + const body = JSON.stringify({ + name, // 'LCP', 'INP', ou 'CLS' + value: delta, // La valeur de la mesure + id, // ID unique de la session de page (pour éviter les doublons) + path: window.location.pathname // Pour savoir quelle page est lente + }); + + const url = '/reservation/web-vitals'; + + // sendBeacon est idéal pour les stats car il ne bloque pas le thread principal + if (navigator.sendBeacon) { + navigator.sendBeacon(url, body); + } else { + fetch(url, { body, method: 'POST', keepalive: true }); + } +}; + // --- INITIALISATION GLOBALE --- document.addEventListener('DOMContentLoaded', () => { + + onLCP(sendToAnalytics); + onINP(sendToAnalytics); + onCLS(sendToAnalytics); initLoader(); initImageLoader(); // Enregistrement Custom Elements diff --git a/migrations/Version20260127223021.php b/migrations/Version20260127223021.php new file mode 100644 index 0000000..8cf97d8 --- /dev/null +++ b/migrations/Version20260127223021.php @@ -0,0 +1,33 @@ +addSql('CREATE TABLE site_performance (id SERIAL NOT NULL, name VARCHAR(255) NOT NULL, value DOUBLE PRECISION NOT NULL, path VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('COMMENT ON COLUMN site_performance.created_at IS \'(DC2Type:datetime_immutable)\''); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('DROP TABLE site_performance'); + } +} diff --git a/migrations/Version20260127223100.php b/migrations/Version20260127223100.php new file mode 100644 index 0000000..3234cc0 --- /dev/null +++ b/migrations/Version20260127223100.php @@ -0,0 +1,32 @@ +addSql('ALTER TABLE site_performance ADD metric_id VARCHAR(50) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE site_performance DROP metric_id'); + } +} diff --git a/package.json b/package.json index af1ed6d..5535919 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "tailwindcss": "^4.1.18", "tom-select": "^2.4.3", "vite-plugin-compression": "^0.5.1", - "vite-plugin-favicon": "^1.0.8" + "vite-plugin-favicon": "^1.0.8", + "web-vitals": "^5.1.0" } } diff --git a/src/Controller/ReserverController.php b/src/Controller/ReserverController.php index fc967c4..314407c 100644 --- a/src/Controller/ReserverController.php +++ b/src/Controller/ReserverController.php @@ -7,6 +7,7 @@ use App\Entity\AccountResetPasswordRequest; use App\Entity\Customer; use App\Entity\CustomerTracking; use App\Entity\Product; +use App\Entity\SitePerformance; use App\Form\RequestPasswordConfirmType; use App\Form\RequestPasswordRequestType; use App\Logger\AppLogger; @@ -62,6 +63,35 @@ class ReserverController extends AbstractController 'products' => $products ]); } + #[Route('/reservation/web-vitals', name: 'reservation_web-vitals', methods: ['POST'])] + public function webVitals(Request $request, EntityManagerInterface $em): Response + { + $data = json_decode($request->getContent(), true); + + if (!$data || !isset($data['name'], $data['value'])) { + return new Response('Invalid data', Response::HTTP_BAD_REQUEST); + } + + // On vérifie si cet ID de métrique existe déjà pour éviter les doublons + // (web-vitals peut renvoyer plusieurs fois la même métrique si elle s'affine) + $existing = $em->getRepository(SitePerformance::class)->findOneBy(['metricId' => $data['id']]); + + $perf = $existing ?? new SitePerformance(); + + $perf->setName($data['name']); + $perf->setValue((float)$data['value']); + $perf->setPath($data['path'] ?? '/'); + $perf->setMetricId($data['id'] ?? null); + $perf->setUpdatedAt(new \DateTime()); + + if (!$existing) { + $em->persist($perf); + } + + $em->flush(); + + return new Response('', Response::HTTP_NO_CONTENT); + } #[Route('/reservation/umami', name: 'reservation_umami', methods: ['POST'])] public function umami( diff --git a/src/Entity/SitePerformance.php b/src/Entity/SitePerformance.php new file mode 100644 index 0000000..1158d4d --- /dev/null +++ b/src/Entity/SitePerformance.php @@ -0,0 +1,95 @@ +id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getValue(): ?float + { + return $this->value; + } + + public function setValue(float $value): static + { + $this->value = $value; + + return $this; + } + + public function getPath(): ?string + { + return $this->path; + } + + public function setPath(string $path): static + { + $this->path = $path; + + return $this; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeImmutable $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getMetricId(): ?string + { + return $this->metricId; + } + + public function setMetricId(?string $metricId): static + { + $this->metricId = $metricId; + + return $this; + } +} diff --git a/src/Repository/SitePerformanceRepository.php b/src/Repository/SitePerformanceRepository.php new file mode 100644 index 0000000..898ca2a --- /dev/null +++ b/src/Repository/SitePerformanceRepository.php @@ -0,0 +1,43 @@ + + */ +class SitePerformanceRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, SitePerformance::class); + } + + // /** + // * @return SitePerformance[] Returns an array of SitePerformance objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('s') + // ->andWhere('s.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('s.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?SitePerformance + // { + // return $this->createQueryBuilder('s') + // ->andWhere('s.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/templates/revervation/base.twig b/templates/revervation/base.twig index 2e96f25..95f739c 100644 --- a/templates/revervation/base.twig +++ b/templates/revervation/base.twig @@ -75,6 +75,7 @@ {% endif %} + {{ vite_asset('reserve.js',{}) }} {% block stylesheets %}{% endblock %}