5 changed files with 201 additions and 1 deletions
@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Controller\Api; |
||||
|
||||
use App\Entity\Visit; |
||||
use App\Repository\VisitRepository; |
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; |
||||
use Symfony\Component\HttpFoundation\JsonResponse; |
||||
use Symfony\Component\HttpFoundation\Request; |
||||
use Symfony\Component\HttpFoundation\Response; |
||||
use Symfony\Component\Routing\Attribute\Route; |
||||
|
||||
class VisitController extends AbstractController |
||||
{ |
||||
#[Route('/api/visit', name: 'api_record_visit', methods: ['POST'])] |
||||
public function recordVisit(Request $request, VisitRepository $visitRepository): JsonResponse |
||||
{ |
||||
$data = json_decode($request->getContent(), true); |
||||
|
||||
if (!isset($data['route']) || empty($data['route'])) { |
||||
return new JsonResponse(['error' => 'Route is required'], Response::HTTP_BAD_REQUEST); |
||||
} |
||||
|
||||
$route = $data['route']; |
||||
$visit = new Visit($route); |
||||
|
||||
$visitRepository->save($visit); |
||||
|
||||
return new JsonResponse(['success' => true]); |
||||
} |
||||
} |
||||
@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
|
||||
<?php |
||||
|
||||
namespace App\Entity; |
||||
|
||||
use Doctrine\DBAL\Types\Types; |
||||
use Doctrine\ORM\Mapping as ORM; |
||||
use App\Repository\VisitRepository; |
||||
|
||||
#[ORM\Entity(repositoryClass: VisitRepository::class)] |
||||
class Visit |
||||
{ |
||||
#[ORM\Id] |
||||
#[ORM\GeneratedValue] |
||||
#[ORM\Column] |
||||
private ?int $id = null; |
||||
|
||||
#[ORM\Column(length: 255)] |
||||
private string $route; |
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)] |
||||
private \DateTimeImmutable $visitedAt; |
||||
|
||||
public function __construct(string $route) |
||||
{ |
||||
$this->route = $route; |
||||
$this->visitedAt = new \DateTimeImmutable(); |
||||
} |
||||
|
||||
public function getId(): ?int |
||||
{ |
||||
return $this->id; |
||||
} |
||||
|
||||
public function getRoute(): string |
||||
{ |
||||
return $this->route; |
||||
} |
||||
|
||||
public function setRoute(string $route): self |
||||
{ |
||||
$this->route = $route; |
||||
return $this; |
||||
} |
||||
|
||||
public function getVisitedAt(): \DateTimeImmutable |
||||
{ |
||||
return $this->visitedAt; |
||||
} |
||||
} |
||||
@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
|
||||
<?php |
||||
|
||||
namespace App\Repository; |
||||
|
||||
use App\Entity\Visit; |
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; |
||||
use Doctrine\Persistence\ManagerRegistry; |
||||
|
||||
/** |
||||
* @extends ServiceEntityRepository<Visit> |
||||
*/ |
||||
class VisitRepository extends ServiceEntityRepository |
||||
{ |
||||
public function __construct(ManagerRegistry $registry) |
||||
{ |
||||
parent::__construct($registry, Visit::class); |
||||
} |
||||
|
||||
public function save(Visit $visit, bool $flush = true): void |
||||
{ |
||||
$this->getEntityManager()->persist($visit); |
||||
|
||||
if ($flush) { |
||||
$this->getEntityManager()->flush(); |
||||
} |
||||
} |
||||
|
||||
public function getVisitCountByRoute(): array |
||||
{ |
||||
return $this->createQueryBuilder('v') |
||||
->select('v.route, COUNT(v.id) as count') |
||||
->groupBy('v.route') |
||||
->orderBy('count', 'DESC') |
||||
->getQuery() |
||||
->getResult(); |
||||
} |
||||
} |
||||
@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
|
||||
{% extends 'base.html.twig' %} |
||||
|
||||
{% block title %}Visitor Analytics{% endblock %} |
||||
|
||||
{% block body %} |
||||
<div class="analytics-container"> |
||||
<h1>Page Visit Analytics</h1> |
||||
|
||||
<div class="analytics-card"> |
||||
<h2>Visit Count by Route</h2> |
||||
|
||||
{% if visitStats|length > 0 %} |
||||
<table class="analytics-table"> |
||||
<thead> |
||||
<tr> |
||||
<th>Route</th> |
||||
<th>Visit Count</th> |
||||
</tr> |
||||
</thead> |
||||
<tbody> |
||||
{% for stat in visitStats %} |
||||
<tr> |
||||
<td>{{ stat.route }}</td> |
||||
<td class="text-right">{{ stat.count }}</td> |
||||
</tr> |
||||
{% endfor %} |
||||
</tbody> |
||||
</table> |
||||
{% else %} |
||||
<p>No visit data recorded yet.</p> |
||||
{% endif %} |
||||
</div> |
||||
|
||||
<div class="analytics-info"> |
||||
<p>This data shows the number of page visits per route. No personal user data is collected.</p> |
||||
</div> |
||||
</div> |
||||
{% endblock %} |
||||
|
||||
{% block stylesheets %} |
||||
{{ parent() }} |
||||
<style> |
||||
.analytics-container { |
||||
max-width: 800px; |
||||
margin: 0 auto; |
||||
padding: 20px; |
||||
} |
||||
|
||||
.analytics-card { |
||||
background: gray; |
||||
border-radius: 8px; |
||||
padding: 20px; |
||||
margin-bottom: 20px; |
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1); |
||||
} |
||||
|
||||
.analytics-table { |
||||
width: 100%; |
||||
border-collapse: collapse; |
||||
} |
||||
|
||||
.analytics-table th, .analytics-table td { |
||||
padding: 10px; |
||||
border-bottom: 1px solid var(--color-border); |
||||
} |
||||
|
||||
.analytics-table th { |
||||
text-align: left; |
||||
font-weight: 600; |
||||
} |
||||
|
||||
.text-right { |
||||
text-align: right; |
||||
} |
||||
|
||||
.analytics-info { |
||||
font-size: 0.9rem; |
||||
color: var(--color-border); |
||||
} |
||||
</style> |
||||
{% endblock %} |
||||
Loading…
Reference in new issue