5 changed files with 201 additions and 1 deletions
@ -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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
{% 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