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>
235 lines
11 KiB
PHP
235 lines
11 KiB
PHP
<?php
|
|
// ─── Reviews Google (Places API — New) ────────────────────────────────────────
|
|
// The homepage shows the gym's best Google reviews, pulled live from Place
|
|
// Details. Configured in the backoffice (views/admin/reviews/index.php):
|
|
//
|
|
// google_places_api_key — encrypted at rest (.env GOOGLE_PLACES_API_KEY wins)
|
|
// google_place_id — the gym's Place ID
|
|
// google_reviews_min_rating — only reviews with at least this many stars (default 4)
|
|
// google_reviews_max — how many to show (default 3)
|
|
// google_reviews_cache_hours — how long a fetch is reused (default 24)
|
|
// google_reviews_sort — relevance (Google's order) | newest | longest
|
|
// google_reviews_photo_only — only reviewers with a profile photo (0/1)
|
|
//
|
|
// Two limits of the API shape the section:
|
|
// - Place Details returns at most 5 reviews, chosen by Google ("most
|
|
// relevant"), and the New API has no sort parameter (checked 2026-09:
|
|
// places.get only takes languageCode/regionCode/sessionToken). Rating,
|
|
// photo, newest and longest are therefore applied to those 5 here, so on a
|
|
// bad week fewer than `max` may qualify.
|
|
// - Reviews are a billed Enterprise + Atmosphere field. Without the cache every
|
|
// page view would be one billed call; with it, one call per cache window.
|
|
//
|
|
// The cache is a PHP array literal under config/cache/ (like the settings
|
|
// cache): read via include, served from opcache, never over HTTP. A failed
|
|
// refresh keeps serving the previous copy so a key/quota problem never blanks
|
|
// the section — the error is kept for the backoffice page to show.
|
|
|
|
const REVIEWS_FIELD_MASK = 'displayName,rating,userRatingCount,googleMapsUri,reviews';
|
|
|
|
// Multi-gym: the API key and display options are shared; the Place ID (and so
|
|
// the cache) belongs to a gym (gyms.google_place_id). Which gym is taken from
|
|
// reviews_gym(): the gym the public page renders, or — on the backoffice
|
|
// Reviews page — the gym being edited (set with reviews_use_gym()).
|
|
function reviews_use_gym(array $gym): void {
|
|
$GLOBALS['_reviews_gym'] = $gym;
|
|
}
|
|
|
|
function reviews_gym(): array {
|
|
return $GLOBALS['_reviews_gym'] ?? current_gym();
|
|
}
|
|
|
|
function reviews_config(): array {
|
|
$gym = reviews_gym();
|
|
$place = trim((string) ($gym['google_place_id'] ?? ''));
|
|
// Pre-multi-gym installs kept the Place ID in settings (gym 1 only).
|
|
if ($place === '' && (int) ($gym['id'] ?? 1) === 1) $place = trim((string) setting('google_place_id', ''));
|
|
return [
|
|
'api_key' => setting_secret('google_places_api_key', 'GOOGLE_PLACES_API_KEY'),
|
|
'place_id' => $place,
|
|
'min_rating' => max(1, min(5, (int) setting('google_reviews_min_rating', 4))),
|
|
'max' => max(1, min(5, (int) setting('google_reviews_max', 3))),
|
|
'cache_hours' => max(1, (int) setting('google_reviews_cache_hours', 24)),
|
|
'sort' => in_array(setting('google_reviews_sort', 'relevance'), ['relevance', 'newest', 'longest'], true)
|
|
? (string) setting('google_reviews_sort', 'relevance') : 'relevance',
|
|
'photo_only' => (bool) (int) setting('google_reviews_photo_only', 0),
|
|
];
|
|
}
|
|
|
|
function reviews_sort_options(): array {
|
|
return ['relevance' => 'Relevância (ordem da Google)', 'newest' => 'Mais recentes', 'longest' => 'Mais completas (texto mais longo)'];
|
|
}
|
|
|
|
function reviews_configured(): bool {
|
|
$c = reviews_config();
|
|
return $c['api_key'] !== '' && $c['place_id'] !== '';
|
|
}
|
|
|
|
function _reviews_cache_path(): string {
|
|
return __DIR__ . '/cache/google_reviews_' . (int) (reviews_gym()['id'] ?? 1) . '.cache.php';
|
|
}
|
|
|
|
function reviews_cache_read(): ?array {
|
|
$path = _reviews_cache_path();
|
|
if (!is_file($path)) return null;
|
|
$data = @include $path;
|
|
return is_array($data) ? $data : null;
|
|
}
|
|
|
|
function _reviews_cache_write(array $data): void {
|
|
$path = _reviews_cache_path();
|
|
if (!is_dir(dirname($path))) @mkdir(dirname($path), 0775, true);
|
|
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
|
|
if (@file_put_contents($tmp, "<?php\nreturn " . var_export($data, true) . ";\n", LOCK_EX) === false) return;
|
|
@rename($tmp, $path);
|
|
if (function_exists('opcache_invalidate')) @opcache_invalidate($path, true);
|
|
}
|
|
|
|
function reviews_cache_clear(): void {
|
|
$path = _reviews_cache_path();
|
|
if (is_file($path)) @unlink($path);
|
|
}
|
|
|
|
// Low-level GET/POST against places.googleapis.com. Returns [status, decoded|null, error].
|
|
function _places_request(string $method, string $url, string $apiKey, string $fieldMask, ?array $body = null): array {
|
|
if (!function_exists('curl_init')) return [0, null, 'cURL indisponível nesta instalação.'];
|
|
$ch = curl_init($url);
|
|
$headers = ['X-Goog-Api-Key: ' . $apiKey, 'X-Goog-FieldMask: ' . $fieldMask, 'Accept: application/json'];
|
|
if ($body !== null) {
|
|
$headers[] = 'Content-Type: application/json';
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
|
|
}
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_CUSTOMREQUEST => $method,
|
|
CURLOPT_HTTPHEADER => $headers,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_CONNECTTIMEOUT => 3,
|
|
CURLOPT_TIMEOUT => 5,
|
|
]);
|
|
$raw = curl_exec($ch);
|
|
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$cerr = curl_error($ch);
|
|
curl_close($ch);
|
|
if ($raw === false) return [0, null, 'Sem ligação à Google: ' . $cerr];
|
|
$json = json_decode((string) $raw, true);
|
|
if ($status !== 200) {
|
|
$msg = is_array($json) ? (string) ($json['error']['message'] ?? '') : '';
|
|
return [$status, $json, 'Google respondeu ' . $status . ($msg !== '' ? ': ' . $msg : '')];
|
|
}
|
|
return [$status, is_array($json) ? $json : null, is_array($json) ? '' : 'Resposta inválida da Google.'];
|
|
}
|
|
|
|
// Fetch Place Details now and rewrite the cache. Returns the cache array.
|
|
function reviews_refresh(): array {
|
|
$c = reviews_config();
|
|
$old = reviews_cache_read() ?? [];
|
|
if ($c['api_key'] === '' || $c['place_id'] === '') {
|
|
return $old + ['error' => 'Falta a chave da API ou o Place ID.'];
|
|
}
|
|
$url = 'https://places.googleapis.com/v1/places/' . rawurlencode($c['place_id']) . '?languageCode=pt-PT';
|
|
[$status, $json, $err] = _places_request('GET', $url, $c['api_key'], REVIEWS_FIELD_MASK);
|
|
|
|
if ($err !== '') {
|
|
// Keep serving the previous copy; retry after an hour, not a full window.
|
|
$data = $old;
|
|
$data['error'] = $err;
|
|
$data['error_at'] = time();
|
|
$data['fetched_at'] = time() - max(0, $c['cache_hours'] * 3600 - 3600);
|
|
_reviews_cache_write($data);
|
|
error_log('[reviews] ' . $err);
|
|
return $data;
|
|
}
|
|
|
|
$reviews = [];
|
|
foreach ((array) ($json['reviews'] ?? []) as $r) {
|
|
$reviews[] = [
|
|
'rating' => (int) ($r['rating'] ?? 0),
|
|
'text' => (string) ($r['text']['text'] ?? $r['originalText']['text'] ?? ''),
|
|
'relative' => (string) ($r['relativePublishTimeDescription'] ?? ''),
|
|
'published' => (string) ($r['publishTime'] ?? ''),
|
|
'author' => (string) ($r['authorAttribution']['displayName'] ?? ''),
|
|
'author_uri' => (string) ($r['authorAttribution']['uri'] ?? ''),
|
|
'author_photo' => (string) ($r['authorAttribution']['photoUri'] ?? ''),
|
|
'uri' => (string) ($r['googleMapsUri'] ?? ''),
|
|
];
|
|
}
|
|
$data = [
|
|
'fetched_at' => time(),
|
|
'name' => (string) ($json['displayName']['text'] ?? ''),
|
|
'rating' => isset($json['rating']) ? (float) $json['rating'] : null,
|
|
'total' => (int) ($json['userRatingCount'] ?? 0),
|
|
'maps_uri' => (string) ($json['googleMapsUri'] ?? ''),
|
|
'reviews' => $reviews,
|
|
'error' => '',
|
|
];
|
|
_reviews_cache_write($data);
|
|
return $data;
|
|
}
|
|
|
|
// Cached data, refreshed when older than the cache window. The refresh is
|
|
// serialised with a non-blocking lock so a burst of visitors at expiry makes
|
|
// one Google call, not one each — the others just serve the stale copy.
|
|
function reviews_get(): array {
|
|
$c = reviews_config();
|
|
$cache = reviews_cache_read();
|
|
$fresh = $cache && (time() - (int) ($cache['fetched_at'] ?? 0)) < $c['cache_hours'] * 3600;
|
|
if ($fresh || !reviews_configured()) return $cache ?? [];
|
|
|
|
$lock = @fopen(_reviews_cache_path() . '.lock', 'c');
|
|
if ($lock && flock($lock, LOCK_EX | LOCK_NB)) {
|
|
$cache = reviews_refresh();
|
|
flock($lock, LOCK_UN);
|
|
}
|
|
if ($lock) fclose($lock);
|
|
return $cache ?? [];
|
|
}
|
|
|
|
// What the homepage renders: summary + the best qualifying reviews, or null
|
|
// when there is nothing worth showing (not configured, no qualifying review).
|
|
function site_reviews(?array $gym = null): ?array {
|
|
if ($gym !== null) reviews_use_gym($gym);
|
|
try { $data = reviews_get(); } catch (Throwable $e) { error_log('[reviews] ' . $e->getMessage()); return null; }
|
|
if (!$data || empty($data['reviews'])) return null;
|
|
$c = reviews_config();
|
|
$picked = array_values(array_filter($data['reviews'], fn($r) =>
|
|
$r['rating'] >= $c['min_rating'] && trim($r['text']) !== ''
|
|
&& (!$c['photo_only'] || $r['author_photo'] !== '')));
|
|
// 'relevance' keeps Google's order. usort is stable, so ties keep it too.
|
|
if ($c['sort'] === 'newest') {
|
|
usort($picked, fn($a, $b) => strcmp($b['published'], $a['published'])); // ISO-8601 sorts as text
|
|
} elseif ($c['sort'] === 'longest') {
|
|
usort($picked, fn($a, $b) => mb_strlen($b['text']) <=> mb_strlen($a['text']));
|
|
}
|
|
$picked = array_slice($picked, 0, $c['max']);
|
|
if (!$picked) return null;
|
|
return [
|
|
'rating' => $data['rating'],
|
|
'total' => $data['total'],
|
|
'maps_uri' => $data['maps_uri'],
|
|
'write_uri' => 'https://search.google.com/local/writereview?placeid=' . rawurlencode($c['place_id']),
|
|
'reviews' => $picked,
|
|
];
|
|
}
|
|
|
|
// Backoffice helper: find the Place ID from a free-text query (Text Search,
|
|
// IDs + name + address only — the cheapest field set).
|
|
function reviews_find_place(string $query, string $apiKey): array {
|
|
[$status, $json, $err] = _places_request(
|
|
'POST', 'https://places.googleapis.com/v1/places:searchText', $apiKey,
|
|
'places.id,places.displayName,places.formattedAddress,places.rating,places.userRatingCount',
|
|
['textQuery' => $query, 'languageCode' => 'pt-PT', 'regionCode' => 'PT']
|
|
);
|
|
if ($err !== '') return ['error' => $err, 'places' => []];
|
|
$out = [];
|
|
foreach ((array) ($json['places'] ?? []) as $p) {
|
|
$out[] = [
|
|
'id' => (string) ($p['id'] ?? ''),
|
|
'name' => (string) ($p['displayName']['text'] ?? ''),
|
|
'address' => (string) ($p['formattedAddress'] ?? ''),
|
|
'rating' => $p['rating'] ?? null,
|
|
'total' => (int) ($p['userRatingCount'] ?? 0),
|
|
];
|
|
}
|
|
return ['error' => '', 'places' => $out];
|
|
}
|