Files
fitme3070.pt/config/functions_reviews.php
T
andreandClaude Opus 5.5 f95c9ebc52 feat: contactos editáveis, equipa com título/aptidões, mensalidades e reviews Google
- Contactos: nova página no backoffice (fitme3070_info) e 964 201 137 como
  telefone principal; o telefone secundário só aparece quando existe.
- Equipa: título, aptidões/funções e formações; o cartão mostra foto, nome e
  título e abre um cartão horizontal de detalhe (hover no desktop, toque no
  telemóvel) sem mexer na grelha.
- Mensalidades: cartões editáveis com um destaque, modal de inscrição que
  guarda o pedido (RGPD + estado + campos reservados para o CRM) e avisa o
  ginásio por e-mail; lista de pedidos com estados, notas e CSV.
- Reviews Google via Places API (New), filtradas por classificação mínima e
  em cache configurável; página de configuração com pesquisa do Place ID.
- Assets com ?v=<mtime>: site/assets é servido com cache imutável de 1 ano.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 14:58:57 +01:00

201 lines
9.1 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)
//
// Two limits of the API shape the section:
// - Place Details returns at most 5 reviews, chosen by Google ("most
// relevant"). There is no "only positive" query: we filter those 5 by
// rating, 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';
function reviews_config(): array {
return [
'api_key' => setting_secret('google_places_api_key', 'GOOGLE_PLACES_API_KEY'),
'place_id' => trim((string) setting('google_place_id', '')),
'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)),
];
}
function reviews_configured(): bool {
$c = reviews_config();
return $c['api_key'] !== '' && $c['place_id'] !== '';
}
function _reviews_cache_path(): string {
return __DIR__ . '/cache/google_reviews.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 {
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']) !== ''));
// Highest rating first; Google's relevance order breaks ties (usort is stable).
usort($picked, fn($a, $b) => $b['rating'] <=> $a['rating']);
$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];
}