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, " $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]; }