Files
fitme3070.pt/views/admin/reviews/index.php
T
andreandClaude Opus 5.5 5e300fc341 feat: vários ginásios (/3070/ e /<código postal>/), CRUD de ginásios, modalidades após a galeria
Estrutura multi-ginásio: as mesmas secções do site, conteúdo próprio por
ginásio, e cada um numa pasta da URL com o código postal da zona.

- migration_20260926_gyms: tabela gyms (pasta, nome, localidade, publicado,
  principal, secções visíveis, título/imagem do header, SEO, galeria, Place ID,
  página CrossFit) com o FitMe 3070 como ginásio 1 em /3070/; gym_id em team,
  modalidades (o horário segue a modalidade), work_hours, memberships,
  membership_leads e website_offer (tudo o existente → ginásio 1); events com
  gym_id NULL = todos; fitme3070_info passa a uma linha por ginásio (id = gym).
- Site: /<pasta>/, /<pasta>/crossfit, /<pasta>/agendar|inscricao. Ginásio por
  publicar → 404 para o público, pré-visualização (com faixa) para a equipa.
  / e /home → 301 para o ginásio principal (a manter a query); com mais de um
  ginásio publicado, a raiz passa a uma página para escolher. Secções
  desligadas não aparecem nem no menu; menu, canonical, JSON-LD, sitemap,
  formulários, e-mails e reviews (Place ID e cache) são do ginásio.
- Backoffice: Gestão → Ginásios (criar/editar/apagar, secções, e «começar
  com o conteúdo de» outro ginásio — cópias independentes) e o seletor
  «A editar» no topo; equipa, modalidades, horários, horário de
  funcionamento, mensalidades, pedidos, contactos e reviews trabalham sobre o
  ginásio escolhido, e um registo de outro ginásio não abre.
- Modalidades passam para logo a seguir à galeria das instalações.
- Galeria sem cantos arredondados (raio da marca).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:26:40 +01:00

188 lines
12 KiB
PHP

