diff --git a/assets/escaperoom-detail.js b/assets/escaperoom-detail.js
new file mode 100644
index 0000000..5d4cc07
--- /dev/null
+++ b/assets/escaperoom-detail.js
@@ -0,0 +1,85 @@
+/*
+ * Small single-purpose Leaflet maps:
+ * - #escaperoom-detail-map : read-only pin on a room's detail page
+ * - #escaperoom-suggest-map : click to drop / move a pin on the "suggest a room"
+ * form, writing the coordinates into the hidden latitude/longitude fields.
+ */
+import L from 'leaflet';
+import 'leaflet/dist/leaflet.css';
+
+import iconUrl from 'leaflet/dist/images/marker-icon.png';
+import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png';
+import shadowUrl from 'leaflet/dist/images/marker-shadow.png';
+
+delete L.Icon.Default.prototype._getIconUrl;
+L.Icon.Default.mergeOptions({ iconUrl, iconRetinaUrl, shadowUrl });
+
+const OSM = {
+ url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
+ opts: {
+ maxZoom: 19,
+ attribution: '© OpenStreetMap contributors',
+ },
+};
+
+function initDetailMap(el) {
+ const lat = parseFloat(el.dataset.lat);
+ const lng = parseFloat(el.dataset.lng);
+ if (Number.isNaN(lat) || Number.isNaN(lng)) {
+ return;
+ }
+
+ const map = L.map(el, { scrollWheelZoom: false }).setView([lat, lng], 15);
+ L.tileLayer(OSM.url, OSM.opts).addTo(map);
+ L.marker([lat, lng]).addTo(map);
+}
+
+function initSuggestMap(el) {
+ const form = el.closest('form');
+ const latField = form && form.querySelector('input[name$="[latitude]"]');
+ const lngField = form && form.querySelector('input[name$="[longitude]"]');
+ if (!latField || !lngField) {
+ return;
+ }
+
+ const startLat = parseFloat(latField.value);
+ const startLng = parseFloat(lngField.value);
+ const hasStart = !Number.isNaN(startLat) && !Number.isNaN(startLng);
+
+ const map = L.map(el).setView(hasStart ? [startLat, startLng] : [20, 0], hasStart ? 15 : 2);
+ L.tileLayer(OSM.url, OSM.opts).addTo(map);
+
+ let marker = hasStart ? L.marker([startLat, startLng], { draggable: true }).addTo(map) : null;
+
+ const write = (latlng) => {
+ latField.value = latlng.lat.toFixed(7);
+ lngField.value = latlng.lng.toFixed(7);
+ latField.dispatchEvent(new Event('change', { bubbles: true }));
+ };
+
+ if (marker) {
+ marker.on('dragend', () => write(marker.getLatLng()));
+ }
+
+ map.on('click', (e) => {
+ if (marker) {
+ marker.setLatLng(e.latlng);
+ } else {
+ marker = L.marker(e.latlng, { draggable: true }).addTo(map);
+ marker.on('dragend', () => write(marker.getLatLng()));
+ }
+ write(e.latlng);
+ });
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+ const detail = document.getElementById('escaperoom-detail-map');
+ if (detail) {
+ initDetailMap(detail);
+ }
+
+ const suggest = document.getElementById('escaperoom-suggest-map');
+ if (suggest) {
+ initSuggestMap(suggest);
+ }
+});
diff --git a/assets/escaperoom-map.js b/assets/escaperoom-map.js
new file mode 100644
index 0000000..66d9fb2
--- /dev/null
+++ b/assets/escaperoom-map.js
@@ -0,0 +1,151 @@
+/*
+ * Public "physical escape rooms" map + list.
+ *
+ * One JSON fetch (website_escaperooms_data) feeds both the Leaflet map (with
+ * marker clustering, so a worldwide dataset stays responsive) and the searchable
+ * table underneath it.
+ */
+import L from 'leaflet';
+import 'leaflet/dist/leaflet.css';
+import 'leaflet.markercluster';
+import 'leaflet.markercluster/dist/MarkerCluster.css';
+import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
+import { DataTable } from 'simple-datatables';
+import 'simple-datatables/dist/style.css';
+
+import iconUrl from 'leaflet/dist/images/marker-icon.png';
+import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png';
+import shadowUrl from 'leaflet/dist/images/marker-shadow.png';
+
+// Webpack rewrites the image paths, so hand them to Leaflet explicitly.
+delete L.Icon.Default.prototype._getIconUrl;
+L.Icon.Default.mergeOptions({ iconUrl, iconRetinaUrl, shadowUrl });
+
+function slugify(name) {
+ return (name || '')
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '') || 'room';
+}
+
+function escapeHtml(value) {
+ const div = document.createElement('div');
+ div.textContent = value == null ? '' : String(value);
+ return div.innerHTML;
+}
+
+function ratingLabel(avg, count) {
+ if (!count) {
+ return '—';
+ }
+ return `${avg.toFixed(1)} ★ (${count})`;
+}
+
+function detailUrl(template, room) {
+ return template.replace('__ID__', room.id).replace('__SLUG__', slugify(room.name));
+}
+
+function buildMap(el, rooms, detailTemplate) {
+ const map = L.map(el, { worldCopyJump: true }).setView([20, 0], 2);
+
+ L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ maxZoom: 19,
+ attribution: '© OpenStreetMap contributors',
+ }).addTo(map);
+
+ const cluster = L.markerClusterGroup({ chunkedLoading: true });
+ const bounds = [];
+
+ rooms.forEach((room) => {
+ if (typeof room.lat !== 'number' || typeof room.lng !== 'number') {
+ return;
+ }
+ const where = [room.city, room.country].filter(Boolean).join(', ');
+ const popup = `
+ ${escapeHtml(room.name)}
+ ${where ? `${escapeHtml(where)} ` : ''}
+ ${escapeHtml(ratingLabel(room.avgRating, room.reviewCount))}
+ View & review »
+ `;
+ const marker = L.marker([room.lat, room.lng]).bindPopup(popup);
+ cluster.addLayer(marker);
+ bounds.push([room.lat, room.lng]);
+ });
+
+ map.addLayer(cluster);
+
+ if (bounds.length) {
+ map.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
+ }
+}
+
+function buildList(table, rooms, detailTemplate) {
+ const tbody = table.querySelector('tbody');
+ const frag = document.createDocumentFragment();
+
+ rooms.forEach((room) => {
+ const tr = document.createElement('tr');
+ const where = room.city ? escapeHtml(room.city) : '';
+ const country = room.country ? escapeHtml(room.country) : '';
+ const ratingValue = room.reviewCount ? room.avgRating : 0;
+
+ tr.innerHTML = `
+
${escapeHtml(room.name)}
+ ${where}
+ ${country}
+ ${escapeHtml(ratingLabel(room.avgRating, room.reviewCount))}
+ `;
+ frag.appendChild(tr);
+ });
+
+ tbody.replaceChildren(frag);
+
+ new DataTable(table, {
+ perPage: 25,
+ perPageSelect: [25, 50, 100],
+ searchable: true,
+ sortable: true,
+ labels: {
+ placeholder: 'Search rooms…',
+ perPage: '{select} per page',
+ noRows: 'No matching rooms',
+ info: 'Showing {start}–{end} of {rows} rooms',
+ },
+ });
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+ const mapEl = document.getElementById('escaperoom-map');
+ if (!mapEl) {
+ return;
+ }
+
+ const endpoint = mapEl.dataset.endpoint;
+ const detailTemplate = mapEl.dataset.detailTemplate;
+ const listEl = document.getElementById('escaperoom-list');
+ const statusEl = document.getElementById('escaperoom-status');
+
+ fetch(endpoint, { headers: { Accept: 'application/json' } })
+ .then((res) => {
+ if (!res.ok) {
+ throw new Error(`HTTP ${res.status}`);
+ }
+ return res.json();
+ })
+ .then((payload) => {
+ const rooms = Array.isArray(payload.rooms) ? payload.rooms : [];
+ buildMap(mapEl, rooms, detailTemplate);
+ if (listEl) {
+ buildList(listEl, rooms, detailTemplate);
+ }
+ if (statusEl) {
+ statusEl.textContent = `${rooms.length.toLocaleString()} rooms on the map`;
+ }
+ })
+ .catch((err) => {
+ if (statusEl) {
+ statusEl.textContent = 'Could not load escape rooms right now.';
+ }
+ console.error('escaperoom-map:', err);
+ });
+});
diff --git a/migrations/Version20260829150000.php b/migrations/Version20260829150000.php
new file mode 100644
index 0000000..e291d4a
--- /dev/null
+++ b/migrations/Version20260829150000.php
@@ -0,0 +1,79 @@
+addSql(<<<'SQL'
+ CREATE TABLE escape_room (
+ id INT AUTO_INCREMENT NOT NULL,
+ submitted_by_id INT DEFAULT NULL,
+ name VARCHAR(255) NOT NULL,
+ venue VARCHAR(255) DEFAULT NULL,
+ street VARCHAR(255) DEFAULT NULL,
+ house_number VARCHAR(32) DEFAULT NULL,
+ postcode VARCHAR(32) DEFAULT NULL,
+ city VARCHAR(128) DEFAULT NULL,
+ country VARCHAR(2) DEFAULT NULL,
+ latitude NUMERIC(10, 7) NOT NULL,
+ longitude NUMERIC(10, 7) NOT NULL,
+ website VARCHAR(511) DEFAULT NULL,
+ phone VARCHAR(64) DEFAULT NULL,
+ source VARCHAR(16) NOT NULL,
+ osm_type VARCHAR(8) DEFAULT NULL,
+ osm_id BIGINT DEFAULT NULL,
+ status VARCHAR(16) NOT NULL,
+ locked TINYINT(1) DEFAULT 0 NOT NULL,
+ created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)',
+ updated_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)',
+ deleted_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)',
+ INDEX idx_escape_room_submitted_by (submitted_by_id),
+ UNIQUE INDEX uniq_escape_room_osm (osm_type, osm_id),
+ INDEX idx_escape_room_status (status),
+ INDEX idx_escape_room_bbox (latitude, longitude),
+ PRIMARY KEY(id)
+ ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
+ SQL);
+
+ $this->addSql(<<<'SQL'
+ CREATE TABLE escape_room_review (
+ id INT AUTO_INCREMENT NOT NULL,
+ escape_room_id INT NOT NULL,
+ author_id INT DEFAULT NULL,
+ rating SMALLINT NOT NULL,
+ title VARCHAR(150) NOT NULL,
+ body LONGTEXT NOT NULL,
+ created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)',
+ deleted_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)',
+ INDEX idx_escape_room_review_room (escape_room_id),
+ INDEX idx_escape_room_review_author (author_id),
+ PRIMARY KEY(id)
+ ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
+ SQL);
+
+ $this->addSql('ALTER TABLE escape_room ADD CONSTRAINT fk_escape_room_submitted_by FOREIGN KEY (submitted_by_id) REFERENCES `user` (id) ON DELETE SET NULL');
+ $this->addSql('ALTER TABLE escape_room_review ADD CONSTRAINT fk_escape_room_review_room FOREIGN KEY (escape_room_id) REFERENCES escape_room (id) ON DELETE CASCADE');
+ $this->addSql('ALTER TABLE escape_room_review ADD CONSTRAINT fk_escape_room_review_author FOREIGN KEY (author_id) REFERENCES `user` (id) ON DELETE SET NULL');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE escape_room_review DROP FOREIGN KEY fk_escape_room_review_room');
+ $this->addSql('ALTER TABLE escape_room_review DROP FOREIGN KEY fk_escape_room_review_author');
+ $this->addSql('ALTER TABLE escape_room DROP FOREIGN KEY fk_escape_room_submitted_by');
+ $this->addSql('DROP TABLE escape_room_review');
+ $this->addSql('DROP TABLE escape_room');
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index ded21df..56f12f4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,8 @@
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
+ "leaflet": "^1.9.4",
+ "leaflet.markercluster": "^1.5.3",
"simple-datatables": "^10.3.0"
},
"devDependencies": {
@@ -3863,6 +3865,21 @@
"node": ">=0.10.0"
}
},
+ "node_modules/leaflet": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
+ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/leaflet.markercluster": {
+ "version": "1.5.3",
+ "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",
+ "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "leaflet": "^1.3.1"
+ }
+ },
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
diff --git a/package.json b/package.json
index 123ba80..4797278 100644
--- a/package.json
+++ b/package.json
@@ -28,6 +28,8 @@
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
+ "leaflet": "^1.9.4",
+ "leaflet.markercluster": "^1.5.3",
"simple-datatables": "^10.3.0"
}
}
diff --git a/src/Website/Command/ImportEscapeRoomsFromOsmCommand.php b/src/Website/Command/ImportEscapeRoomsFromOsmCommand.php
new file mode 100644
index 0000000..aa3e568
--- /dev/null
+++ b/src/Website/Command/ImportEscapeRoomsFromOsmCommand.php
@@ -0,0 +1,338 @@
+ name => [south, west, north, east]
+ */
+ private const WORLD_TILES = [
+ 'Europe' => [34.0, -25.0, 72.0, 45.0],
+ 'Africa' => [-36.0, -20.0, 38.0, 52.0],
+ 'Middle East' => [12.0, 26.0, 43.0, 63.0],
+ 'North Asia' => [40.0, 45.0, 78.0, 180.0],
+ 'South Asia' => [5.0, 63.0, 40.0, 93.0],
+ 'East Asia' => [18.0, 93.0, 54.0, 146.0],
+ 'SE Asia/Oceania' => [-48.0, 93.0, 18.0, 180.0],
+ 'North America' => [10.0, -168.0, 72.0, -52.0],
+ 'Central America' => [7.0, -92.0, 33.0, -58.0],
+ 'South America' => [-56.0, -82.0, 13.0, -34.0],
+ ];
+
+ public function __construct(
+ private readonly HttpClientInterface $httpClient,
+ private readonly EntityManagerInterface $em,
+ private readonly EscapeRoomRepository $rooms,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void
+ {
+ $this
+ ->addOption('area', null, InputOption::VALUE_REQUIRED, 'Restrict to one country by ISO 3166-1 alpha-2 code, e.g. --area=NL')
+ ->addOption('bbox', null, InputOption::VALUE_REQUIRED, 'Restrict to a bounding box: --bbox=south,west,north,east')
+ ->addOption('tiled', null, InputOption::VALUE_NONE, 'Split a worldwide import into continental tiles (recommended for the first run)')
+ ->addOption('endpoint', null, InputOption::VALUE_REQUIRED, 'Overpass API endpoint', self::DEFAULT_ENDPOINT)
+ ->addOption('timeout', null, InputOption::VALUE_REQUIRED, 'Overpass server-side timeout in seconds', '600')
+ ->addOption('sleep', null, InputOption::VALUE_REQUIRED, 'Seconds to wait between requests', '5')
+ ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Fetch and parse but write nothing to the database');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+ $endpoint = (string) $input->getOption('endpoint');
+ $timeout = max(30, (int) $input->getOption('timeout'));
+ $sleep = max(0, (int) $input->getOption('sleep'));
+ $dryRun = (bool) $input->getOption('dry-run');
+
+ try {
+ $queries = $this->buildQueries($input, $timeout);
+ } catch (\InvalidArgumentException $e) {
+ $io->error($e->getMessage());
+
+ return Command::INVALID;
+ }
+
+ $io->title('Importing escape rooms from OpenStreetMap');
+ $io->writeln(sprintf('Endpoint: %s · batches: %d %s', $endpoint, \count($queries), $dryRun ? ' · dry run ' : ''));
+
+ $totals = ['created' => 0, 'updated' => 0, 'locked' => 0, 'noname' => 0, 'nocoords' => 0];
+ $seen = 0;
+ $first = true;
+
+ foreach ($queries as $label => $ql) {
+ if (!$first && $sleep > 0) {
+ $io->writeln(sprintf(' sleeping %ds… ', $sleep));
+ sleep($sleep);
+ }
+ $first = false;
+
+ $io->section((string) $label);
+
+ $elements = $this->fetch($endpoint, $ql, $timeout, $io);
+ if ($elements === null) {
+ $io->warning('Skipping this batch after a failed request.');
+ continue;
+ }
+
+ $io->writeln(sprintf(' %d element(s) returned', \count($elements)));
+ $batch = 0;
+
+ foreach ($elements as $element) {
+ ++$seen;
+ $outcome = $this->upsert($element, $dryRun);
+ ++$totals[$outcome];
+
+ if (!$dryRun && $outcome !== 'noname' && $outcome !== 'nocoords' && ++$batch % 400 === 0) {
+ $this->em->flush();
+ $this->em->clear();
+ }
+ }
+
+ if (!$dryRun) {
+ $this->em->flush();
+ $this->em->clear();
+ }
+ }
+
+ $io->newLine();
+ $io->success(sprintf(
+ "%d element(s) processed\n created: %d\n updated: %d\n skipped (locked): %d\n skipped (no name): %d\n skipped (no coords): %d",
+ $seen,
+ $totals['created'],
+ $totals['updated'],
+ $totals['locked'],
+ $totals['noname'],
+ $totals['nocoords'],
+ ));
+
+ return Command::SUCCESS;
+ }
+
+ /**
+ * @return array label => Overpass QL
+ */
+ private function buildQueries(InputInterface $input, int $timeout): array
+ {
+ $area = $input->getOption('area');
+ $bbox = $input->getOption('bbox');
+ $tiled = (bool) $input->getOption('tiled');
+
+ $header = sprintf('[out:json][timeout:%d];', $timeout);
+ $footer = 'out center tags;';
+
+ if ($area !== null) {
+ $code = strtoupper(trim((string) $area));
+ if (!preg_match('/^[A-Z]{2}$/', $code)) {
+ throw new \InvalidArgumentException('--area expects a two-letter ISO country code, e.g. NL');
+ }
+
+ return [
+ 'Country '.$code => sprintf('%s area["ISO3166-1"="%s"][admin_level=2]->.a; nwr["leisure"="escape_game"](area.a); %s', $header, $code, $footer),
+ ];
+ }
+
+ if ($bbox !== null) {
+ $box = $this->parseBbox((string) $bbox);
+
+ return [
+ 'Bounding box' => sprintf('%s nwr["leisure"="escape_game"](%F,%F,%F,%F); %s', $header, $box[0], $box[1], $box[2], $box[3], $footer),
+ ];
+ }
+
+ if ($tiled) {
+ $queries = [];
+ foreach (self::WORLD_TILES as $name => $box) {
+ $queries[$name] = sprintf('%s nwr["leisure"="escape_game"](%F,%F,%F,%F); %s', $header, $box[0], $box[1], $box[2], $box[3], $footer);
+ }
+
+ return $queries;
+ }
+
+ return [
+ 'Worldwide' => sprintf('%s nwr["leisure"="escape_game"]; %s', $header, $footer),
+ ];
+ }
+
+ /**
+ * @return array{float, float, float, float} south, west, north, east
+ */
+ private function parseBbox(string $raw): array
+ {
+ $parts = array_map('trim', explode(',', $raw));
+ if (\count($parts) !== 4 || array_filter($parts, static fn ($p) => !is_numeric($p))) {
+ throw new \InvalidArgumentException('--bbox expects four comma-separated numbers: south,west,north,east');
+ }
+
+ return [(float) $parts[0], (float) $parts[1], (float) $parts[2], (float) $parts[3]];
+ }
+
+ /**
+ * @return array>|null
+ */
+ private function fetch(string $endpoint, string $ql, int $timeout, SymfonyStyle $io): ?array
+ {
+ for ($attempt = 1; $attempt <= 2; ++$attempt) {
+ try {
+ $response = $this->httpClient->request('POST', $endpoint, [
+ 'body' => ['data' => $ql],
+ 'headers' => ['Accept' => 'application/json'],
+ 'timeout' => $timeout + 60,
+ ]);
+
+ $data = $response->toArray();
+
+ if (isset($data['remark']) && str_contains((string) $data['remark'], 'error')) {
+ $io->warning('Overpass reported: '.$data['remark']);
+
+ return null;
+ }
+
+ return $data['elements'] ?? [];
+ } catch (\Throwable $e) {
+ $io->writeln(sprintf(' request failed (attempt %d/2): %s ', $attempt, $e->getMessage()));
+ if ($attempt < 2) {
+ sleep(15);
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * @param array $element
+ *
+ * @return 'created'|'updated'|'locked'|'noname'|'nocoords'
+ */
+ private function upsert(array $element, bool $dryRun): string
+ {
+ $tags = $element['tags'] ?? [];
+ $name = mb_substr(trim((string) ($tags['name'] ?? '')), 0, 255);
+ if ($name === '') {
+ return 'noname';
+ }
+
+ [$lat, $lng] = $this->coords($element);
+ if ($lat === null || $lng === null) {
+ return 'nocoords';
+ }
+
+ $osmType = (string) ($element['type'] ?? 'node');
+ $osmId = (string) ($element['id'] ?? '');
+
+ $room = $this->rooms->findOneByOsm($osmType, $osmId);
+ $isNew = $room === null;
+
+ if (!$isNew && $room->isLocked()) {
+ return 'locked';
+ }
+
+ if ($isNew) {
+ $room = (new EscapeRoom())
+ ->setSource(EscapeRoomSource::OSM)
+ ->setStatus(EscapeRoomStatus::PUBLISHED)
+ ->setOsmType($osmType)
+ ->setOsmId($osmId);
+ }
+
+ // Clamp each value to its column width - OSM address tags are free text
+ // and occasionally blow past these (e.g. addr:housenumber "12-14, 16, 18").
+ $room
+ ->setName($name)
+ ->setVenue($this->firstTag($tags, ['brand', 'operator'], 255))
+ ->setStreet($this->firstTag($tags, ['addr:street'], 255))
+ ->setHouseNumber($this->firstTag($tags, ['addr:housenumber'], 32))
+ ->setPostcode($this->firstTag($tags, ['addr:postcode'], 32))
+ ->setCity($this->firstTag($tags, ['addr:city', 'addr:town', 'addr:suburb'], 128))
+ ->setCountry($this->normaliseCountry($this->firstTag($tags, ['addr:country'], 8)))
+ ->setWebsite($this->firstTag($tags, ['website', 'contact:website', 'url'], 511))
+ ->setPhone($this->firstTag($tags, ['phone', 'contact:phone'], 64))
+ ->setLatitude($lat)
+ ->setLongitude($lng);
+
+ if (!$isNew) {
+ $room->touch();
+ }
+
+ if (!$dryRun) {
+ $this->em->persist($room);
+ }
+
+ return $isNew ? 'created' : 'updated';
+ }
+
+ /**
+ * @param array $element
+ *
+ * @return array{?float, ?float}
+ */
+ private function coords(array $element): array
+ {
+ if (isset($element['lat'], $element['lon'])) {
+ return [(float) $element['lat'], (float) $element['lon']];
+ }
+
+ if (isset($element['center']['lat'], $element['center']['lon'])) {
+ return [(float) $element['center']['lat'], (float) $element['center']['lon']];
+ }
+
+ return [null, null];
+ }
+
+ /**
+ * First non-empty value among $keys, trimmed and clamped to $maxLen chars.
+ *
+ * @param array $tags
+ * @param string[] $keys
+ */
+ private function firstTag(array $tags, array $keys, int $maxLen = 255): ?string
+ {
+ foreach ($keys as $key) {
+ $value = trim((string) ($tags[$key] ?? ''));
+ if ($value !== '') {
+ return mb_substr($value, 0, $maxLen);
+ }
+ }
+
+ return null;
+ }
+
+ private function normaliseCountry(?string $value): ?string
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ $value = strtoupper(trim($value));
+
+ return preg_match('/^[A-Z]{2}$/', $value) === 1 ? $value : null;
+ }
+}
diff --git a/src/Website/Controller/AdminEscapeRoomController.php b/src/Website/Controller/AdminEscapeRoomController.php
new file mode 100644
index 0000000..52b70df
--- /dev/null
+++ b/src/Website/Controller/AdminEscapeRoomController.php
@@ -0,0 +1,152 @@
+query->get('status'));
+
+ return $this->render('website/admin/escape_room/index.html.twig', [
+ 'rooms' => $rooms->findForAdmin($filter),
+ 'reviewCounts' => $rooms->reviewCounts(),
+ 'filter' => $filter,
+ 'counts' => [
+ 'all' => \count($rooms->findForAdmin()),
+ EscapeRoomStatus::PENDING->value => $rooms->countByStatus(EscapeRoomStatus::PENDING),
+ EscapeRoomStatus::PUBLISHED->value => $rooms->countByStatus(EscapeRoomStatus::PUBLISHED),
+ EscapeRoomStatus::HIDDEN->value => $rooms->countByStatus(EscapeRoomStatus::HIDDEN),
+ EscapeRoomStatus::REJECTED->value => $rooms->countByStatus(EscapeRoomStatus::REJECTED),
+ ],
+ ]);
+ }
+
+ #[Route('/new', name: 'website_admin_escaperoom_new', methods: ['GET', 'POST'])]
+ public function new(Request $request, EntityManagerInterface $em): Response
+ {
+ $room = new EscapeRoom();
+ $form = $this->createForm(AdminEscapeRoomType::class, $room);
+ $form->handleRequest($request);
+
+ if ($form->isSubmitted() && $form->isValid()) {
+ $em->persist($room);
+ $em->flush();
+ $this->addFlash('success', sprintf('"%s" added.', $room->getName()));
+
+ return $this->redirectToRoute('website_admin_escaperooms');
+ }
+
+ return $this->render('website/admin/escape_room/form.html.twig', [
+ 'form' => $form,
+ 'room' => $room,
+ 'heading' => 'New escape room',
+ ]);
+ }
+
+ #[Route('/{id}/edit', name: 'website_admin_escaperoom_edit', methods: ['GET', 'POST'], requirements: ['id' => '\d+'])]
+ public function edit(EscapeRoom $room, Request $request, EntityManagerInterface $em): Response
+ {
+ $form = $this->createForm(AdminEscapeRoomType::class, $room);
+ $form->handleRequest($request);
+
+ if ($form->isSubmitted() && $form->isValid()) {
+ $room->touch();
+ $em->flush();
+ $this->addFlash('success', 'Changes saved.');
+
+ return $this->redirectToRoute('website_admin_escaperooms');
+ }
+
+ return $this->render('website/admin/escape_room/form.html.twig', [
+ 'form' => $form,
+ 'room' => $room,
+ 'heading' => 'Edit: '.$room->getName(),
+ ]);
+ }
+
+ #[Route('/{id}/status/{status}', name: 'website_admin_escaperoom_moderate', methods: ['POST'], requirements: ['id' => '\d+'])]
+ public function moderate(EscapeRoom $room, string $status, Request $request, EntityManagerInterface $em): Response
+ {
+ if (!$this->isCsrfTokenValid('moderate_room_'.$room->getId(), (string) $request->request->get('_token'))) {
+ $this->addFlash('danger', 'Invalid CSRF token.');
+
+ return $this->redirectToRoute('website_admin_escaperooms');
+ }
+
+ $target = EscapeRoomStatus::tryFrom($status);
+ if ($target === null) {
+ throw $this->createNotFoundException();
+ }
+
+ $room->setStatus($target)->touch();
+ $em->flush();
+ $this->addFlash('success', sprintf('"%s" is now %s.', $room->getName(), $target->label()));
+
+ return $this->redirectToRoute('website_admin_escaperooms', ['status' => $request->request->get('return_to')]);
+ }
+
+ #[Route('/{id}/delete', name: 'website_admin_escaperoom_delete', methods: ['POST'], requirements: ['id' => '\d+'])]
+ public function delete(EscapeRoom $room, Request $request, EntityManagerInterface $em): Response
+ {
+ if (!$this->isCsrfTokenValid('delete_room_'.$room->getId(), (string) $request->request->get('_token'))) {
+ $this->addFlash('danger', 'Invalid CSRF token.');
+
+ return $this->redirectToRoute('website_admin_escaperooms');
+ }
+
+ if (!$room->isDeleted()) {
+ $room->setDeletedAt(new \DateTimeImmutable());
+ $em->flush();
+ }
+
+ $this->addFlash('success', sprintf('"%s" deleted.', $room->getName()));
+
+ return $this->redirectToRoute('website_admin_escaperooms');
+ }
+
+ #[Route('/reviews', name: 'website_admin_escaperoom_reviews', methods: ['GET'])]
+ public function reviews(EscapeRoomReviewRepository $reviews): Response
+ {
+ return $this->render('website/admin/review/index.html.twig', [
+ 'reviews' => $reviews->findRecent(),
+ ]);
+ }
+
+ #[Route('/reviews/{id}/delete', name: 'website_admin_escaperoom_review_delete', methods: ['POST'], requirements: ['id' => '\d+'])]
+ public function deleteReview(EscapeRoomReview $review, Request $request, EntityManagerInterface $em): Response
+ {
+ if (!$this->isCsrfTokenValid('delete_review_'.$review->getId(), (string) $request->request->get('_token'))) {
+ $this->addFlash('danger', 'Invalid CSRF token.');
+
+ return $this->redirectToRoute('website_admin_escaperoom_reviews');
+ }
+
+ if (!$review->isDeleted()) {
+ $review->setDeletedAt(new \DateTimeImmutable());
+ $em->flush();
+ }
+
+ $this->addFlash('success', 'Review removed.');
+
+ return $this->redirectToRoute('website_admin_escaperoom_reviews');
+ }
+}
diff --git a/src/Website/Controller/EscapeRoomController.php b/src/Website/Controller/EscapeRoomController.php
new file mode 100644
index 0000000..e4ab3e0
--- /dev/null
+++ b/src/Website/Controller/EscapeRoomController.php
@@ -0,0 +1,139 @@
+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,
+ ]);
+ }
+}
diff --git a/src/Website/Entity/EscapeRoom.php b/src/Website/Entity/EscapeRoom.php
new file mode 100644
index 0000000..90d7715
--- /dev/null
+++ b/src/Website/Entity/EscapeRoom.php
@@ -0,0 +1,378 @@
+ false])]
+ private bool $locked = false;
+
+ #[ORM\ManyToOne(targetEntity: User::class)]
+ #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
+ private ?User $submittedBy = null;
+
+ #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
+ private ?\DateTimeImmutable $createdAt = null;
+
+ #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
+ private ?\DateTimeImmutable $updatedAt = null;
+
+ #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
+ private ?\DateTimeImmutable $deletedAt = null;
+
+ /** @var Collection */
+ #[ORM\OneToMany(mappedBy: 'escapeRoom', targetEntity: EscapeRoomReview::class, cascade: ['remove'])]
+ private Collection $reviews;
+
+ public function __construct()
+ {
+ $this->createdAt = new \DateTimeImmutable();
+ $this->reviews = new ArrayCollection();
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getName(): ?string
+ {
+ return $this->name;
+ }
+
+ public function setName(string $name): static
+ {
+ $this->name = $name;
+
+ return $this;
+ }
+
+ public function getVenue(): ?string
+ {
+ return $this->venue;
+ }
+
+ public function setVenue(?string $venue): static
+ {
+ $this->venue = $venue;
+
+ return $this;
+ }
+
+ public function getStreet(): ?string
+ {
+ return $this->street;
+ }
+
+ public function setStreet(?string $street): static
+ {
+ $this->street = $street;
+
+ return $this;
+ }
+
+ public function getHouseNumber(): ?string
+ {
+ return $this->houseNumber;
+ }
+
+ public function setHouseNumber(?string $houseNumber): static
+ {
+ $this->houseNumber = $houseNumber;
+
+ return $this;
+ }
+
+ public function getPostcode(): ?string
+ {
+ return $this->postcode;
+ }
+
+ public function setPostcode(?string $postcode): static
+ {
+ $this->postcode = $postcode;
+
+ return $this;
+ }
+
+ public function getCity(): ?string
+ {
+ return $this->city;
+ }
+
+ public function setCity(?string $city): static
+ {
+ $this->city = $city;
+
+ return $this;
+ }
+
+ public function getCountry(): ?string
+ {
+ return $this->country;
+ }
+
+ public function setCountry(?string $country): static
+ {
+ $this->country = $country ? strtoupper($country) : null;
+
+ return $this;
+ }
+
+ public function getLatitude(): ?string
+ {
+ return $this->latitude;
+ }
+
+ public function setLatitude(string|float|null $latitude): static
+ {
+ $this->latitude = $latitude === null ? null : (string) $latitude;
+
+ return $this;
+ }
+
+ public function getLongitude(): ?string
+ {
+ return $this->longitude;
+ }
+
+ public function setLongitude(string|float|null $longitude): static
+ {
+ $this->longitude = $longitude === null ? null : (string) $longitude;
+
+ return $this;
+ }
+
+ public function getWebsite(): ?string
+ {
+ return $this->website;
+ }
+
+ public function setWebsite(?string $website): static
+ {
+ $this->website = $website;
+
+ return $this;
+ }
+
+ public function getPhone(): ?string
+ {
+ return $this->phone;
+ }
+
+ public function setPhone(?string $phone): static
+ {
+ $this->phone = $phone;
+
+ return $this;
+ }
+
+ public function getSource(): EscapeRoomSource
+ {
+ return $this->source;
+ }
+
+ public function setSource(EscapeRoomSource $source): static
+ {
+ $this->source = $source;
+
+ return $this;
+ }
+
+ public function getOsmType(): ?string
+ {
+ return $this->osmType;
+ }
+
+ public function setOsmType(?string $osmType): static
+ {
+ $this->osmType = $osmType;
+
+ return $this;
+ }
+
+ public function getOsmId(): ?string
+ {
+ return $this->osmId;
+ }
+
+ public function setOsmId(int|string|null $osmId): static
+ {
+ $this->osmId = $osmId === null ? null : (string) $osmId;
+
+ return $this;
+ }
+
+ public function getStatus(): EscapeRoomStatus
+ {
+ return $this->status;
+ }
+
+ public function setStatus(EscapeRoomStatus $status): static
+ {
+ $this->status = $status;
+
+ return $this;
+ }
+
+ public function isLocked(): bool
+ {
+ return $this->locked;
+ }
+
+ public function setLocked(bool $locked): static
+ {
+ $this->locked = $locked;
+
+ return $this;
+ }
+
+ public function getSubmittedBy(): ?User
+ {
+ return $this->submittedBy;
+ }
+
+ public function setSubmittedBy(?User $submittedBy): static
+ {
+ $this->submittedBy = $submittedBy;
+
+ return $this;
+ }
+
+ public function getCreatedAt(): ?\DateTimeImmutable
+ {
+ return $this->createdAt;
+ }
+
+ public function getUpdatedAt(): ?\DateTimeImmutable
+ {
+ return $this->updatedAt;
+ }
+
+ public function touch(): static
+ {
+ $this->updatedAt = new \DateTimeImmutable();
+
+ return $this;
+ }
+
+ public function getDeletedAt(): ?\DateTimeImmutable
+ {
+ return $this->deletedAt;
+ }
+
+ public function setDeletedAt(?\DateTimeImmutable $deletedAt): static
+ {
+ $this->deletedAt = $deletedAt;
+
+ return $this;
+ }
+
+ public function isDeleted(): bool
+ {
+ return $this->deletedAt !== null;
+ }
+
+ /** @return Collection */
+ public function getReviews(): Collection
+ {
+ return $this->reviews;
+ }
+
+ /** @return EscapeRoomReview[] */
+ public function getVisibleReviews(): array
+ {
+ return $this->reviews
+ ->filter(static fn (EscapeRoomReview $r) => !$r->isDeleted())
+ ->toArray();
+ }
+
+ public function getSlug(): string
+ {
+ $slug = strtolower((string) $this->name);
+ $slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? '';
+
+ return trim($slug, '-') ?: 'room';
+ }
+
+ public function getDisplayAddress(): string
+ {
+ $line = trim(sprintf('%s %s', (string) $this->street, (string) $this->houseNumber));
+ $parts = array_filter([
+ $line !== '' ? $line : null,
+ trim(sprintf('%s %s', (string) $this->postcode, (string) $this->city)) ?: null,
+ $this->country,
+ ]);
+
+ return implode(', ', $parts);
+ }
+}
diff --git a/src/Website/Entity/EscapeRoomReview.php b/src/Website/Entity/EscapeRoomReview.php
new file mode 100644
index 0000000..cc6783b
--- /dev/null
+++ b/src/Website/Entity/EscapeRoomReview.php
@@ -0,0 +1,141 @@
+createdAt = new \DateTimeImmutable();
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getEscapeRoom(): ?EscapeRoom
+ {
+ return $this->escapeRoom;
+ }
+
+ public function setEscapeRoom(?EscapeRoom $escapeRoom): static
+ {
+ $this->escapeRoom = $escapeRoom;
+
+ return $this;
+ }
+
+ public function getAuthor(): ?User
+ {
+ return $this->author;
+ }
+
+ public function setAuthor(?User $author): static
+ {
+ $this->author = $author;
+
+ return $this;
+ }
+
+ public function getRating(): int
+ {
+ return $this->rating;
+ }
+
+ public function setRating(int $rating): static
+ {
+ $this->rating = $rating;
+
+ return $this;
+ }
+
+ public function getTitle(): ?string
+ {
+ return $this->title;
+ }
+
+ public function setTitle(?string $title): static
+ {
+ $this->title = $title;
+
+ return $this;
+ }
+
+ public function getBody(): ?string
+ {
+ return $this->body;
+ }
+
+ public function setBody(?string $body): static
+ {
+ $this->body = $body;
+
+ return $this;
+ }
+
+ public function getCreatedAt(): ?\DateTimeImmutable
+ {
+ return $this->createdAt;
+ }
+
+ public function getDeletedAt(): ?\DateTimeImmutable
+ {
+ return $this->deletedAt;
+ }
+
+ public function setDeletedAt(?\DateTimeImmutable $deletedAt): static
+ {
+ $this->deletedAt = $deletedAt;
+
+ return $this;
+ }
+
+ public function isDeleted(): bool
+ {
+ return $this->deletedAt !== null;
+ }
+}
diff --git a/src/Website/Enum/EscapeRoomSource.php b/src/Website/Enum/EscapeRoomSource.php
new file mode 100644
index 0000000..60fd943
--- /dev/null
+++ b/src/Website/Enum/EscapeRoomSource.php
@@ -0,0 +1,19 @@
+ 'OpenStreetMap',
+ self::USER => 'Visitor submission',
+ self::ADMIN => 'Added by admin',
+ };
+ }
+}
diff --git a/src/Website/Enum/EscapeRoomStatus.php b/src/Website/Enum/EscapeRoomStatus.php
new file mode 100644
index 0000000..cce0f42
--- /dev/null
+++ b/src/Website/Enum/EscapeRoomStatus.php
@@ -0,0 +1,30 @@
+ 'Pending review',
+ self::PUBLISHED => 'Published',
+ self::REJECTED => 'Rejected',
+ self::HIDDEN => 'Hidden',
+ };
+ }
+
+ public function isPubliclyVisible(): bool
+ {
+ return $this === self::PUBLISHED;
+ }
+}
diff --git a/src/Website/Form/AdminEscapeRoomType.php b/src/Website/Form/AdminEscapeRoomType.php
new file mode 100644
index 0000000..e87f46c
--- /dev/null
+++ b/src/Website/Form/AdminEscapeRoomType.php
@@ -0,0 +1,67 @@
+add('name', TextType::class, ['constraints' => [new NotBlank()]])
+ ->add('venue', TextType::class, ['required' => false])
+ ->add('street', TextType::class, ['required' => false])
+ ->add('houseNumber', TextType::class, ['label' => 'House number', 'required' => false])
+ ->add('postcode', TextType::class, ['required' => false])
+ ->add('city', TextType::class, ['required' => false])
+ ->add('country', CountryType::class, ['placeholder' => '—', 'required' => false])
+ ->add('latitude', NumberType::class, [
+ 'scale' => 7,
+ 'html5' => true,
+ 'constraints' => [new NotBlank(), new Range(min: -90, max: 90)],
+ ])
+ ->add('longitude', NumberType::class, [
+ 'scale' => 7,
+ 'html5' => true,
+ 'constraints' => [new NotBlank(), new Range(min: -180, max: 180)],
+ ])
+ ->add('website', UrlType::class, ['required' => false, 'default_protocol' => 'https'])
+ ->add('phone', TelType::class, ['required' => false])
+ ->add('status', EnumType::class, [
+ 'class' => EscapeRoomStatus::class,
+ 'choice_label' => fn (EscapeRoomStatus $s) => $s->label(),
+ ])
+ ->add('source', EnumType::class, [
+ 'class' => EscapeRoomSource::class,
+ 'choice_label' => fn (EscapeRoomSource $s) => $s->label(),
+ ])
+ ->add('locked', CheckboxType::class, [
+ 'required' => false,
+ 'label' => 'Locked (protect from OSM re-import)',
+ ]);
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => EscapeRoom::class,
+ ]);
+ }
+}
diff --git a/src/Website/Form/EscapeRoomReviewType.php b/src/Website/Form/EscapeRoomReviewType.php
new file mode 100644
index 0000000..e3e9898
--- /dev/null
+++ b/src/Website/Form/EscapeRoomReviewType.php
@@ -0,0 +1,46 @@
+add('rating', ChoiceType::class, [
+ 'placeholder' => 'Choose a rating',
+ 'choices' => [
+ '★★★★★ — Outstanding' => 5,
+ '★★★★ — Very good' => 4,
+ '★★★ — Decent' => 3,
+ '★★ — Poor' => 2,
+ '★ — Terrible' => 1,
+ ],
+ 'constraints' => [new NotBlank(message: 'Pick a rating')],
+ ])
+ ->add('title', TextType::class, [
+ 'attr' => ['maxlength' => 150, 'placeholder' => 'Sum it up in a few words'],
+ ])
+ ->add('body', TextareaType::class, [
+ 'attr' => ['rows' => 5, 'maxlength' => 5000, 'placeholder' => 'What worked, what didn\'t, would you recommend it?'],
+ ]);
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => EscapeRoomReview::class,
+ ]);
+ }
+}
diff --git a/src/Website/Form/SuggestEscapeRoomType.php b/src/Website/Form/SuggestEscapeRoomType.php
new file mode 100644
index 0000000..cd3a79a
--- /dev/null
+++ b/src/Website/Form/SuggestEscapeRoomType.php
@@ -0,0 +1,78 @@
+add('name', TextType::class, [
+ 'label' => 'Room / venue name',
+ 'constraints' => [new NotBlank(message: 'Enter the room name'), new Length(max: 255)],
+ ])
+ ->add('venue', TextType::class, [
+ 'label' => 'Operating company (optional)',
+ 'required' => false,
+ 'constraints' => [new Length(max: 255)],
+ ])
+ ->add('street', TextType::class, ['required' => false, 'constraints' => [new Length(max: 255)]])
+ ->add('houseNumber', TextType::class, ['label' => 'No.', 'required' => false, 'constraints' => [new Length(max: 32)]])
+ ->add('postcode', TextType::class, ['required' => false, 'constraints' => [new Length(max: 32)]])
+ ->add('city', TextType::class, ['required' => false, 'constraints' => [new Length(max: 128)]])
+ ->add('country', CountryType::class, [
+ 'placeholder' => 'Select a country',
+ 'required' => false,
+ ])
+ ->add('website', UrlType::class, [
+ 'required' => false,
+ 'default_protocol' => 'https',
+ 'constraints' => [new Length(max: 511)],
+ ])
+ ->add('phone', TelType::class, ['required' => false, 'constraints' => [new Length(max: 64)]])
+ ->add('latitude', HiddenType::class, [
+ 'constraints' => [
+ new NotBlank(message: 'Drop a pin on the map to set the location'),
+ new Regex(pattern: '/^-?\d{1,3}(\.\d+)?$/', message: 'Invalid latitude'),
+ new Range(min: -90, max: 90),
+ ],
+ ])
+ ->add('longitude', HiddenType::class, [
+ 'constraints' => [
+ new NotBlank(message: 'Drop a pin on the map to set the location'),
+ new Regex(pattern: '/^-?\d{1,3}(\.\d+)?$/', message: 'Invalid longitude'),
+ new Range(min: -180, max: 180),
+ ],
+ ])
+ ->add('captcha', Recaptcha3Type::class, [
+ 'mapped' => false,
+ 'constraints' => new Recaptcha3(),
+ 'action_name' => 'escaperoom_suggest',
+ ]);
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => EscapeRoom::class,
+ ]);
+ }
+}
diff --git a/src/Website/Repository/EscapeRoomRepository.php b/src/Website/Repository/EscapeRoomRepository.php
new file mode 100644
index 0000000..01c237d
--- /dev/null
+++ b/src/Website/Repository/EscapeRoomRepository.php
@@ -0,0 +1,134 @@
+
+ */
+class EscapeRoomRepository extends ServiceEntityRepository
+{
+ public function __construct(ManagerRegistry $registry)
+ {
+ parent::__construct($registry, EscapeRoom::class);
+ }
+
+ public function save(EscapeRoom $room, bool $flush = true): void
+ {
+ $this->getEntityManager()->persist($room);
+
+ if ($flush) {
+ $this->getEntityManager()->flush();
+ }
+ }
+
+ public function findOneByOsm(string $osmType, string $osmId): ?EscapeRoom
+ {
+ return $this->findOneBy(['osmType' => $osmType, 'osmId' => $osmId]);
+ }
+
+ /**
+ * Lightweight rows for the public map / list. One row per published room with
+ * its aggregated rating. Optionally clipped to a lat/lng bounding box.
+ *
+ * @param array{south: float, west: float, north: float, east: float}|null $bbox
+ *
+ * @return list
+ */
+ public function findMapData(?array $bbox = null, int $limit = 20000): array
+ {
+ $qb = $this->createQueryBuilder('r')
+ ->select('r.id, r.name, r.city, r.country, r.latitude, r.longitude')
+ ->addSelect('AVG(rev.rating) AS avgRating')
+ ->addSelect('COUNT(rev.id) AS reviewCount')
+ ->leftJoin('r.reviews', 'rev', 'WITH', 'rev.deletedAt IS NULL')
+ ->where('r.deletedAt IS NULL')
+ ->andWhere('r.status = :published')
+ ->setParameter('published', EscapeRoomStatus::PUBLISHED)
+ ->groupBy('r.id')
+ ->setMaxResults($limit);
+
+ if ($bbox !== null) {
+ $qb->andWhere('r.latitude BETWEEN :south AND :north')
+ ->andWhere('r.longitude BETWEEN :west AND :east')
+ ->setParameter('south', $bbox['south'])
+ ->setParameter('north', $bbox['north'])
+ ->setParameter('west', $bbox['west'])
+ ->setParameter('east', $bbox['east']);
+ }
+
+ $rows = $qb->getQuery()->getArrayResult();
+
+ return array_map(static function (array $row): array {
+ return [
+ 'id' => (int) $row['id'],
+ 'name' => (string) $row['name'],
+ 'city' => $row['city'],
+ 'country' => $row['country'],
+ 'lat' => (float) $row['latitude'],
+ 'lng' => (float) $row['longitude'],
+ 'avgRating' => $row['reviewCount'] > 0 ? round((float) $row['avgRating'], 1) : null,
+ 'reviewCount' => (int) $row['reviewCount'],
+ ];
+ }, $rows);
+ }
+
+ public function countByStatus(EscapeRoomStatus $status): int
+ {
+ return (int) $this->createQueryBuilder('r')
+ ->select('COUNT(r.id)')
+ ->where('r.deletedAt IS NULL')
+ ->andWhere('r.status = :status')
+ ->setParameter('status', $status)
+ ->getQuery()
+ ->getSingleScalarResult();
+ }
+
+ /**
+ * All non-deleted rooms, newest first. For the admin list.
+ *
+ * @return EscapeRoom[]
+ */
+ public function findForAdmin(?EscapeRoomStatus $status = null): array
+ {
+ $qb = $this->createQueryBuilder('r')
+ ->where('r.deletedAt IS NULL')
+ ->orderBy('r.createdAt', 'DESC');
+
+ if ($status !== null) {
+ $qb->andWhere('r.status = :status')->setParameter('status', $status);
+ }
+
+ return $qb->getQuery()->getResult();
+ }
+
+ /**
+ * Visible review count per room, in one query, for the admin list.
+ *
+ * @return array roomId => count
+ */
+ public function reviewCounts(): array
+ {
+ $rows = $this->getEntityManager()
+ ->createQuery(
+ 'SELECT IDENTITY(rev.escapeRoom) AS rid, COUNT(rev.id) AS c '
+ .'FROM App\Website\Entity\EscapeRoomReview rev '
+ .'WHERE rev.deletedAt IS NULL GROUP BY rid'
+ )
+ ->getScalarResult();
+
+ $map = [];
+ foreach ($rows as $row) {
+ $map[(int) $row['rid']] = (int) $row['c'];
+ }
+
+ return $map;
+ }
+}
diff --git a/src/Website/Repository/EscapeRoomReviewRepository.php b/src/Website/Repository/EscapeRoomReviewRepository.php
new file mode 100644
index 0000000..ade2cdd
--- /dev/null
+++ b/src/Website/Repository/EscapeRoomReviewRepository.php
@@ -0,0 +1,80 @@
+
+ */
+class EscapeRoomReviewRepository extends ServiceEntityRepository
+{
+ public function __construct(ManagerRegistry $registry)
+ {
+ parent::__construct($registry, EscapeRoomReview::class);
+ }
+
+ public function save(EscapeRoomReview $review, bool $flush = true): void
+ {
+ $this->getEntityManager()->persist($review);
+
+ if ($flush) {
+ $this->getEntityManager()->flush();
+ }
+ }
+
+ /**
+ * @return EscapeRoomReview[]
+ */
+ public function findVisibleForRoom(EscapeRoom $room): array
+ {
+ return $this->createQueryBuilder('rev')
+ ->where('rev.escapeRoom = :room')
+ ->andWhere('rev.deletedAt IS NULL')
+ ->setParameter('room', $room)
+ ->orderBy('rev.createdAt', 'DESC')
+ ->getQuery()
+ ->getResult();
+ }
+
+ /**
+ * @return array{avg: ?float, count: int}
+ */
+ public function ratingSummary(EscapeRoom $room): array
+ {
+ $row = $this->createQueryBuilder('rev')
+ ->select('AVG(rev.rating) AS avg, COUNT(rev.id) AS count')
+ ->where('rev.escapeRoom = :room')
+ ->andWhere('rev.deletedAt IS NULL')
+ ->setParameter('room', $room)
+ ->getQuery()
+ ->getSingleResult();
+
+ $count = (int) $row['count'];
+
+ return [
+ 'avg' => $count > 0 ? round((float) $row['avg'], 1) : null,
+ 'count' => $count,
+ ];
+ }
+
+ /**
+ * Recent reviews across all rooms, for admin moderation.
+ *
+ * @return EscapeRoomReview[]
+ */
+ public function findRecent(int $limit = 200): array
+ {
+ return $this->createQueryBuilder('rev')
+ ->addSelect('room')
+ ->join('rev.escapeRoom', 'room')
+ ->where('rev.deletedAt IS NULL')
+ ->orderBy('rev.createdAt', 'DESC')
+ ->setMaxResults($limit)
+ ->getQuery()
+ ->getResult();
+ }
+}
diff --git a/templates/game/admin/base.html.twig b/templates/game/admin/base.html.twig
index 08d1266..b897c7f 100644
--- a/templates/game/admin/base.html.twig
+++ b/templates/game/admin/base.html.twig
@@ -124,6 +124,7 @@
{# ── Sidebar ──────────────────────────────────────────────────────── #}
+ {% block admin_sidebar %}
+ {% endblock %}
{# ── Content ──────────────────────────────────────────────────────── #}
diff --git a/templates/layout/site.html.twig b/templates/layout/site.html.twig
index c88c628..7afa5ee 100644
--- a/templates/layout/site.html.twig
+++ b/templates/layout/site.html.twig
@@ -20,6 +20,9 @@
{{ 'nav.game'|trans }}
+
+ {{ 'nav.escaperooms'|trans }}
+
{% if app.user %}
{% if is_granted('ROLE_ADMIN') %}
diff --git a/templates/website/admin/base.html.twig b/templates/website/admin/base.html.twig
new file mode 100644
index 0000000..e14a7ff
--- /dev/null
+++ b/templates/website/admin/base.html.twig
@@ -0,0 +1,110 @@
+{% extends 'game/admin/base.html.twig' %}
+
+{% block stylesheets %}
+ {{ parent() }}
+
+{% endblock %}
+
+{% block admin_sidebar %}
+
+{% endblock %}
diff --git a/templates/website/admin/escape_room/form.html.twig b/templates/website/admin/escape_room/form.html.twig
new file mode 100644
index 0000000..301619f
--- /dev/null
+++ b/templates/website/admin/escape_room/form.html.twig
@@ -0,0 +1,41 @@
+{% extends 'website/admin/base.html.twig' %}
+
+{% block title %}{{ heading }} — Website Admin{% endblock %}
+
+{% block admin_body %}
+
+
+ {{ heading }}
+
+
+ {{ form_start(form, {attr: {class: 'er-form'}}) }}
+
{{ form_row(form.name) }}
+
{{ form_row(form.venue) }}
+
+ {{ form_row(form.street) }}
+ {{ form_row(form.houseNumber) }}
+ {{ form_row(form.postcode) }}
+ {{ form_row(form.city) }}
+ {{ form_row(form.country) }}
+
+
+ {{ form_row(form.latitude) }}
+ {{ form_row(form.longitude) }}
+
+ {{ form_row(form.website) }}
+ {{ form_row(form.phone) }}
+
+ {{ form_row(form.status) }}
+ {{ form_row(form.source) }}
+
+
{{ form_row(form.locked) }}
+
+
+ {{ form_end(form) }}
+
+{% endblock %}
diff --git a/templates/website/admin/escape_room/index.html.twig b/templates/website/admin/escape_room/index.html.twig
new file mode 100644
index 0000000..3019069
--- /dev/null
+++ b/templates/website/admin/escape_room/index.html.twig
@@ -0,0 +1,93 @@
+{% extends 'website/admin/base.html.twig' %}
+
+{% block title %}Escape Rooms — Website Admin{% endblock %}
+
+{% block admin_body %}
+
+
+ {% set tabs = {
+ '': 'All (' ~ counts.all ~ ')',
+ 'pending': 'Pending (' ~ counts.pending ~ ')',
+ 'published': 'Published (' ~ counts.published ~ ')',
+ 'hidden': 'Hidden (' ~ counts.hidden ~ ')',
+ 'rejected': 'Rejected (' ~ counts.rejected ~ ')',
+ } %}
+
+ {% for value, label in tabs %}
+ {% set active = (filter is null and value == '') or (filter and filter.value == value) %}
+
+ {{ label }}
+
+ {% endfor %}
+
+
+
+
+
+
+ Name
+ City
+ Country
+ Source
+ Status
+ Reviews
+ Actions
+
+
+
+ {% for room in rooms %}
+
+
+ {% if room.status.isPubliclyVisible %}
+ {{ room.name }}
+ {% else %}
+ {{ room.name }}
+ {% endif %}
+
+ {{ room.city }}
+ {{ room.country }}
+ {{ room.source.value }}
+
+ {{ room.status.label }}
+
+ {{ reviewCounts[room.id]|default(0) }}
+
+ Edit
+ {% if not room.status.isPubliclyVisible %}
+
+ {% else %}
+
+ {% endif %}
+ {% if room.status.value != 'rejected' %}
+
+ {% endif %}
+
+
+
+ {% else %}
+ No rooms.
+ {% endfor %}
+
+
+
+{% endblock %}
diff --git a/templates/website/admin/review/index.html.twig b/templates/website/admin/review/index.html.twig
new file mode 100644
index 0000000..6d66080
--- /dev/null
+++ b/templates/website/admin/review/index.html.twig
@@ -0,0 +1,43 @@
+{% extends 'website/admin/base.html.twig' %}
+
+{% block title %}Reviews — Website Admin{% endblock %}
+
+{% block admin_body %}
+ Recent reviews
+
+
+
+
+
+ Date
+ Room
+ Rating
+ Title
+ Author
+ Actions
+
+
+
+ {% for review in reviews %}
+
+ {{ review.createdAt|date('Y-m-d') }}
+
+ {{ review.escapeRoom.name }}
+
+ {{ review.rating }} ★
+ {{ review.title }}
+ {{ review.author ? review.author.username : '—' }}
+
+
+
+
+ {% else %}
+ No reviews yet.
+ {% endfor %}
+
+
+
+{% endblock %}
diff --git a/templates/website/escape_room/index.html.twig b/templates/website/escape_room/index.html.twig
new file mode 100644
index 0000000..29cf8b7
--- /dev/null
+++ b/templates/website/escape_room/index.html.twig
@@ -0,0 +1,74 @@
+{% extends 'layout/site.html.twig' %}
+
+{% block title %}Escape room map | {{ 'site.name'|trans }}{% endblock %}
+
+{% block stylesheets %}
+ {{ parent() }}
+ {{ encore_entry_link_tags('escaperoom-map') }}
+
+{% endblock %}
+
+{% block body %}
+ {% set detailTemplate = path('website_escaperoom_show', {id: 987654321, slug: 'zzslugzz'})|replace({'987654321': '__ID__', 'zzslugzz': '__SLUG__'}) %}
+
+
+
+
Physical escape rooms
+
Zoom in to find rooms near you, then open one to read and leave reviews.
+
+
+
{{ publishedCount }} rooms on the map
+
Suggest a room
+
+
+
+
+
+
+
+
+
+ Name
+ City
+ Country
+ Rating
+
+
+
+ Loading rooms…
+
+
+
+{% endblock %}
+
+{% block javascripts %}
+ {{ parent() }}
+ {{ encore_entry_script_tags('escaperoom-map') }}
+{% endblock %}
diff --git a/templates/website/escape_room/show.html.twig b/templates/website/escape_room/show.html.twig
new file mode 100644
index 0000000..b7f361e
--- /dev/null
+++ b/templates/website/escape_room/show.html.twig
@@ -0,0 +1,122 @@
+{% extends 'layout/site.html.twig' %}
+
+{% block title %}{{ room.name }} | {{ 'site.name'|trans }}{% endblock %}
+
+{% block stylesheets %}
+ {{ parent() }}
+ {{ encore_entry_link_tags('escaperoom-detail') }}
+
+{% endblock %}
+
+{% block body %}
+
+
+ Escape room map
+ {{ room.name }}
+
+
+
+
+
+
{{ room.name }}
+ {% if room.venue %}
{{ room.venue }}
{% endif %}
+
+
+ {% if rating.count > 0 %}
+ {{ rating.avg }} ★
+ · {{ rating.count }} review{{ rating.count != 1 ? 's' : '' }}
+ {% else %}
+ No reviews yet
+ {% endif %}
+
+
+ {% if room.displayAddress %}
+
{{ room.displayAddress }}
+ {% endif %}
+
+ {% if room.website %}Website · {% endif %}
+ {% if room.phone %}{{ room.phone }} · {% endif %}
+ Open in OpenStreetMap
+
+
+
+
+
Reviews
+
+ {% if app.user %}
+
+
+
Leave a review
+ {{ form_start(reviewForm, {action: path('website_escaperoom_review', {id: room.id})}) }}
+
+ {{ form_label(reviewForm.rating) }}
+ {{ form_widget(reviewForm.rating, {attr: {class: 'form-select'}}) }}
+ {{ form_errors(reviewForm.rating) }}
+
+
+ {{ form_label(reviewForm.title) }}
+ {{ form_widget(reviewForm.title, {attr: {class: 'form-control'}}) }}
+ {{ form_errors(reviewForm.title) }}
+
+
+ {{ form_label(reviewForm.body) }}
+ {{ form_widget(reviewForm.body, {attr: {class: 'form-control'}}) }}
+ {{ form_errors(reviewForm.body) }}
+
+
Post review
+ {{ form_end(reviewForm) }}
+
+
+ {% else %}
+
Log in to leave a review.
+ {% endif %}
+
+ {% for review in reviews %}
+
+
+ {{ review.title }}
+
+ {%- for i in 1..5 -%}
+ ★
+ {%- endfor -%}
+
+
+
+ {{ review.author ? review.author.username : 'Anonymous' }} · {{ review.createdAt|date('Y-m-d') }}
+
+ {{ review.body|nl2br }}
+
+ {% else %}
+
Be the first to review this room.
+ {% endfor %}
+
+
+
+
+
+
+
Coordinates
+
{{ room.latitude }}, {{ room.longitude }}
+
Listing source
+
{{ room.source.label }}
+
+
+
+
+{% endblock %}
+
+{% block javascripts %}
+ {{ parent() }}
+ {{ encore_entry_script_tags('escaperoom-detail') }}
+{% endblock %}
diff --git a/templates/website/escape_room/suggest.html.twig b/templates/website/escape_room/suggest.html.twig
new file mode 100644
index 0000000..b3a52ff
--- /dev/null
+++ b/templates/website/escape_room/suggest.html.twig
@@ -0,0 +1,105 @@
+{% extends 'layout/site.html.twig' %}
+
+{% block title %}Suggest an escape room | {{ 'site.name'|trans }}{% endblock %}
+
+{% block stylesheets %}
+ {{ parent() }}
+ {{ encore_entry_link_tags('escaperoom-detail') }}
+
+{% endblock %}
+
+{% block body %}
+
+
+
+
+ Escape room map
+ Suggest a room
+
+
+
+
Suggest an escape room
+
Know a physical escape room that isn't on the map? Add it here — an admin will review it before it goes live.
+
+
+
+ {{ form_errors(form) }}
+ {{ form_start(form) }}
+
+ {{ form_label(form.name) }}
+ {{ form_widget(form.name, {attr: {class: 'form-control'}}) }}
+ {{ form_errors(form.name) }}
+
+
+ {{ form_label(form.venue) }}
+ {{ form_widget(form.venue, {attr: {class: 'form-control'}}) }}
+ {{ form_errors(form.venue) }}
+
+
+
+
+ {{ form_label(form.street) }}
+ {{ form_widget(form.street, {attr: {class: 'form-control'}}) }}
+
+
+ {{ form_label(form.houseNumber) }}
+ {{ form_widget(form.houseNumber, {attr: {class: 'form-control'}}) }}
+
+
+
+
+ {{ form_label(form.postcode) }}
+ {{ form_widget(form.postcode, {attr: {class: 'form-control'}}) }}
+
+
+ {{ form_label(form.city) }}
+ {{ form_widget(form.city, {attr: {class: 'form-control'}}) }}
+
+
+
+ {{ form_label(form.country) }}
+ {{ form_widget(form.country, {attr: {class: 'form-select'}}) }}
+
+
+
+ {{ form_label(form.website) }}
+ {{ form_widget(form.website, {attr: {class: 'form-control'}}) }}
+
+
+ {{ form_label(form.phone) }}
+ {{ form_widget(form.phone, {attr: {class: 'form-control'}}) }}
+
+
+
+
Location — click the map to drop a pin
+
+ {{ form_widget(form.latitude) }}
+ {{ form_widget(form.longitude) }}
+ {{ form_errors(form.latitude) }}
+ {{ form_errors(form.longitude) }}
+
+
+ {{ form_row(form.captcha) }}
+
+
+
+ Submit for review
+
+ {{ form_end(form) }}
+
+
+
+
+{% endblock %}
+
+{% block javascripts %}
+ {{ parent() }}
+ {{ encore_entry_script_tags('escaperoom-detail') }}
+{% endblock %}
diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml
index c115db2..fcc7c41 100644
--- a/translations/messages.en.yaml
+++ b/translations/messages.en.yaml
@@ -2,6 +2,7 @@
site.name: EscapePage
nav.home: Home
nav.game: Game
+nav.escaperooms: Rooms
nav.admin: Admin
nav.profile: Profile
nav.login: Login
diff --git a/translations/messages.nl.yaml b/translations/messages.nl.yaml
index 9d58ef9..8b83745 100644
--- a/translations/messages.nl.yaml
+++ b/translations/messages.nl.yaml
@@ -2,6 +2,7 @@
site.name: EscapePage
nav.home: Start
nav.game: Spel
+nav.escaperooms: Rooms
nav.admin: Beheer
nav.profile: Profiel
nav.login: Inloggen
diff --git a/webpack.config.js b/webpack.config.js
index 8392dd9..b5ef308 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -15,6 +15,8 @@ Encore
.addEntry('app', './assets/app.js')
.addEntry('game1', './assets/game1.js')
.addEntry('pregame', './assets/pregame.js')
+ .addEntry('escaperoom-map', './assets/escaperoom-map.js')
+ .addEntry('escaperoom-detail', './assets/escaperoom-detail.js')
// split entry chunks for better caching
.splitEntryChunks()
// will require an extra script tag for runtime.js