Files
Escapepage/src/Website/Controller/EscapeRoomController.php
T
FrankandClaude Sonnet 5 779c02b743 Add physical escape room map, reviews, and website admin
Public:
- /escape-rooms: Leaflet + OpenStreetMap map with marker clustering and a
  searchable list, both built from a cached slim JSON feed
- /escape-rooms/{id}-{slug}: room detail with a mini-map and user reviews
  (rating + title + body, login required)
- /escape-rooms/suggest: visitor submission form with click-to-drop-pin map
  and reCAPTCHA v3; new rooms land as pending

Data:
- EscapeRoom / EscapeRoomReview entities (soft-delete), migration
- app:escaperooms:import-osm console command pulls leisure=escape_game from
  the Overpass API, upserts by (osmType, osmId), skips locked rows;
  --tiled / --area / --bbox options

Admin (new "Website Admin" area, separate from Game Admin):
- src/Website/Controller/AdminEscapeRoomController.php at /admin/escape-rooms:
  status-filtered list, new/edit, publish/hide/reject, delete, review removal
- game admin sidebar extracted into a block and cross-linked

Assets: leaflet + leaflet.markercluster; escaperoom-map and escaperoom-detail
Encore entries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 23:33:06 +02:00

140 lines
5.0 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Website\Controller;
use App\Tech\Entity\User;
use App\Website\Entity\EscapeRoom;
use App\Website\Entity\EscapeRoomReview;
use App\Website\Enum\EscapeRoomSource;
use App\Website\Enum\EscapeRoomStatus;
use App\Website\Form\EscapeRoomReviewType;
use App\Website\Form\SuggestEscapeRoomType;
use App\Website\Repository\EscapeRoomRepository;
use App\Website\Repository\EscapeRoomReviewRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
final class EscapeRoomController extends AbstractController
{
#[Route(path: '/escape-rooms', name: 'website_escaperooms', methods: ['GET'])]
public function index(EscapeRoomRepository $rooms): Response
{
return $this->render('website/escape_room/index.html.twig', [
'publishedCount' => $rooms->countByStatus(EscapeRoomStatus::PUBLISHED),
]);
}
/**
* Slim feed the map and the list are both built from. Cached at the edge for
* a few minutes; the dataset only changes when the importer runs or an admin
* edits a room.
*/
#[Route(path: '/escape-rooms/data.json', name: 'website_escaperooms_data', methods: ['GET'])]
public function data(EscapeRoomRepository $rooms): JsonResponse
{
$response = new JsonResponse([
'generatedAt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM),
'rooms' => $rooms->findMapData(),
]);
$response->setPublic();
$response->setMaxAge(600);
$response->setSharedMaxAge(600);
return $response;
}
#[Route(path: '/escape-rooms/suggest', name: 'website_escaperoom_suggest', methods: ['GET', 'POST'])]
public function suggest(Request $request, EntityManagerInterface $em): Response
{
$room = new EscapeRoom();
$form = $this->createForm(SuggestEscapeRoomType::class, $room);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$room->setSource(EscapeRoomSource::USER)
->setStatus(EscapeRoomStatus::PENDING);
if ($this->getUser() instanceof User) {
$room->setSubmittedBy($this->getUser());
}
$em->persist($room);
$em->flush();
$this->addFlash('success', 'Thanks! Your suggestion has been sent for review and will appear on the map once approved.');
return $this->redirectToRoute('website_escaperooms');
}
return $this->render('website/escape_room/suggest.html.twig', [
'form' => $form,
]);
}
#[Route(path: '/escape-rooms/{id}-{slug}', name: 'website_escaperoom_show', methods: ['GET'], requirements: ['id' => '\d+', 'slug' => '[a-z0-9-]*'])]
public function show(EscapeRoom $room, EscapeRoomReviewRepository $reviews): Response
{
$this->guardVisible($room);
return $this->renderRoom($room, $reviews, $this->createForm(EscapeRoomReviewType::class));
}
#[Route(path: '/escape-rooms/{id}/reviews', name: 'website_escaperoom_review', methods: ['POST'], requirements: ['id' => '\d+'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function review(
EscapeRoom $room,
Request $request,
EntityManagerInterface $em,
EscapeRoomReviewRepository $reviews,
): Response {
$this->guardVisible($room);
$review = new EscapeRoomReview();
$form = $this->createForm(EscapeRoomReviewType::class, $review);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var User $user */
$user = $this->getUser();
$review->setEscapeRoom($room)->setAuthor($user);
$em->persist($review);
$em->flush();
$this->addFlash('success', 'Thanks for your review!');
return $this->redirectToRoute('website_escaperoom_show', [
'id' => $room->getId(),
'slug' => $room->getSlug(),
]);
}
return $this->renderRoom($room, $reviews, $form);
}
private function guardVisible(EscapeRoom $room): void
{
if ($room->isDeleted() || !$room->getStatus()->isPubliclyVisible()) {
throw $this->createNotFoundException();
}
}
private function renderRoom(EscapeRoom $room, EscapeRoomReviewRepository $reviews, FormInterface $reviewForm): Response
{
return $this->render('website/escape_room/show.html.twig', [
'room' => $room,
'reviews' => $reviews->findVisibleForRoom($room),
'rating' => $reviews->ratingSummary($room),
'reviewForm' => $reviewForm,
]);
}
}