<?php
require __DIR__ . '/../../../config/db.php';
require __DIR__ . '/../../../config/functions.php';
$user = require_admin();
// The Place ID and cache are per gym: this page works on the gym being edited.
$rgym = admin_gym($pdo);
reviews_use_gym($rgym);
$search = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_check();
$uid = (int) ($user['id'] ?? 0) ?: null;
$action = (string) ($_POST['action'] ?? '');
if ($action === 'save') {
$before = reviews_config();
// Key: blank keeps the stored one; the "remover" box clears it.
$key = trim((string) ($_POST['google_places_api_key'] ?? ''));
if (!empty($_POST['clear_key'])) {
db_update_setting($pdo, 'google_places_api_key', '', $uid);
} elseif ($key !== '') {
db_update_setting($pdo, 'google_places_api_key', secret_encrypt($key), $uid);
}
$pdo->prepare("UPDATE gyms SET google_place_id = ?, updated_at = NOW() WHERE id = ?")
->execute([mb_substr(trim((string) ($_POST['google_place_id'] ?? '')), 0, 120), (int) $rgym['id']]);
db_update_setting($pdo, 'google_reviews_min_rating', (string) max(1, min(5, (int) ($_POST['min_rating'] ?? 4))), $uid);
db_update_setting($pdo, 'google_reviews_max', (string) max(1, min(5, (int) ($_POST['max'] ?? 3))), $uid);
db_update_setting($pdo, 'google_reviews_cache_hours', (string) max(1, (int) ($_POST['cache_hours'] ?? 24)), $uid);
$sort = (string) ($_POST['sort'] ?? 'relevance');
db_update_setting($pdo, 'google_reviews_sort', isset(reviews_sort_options()[$sort]) ? $sort : 'relevance', $uid);
db_update_setting($pdo, 'google_reviews_photo_only', !empty($_POST['photo_only']) ? '1' : '0', $uid);
// Only a new key / place needs a new (billed) fetch; rating, count, sort
// and photo filters are applied to the cached 5 at render time.
// (setting() is cached per request, so compare with what was posted.)
$key_changed = !empty($_POST['clear_key']) || ($key !== '' && $key !== $before['api_key']);
$place_changed = trim((string) ($_POST['google_place_id'] ?? '')) !== $before['place_id'];
if ($key_changed || $place_changed) reviews_cache_clear();
session()->setFlashdata('success', 'Definições guardadas.');
header('Location: ' . base_url('views/admin/reviews/index.php'));
exit;
}
if ($action === 'refresh') {
$r = reviews_refresh();
session()->setFlashdata(!empty($r['error']) ? 'error' : 'success',
!empty($r['error']) ? 'Falhou: ' . $r['error'] : 'Reviews atualizadas (' . count($r['reviews'] ?? []) . ' recebidas da Google).');
header('Location: ' . base_url('views/admin/reviews/index.php'));
exit;
}
if ($action === 'search') {
$apiKey = reviews_config()['api_key'];
$q = trim((string) ($_POST['query'] ?? ''));
$search = ($apiKey === '') ? ['error' => 'Guarda primeiro a chave da API.', 'places' => []]
: ($q === '' ? ['error' => 'Escreve o nome do ginásio.', 'places' => []] : reviews_find_place($q, $apiKey));
}
}
$cfg = reviews_config();
$env_key = (string) env('GOOGLE_PLACES_API_KEY', '') !== '';
$cache = reviews_cache_read();
$preview = site_reviews();
$info = site_info($pdo, (int) $rgym['id']);
$page_title = 'Reviews Google';
include __DIR__ . '/../../../includes/header.php';
?>
<div class="container" style="max-width:900px;">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0">Reviews Google</h1>
<a class="btn btn-outline-secondary btn-sm" href="<?= base_url('home#reviews') ?>" target="_blank" rel="noopener"><i class="bi bi-box-arrow-up-right me-1"></i>Ver no site</a>
</div>
<p class="text-muted small">A secção mostra as melhores reviews do ginásio no Google, obtidas pela Places API. A Google devolve no máximo 5 reviews (escolhidas por relevância); o site mostra as que têm a classificação mínima definida. Se nenhuma se qualificar, a secção fica escondida.</p>
<div class="card shadow-sm mb-4"><div class="card-body">
<h2 class="h5 mb-3">Configuração</h2>
<form method="POST">
<?= csrf_field() ?>
<input type="hidden" name="action" value="save">
<div class="row g-3">
<div class="col-md-7">
<label class="form-label">Chave da API (Places API — New)</label>
<?php if ($env_key): ?>
<input class="form-control" disabled value="Definida no .env (GOOGLE_PLACES_API_KEY)">
<?php else: ?>
<input class="form-control font-monospace" type="password" name="google_places_api_key" autocomplete="off"
placeholder="<?= $cfg['api_key'] !== '' ? 'Guardada — deixa vazio para manter' : 'AIza…' ?>">
<?php if ($cfg['api_key'] !== ''): ?>
<div class="form-check mt-1"><input class="form-check-input" type="checkbox" name="clear_key" id="clear_key" value="1">
<label class="form-check-label small" for="clear_key">Remover chave guardada</label></div>
<?php endif; ?>
<?php if (!secrets_available()): ?><div class="form-text text-warning">Sem chave de encriptação no .env — a chave fica guardada sem encriptação.</div><?php endif; ?>
<?php endif; ?>
<div class="form-text">Google Cloud Console → ativar «Places API (New)» → Credenciais. Restringe a chave a essa API.</div>
</div>
<div class="col-md-5">
<label class="form-label">Place ID</label>
<input class="form-control font-monospace" name="google_place_id" value="<?= htmlspecialchars($cfg['place_id']) ?>" placeholder="ChIJ…">
<div class="form-text">Não sabes? Usa a pesquisa abaixo.</div>
</div>
<div class="col-md-4">
<label class="form-label">Classificação mínima</label>
<select class="form-select" name="min_rating">
<?php for ($i = 5; $i >= 1; $i--): ?><option value="<?= $i ?>" <?= $cfg['min_rating'] === $i ? 'selected' : '' ?>><?= $i ?> ★<?= $i < 5 ? ' ou mais' : '' ?></option><?php endfor; ?>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Número de reviews</label>
<input type="number" class="form-control" name="max" min="1" max="5" value="<?= $cfg['max'] ?>">
</div>
<div class="col-md-4">
<label class="form-label">Atualizar a cada (horas)</label>
<input type="number" class="form-control" name="cache_hours" min="1" value="<?= $cfg['cache_hours'] ?>">
<div class="form-text">Cada atualização é um pedido pago à Google.</div>
</div>
<div class="col-md-6">
<label class="form-label">Ordenar por</label>
<select class="form-select" name="sort">
<?php foreach (reviews_sort_options() as $k => $label): ?><option value="<?= $k ?>" <?= $cfg['sort'] === $k ? 'selected' : '' ?>><?= $label ?></option><?php endforeach; ?>
</select>
</div>
<div class="col-md-6 d-flex align-items-end">
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" name="photo_only" id="photo_only" value="1" <?= $cfg['photo_only'] ? 'checked' : '' ?>>
<label class="form-check-label" for="photo_only">Só pessoas com foto de perfil</label>
</div>
</div>
<div class="col-12"><div class="form-text mt-0">A Google não permite pedir reviews ordenadas nem mais do que 5: filtros e ordem aplicam-se às 5 que ela devolve. «Mais úteis» não existe na API — «Mais completas» é a aproximação.</div></div>
</div>
<button class="btn btn-primary mt-3">Guardar</button>
</form>
</div></div>
<div class="card shadow-sm mb-4"><div class="card-body">
<h2 class="h5 mb-3">Encontrar o Place ID</h2>
<form method="POST" class="d-flex gap-2">
<?= csrf_field() ?>
<input type="hidden" name="action" value="search">
<input class="form-control" name="query" value="<?= htmlspecialchars((string) ($_POST['query'] ?? trim($info['name'] . ' ' . $info['adress']))) ?>">
<button class="btn btn-outline-primary text-nowrap"><i class="bi bi-search me-1"></i>Procurar</button>
</form>
<?php if ($search !== null): ?>
<?php if ($search['error'] !== ''): ?><div class="alert alert-danger mt-3 mb-0"><?= htmlspecialchars($search['error']) ?></div>
<?php elseif (!$search['places']): ?><div class="alert alert-warning mt-3 mb-0">Sem resultados.</div>
<?php else: ?>
<ul class="list-group mt-3">
<?php foreach ($search['places'] as $p): ?>
<li class="list-group-item d-flex justify-content-between align-items-center gap-2">
<div><strong><?= htmlspecialchars($p['name']) ?></strong>
<?php if ($p['rating'] !== null): ?><span class="small text-muted"> · <?= htmlspecialchars((string) $p['rating']) ?> ★ (<?= (int) $p['total'] ?>)</span><?php endif; ?>
<div class="small text-muted"><?= htmlspecialchars($p['address']) ?></div>
<code class="small"><?= htmlspecialchars($p['id']) ?></code></div>
<button type="button" class="btn btn-sm btn-outline-success text-nowrap" onclick="document.querySelector('[name=google_place_id]').value='<?= htmlspecialchars($p['id'], ENT_QUOTES) ?>';window.scrollTo(0,0);">Usar</button>
</li>
<?php endforeach; ?>
</ul>
<div class="form-text">Depois de «Usar», carrega em Guardar.</div>
<?php endif; ?>
<?php endif; ?>
</div></div>
<div class="card shadow-sm"><div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h5 mb-0">Estado</h2>
<form method="POST" class="mb-0"><?= csrf_field() ?><input type="hidden" name="action" value="refresh">
<button class="btn btn-sm btn-outline-primary" <?= reviews_configured() ? '' : 'disabled' ?>><i class="bi bi-arrow-clockwise me-1"></i>Atualizar agora</button></form>
</div>
<?php if (!reviews_configured()): ?>
<p class="text-muted mb-0">Por configurar — a secção não aparece no site.</p>
<?php else: ?>
<p class="small mb-2">
Última atualização: <?= !empty($cache['fetched_at']) ? date('d/m/Y H:i', (int) $cache['fetched_at']) : '—' ?>
<?php if (isset($cache['rating'])): ?> · <?= htmlspecialchars(number_format((float) $cache['rating'], 1, ',', '')) ?> ★ em <?= (int) ($cache['total'] ?? 0) ?> avaliações<?php endif; ?>
</p>
<?php if (!empty($cache['error'])): ?><div class="alert alert-danger small">Último erro: <?= htmlspecialchars($cache['error']) ?></div><?php endif; ?>
<?php if ($preview): ?>
<p class="small text-muted mb-2">No site aparecem <?= count($preview['reviews']) ?>:</p>
<?php foreach ($preview['reviews'] as $r): ?>
<div class="border rounded p-2 mb-2 small">
<strong><?= htmlspecialchars($r['author']) ?></strong> · <?= str_repeat('★', $r['rating']) ?> · <span class="text-muted"><?= htmlspecialchars($r['relative']) ?></span>
<div><?= nl2br(htmlspecialchars(mb_strimwidth($r['text'], 0, 240, '…'))) ?></div>
</div>
<?php endforeach; ?>
<?php else: ?>
<p class="text-muted small mb-0">Nenhuma review qualifica neste momento — a secção está escondida.</p>
<?php endif; ?>
<?php endif; ?>
</div></div>
</div>
<?php include __DIR__ . '/../../../includes/footer.php';