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>
188 lines
12 KiB
PHP
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';
|