From 1614bf327bd053eb939a7d1ab17ba1a47c9e3ad6 Mon Sep 17 00:00:00 2001 From: andre Date: Thu, 24 Sep 2026 15:46:00 +0100 Subject: [PATCH] =?UTF-8?q?feat(reviews):=20ordenar=20por=20recentes/compl?= =?UTF-8?q?etas,=20s=C3=B3=20com=20foto,=20cart=C3=A3o=20com=20foto=20gran?= =?UTF-8?q?de?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Places API (New) não tem parâmetro de ordenação e devolve no máximo 5 reviews, por isso ordem e filtro aplicam-se a essas 5: relevância (ordem da Google), mais recentes (publishTime) ou mais completas (texto mais longo), e opcionalmente só quem tem foto de perfil. Mudar estas opções já não apaga a cache — só uma nova chave ou Place ID obriga a um novo pedido pago. Cartão: foto de 84px (ou inicial), nome, data e estrelas grandes em cima; texto mais pequeno por baixo. Co-Authored-By: Claude Opus 5.5 --- config/functions_reviews.php | 27 ++++++++++--- site/assets/css/style.css | 71 +++++++++++++++++++++++------------ site/index.php | 16 ++++---- views/admin/reviews/index.php | 24 +++++++++++- 4 files changed, 101 insertions(+), 37 deletions(-) diff --git a/config/functions_reviews.php b/config/functions_reviews.php index edc7413..9736f2e 100644 --- a/config/functions_reviews.php +++ b/config/functions_reviews.php @@ -8,11 +8,15 @@ // 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"). There is no "only positive" query: we filter those 5 by -// rating, so on a bad week fewer than `max` may qualify. +// 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. // @@ -30,9 +34,16 @@ function reviews_config(): array { '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'] !== ''; @@ -163,9 +174,15 @@ 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_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 [ diff --git a/site/assets/css/style.css b/site/assets/css/style.css index 289ea87..76fd956 100644 --- a/site/assets/css/style.css +++ b/site/assets/css/style.css @@ -3384,42 +3384,46 @@ section[id] { .review-card { display: flex; flex-direction: column; + align-items: center; + text-align: center; margin: 0; background: var(--fitme-dark-2); border: 1px solid var(--fitme-dark-3); border-radius: 6px; - padding: 24px; + padding: 28px 24px 24px; } -.rv-text { - flex: 1 1 auto; - margin: .8rem 0 1.2rem; - font-size: .95rem; - line-height: 1.6; - color: #fff; - display: -webkit-box; - -webkit-line-clamp: 7; - -webkit-box-orient: vertical; +.rv-avatar { + width: 84px; + height: 84px; + border-radius: 50%; overflow: hidden; + border: 3px solid var(--fitme-accent); + background: var(--fitme-dark-3); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: .8rem; + flex: 0 0 auto; +} + +.rv-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.rv-avatar span { + font-size: 2rem; + font-weight: 900; + color: var(--fitme-accent); } .rv-author { - display: flex; - align-items: center; - gap: 12px; -} - -.rv-author img { - width: 40px; - height: 40px; - border-radius: 50%; - object-fit: cover; -} - -.rv-author span { display: flex; flex-direction: column; - font-weight: 700; + font-size: 1.05rem; + font-weight: 800; color: #fff; } @@ -3427,8 +3431,27 @@ section[id] { .rv-author a:hover { color: var(--fitme-accent); } .rv-author small { + font-size: .78rem; font-weight: 400; color: var(--fitme-ash); + margin-top: 2px; +} + +.rv-stars-lg { + font-size: 1.3rem; + letter-spacing: 3px; + margin: .6rem 0 .2rem; +} + +.rv-text { + margin: .6rem 0 0; + font-size: .85rem; + line-height: 1.55; + color: var(--fitme-ash); + display: -webkit-box; + -webkit-line-clamp: 6; + -webkit-box-orient: vertical; + overflow: hidden; } .reviews-actions { diff --git a/site/index.php b/site/index.php index cce2034..b1005dd 100644 --- a/site/index.php +++ b/site/index.php @@ -501,15 +501,17 @@ if ($reviews):
- -
+ +
+ + +
- - - - - + +
+ +
diff --git a/views/admin/reviews/index.php b/views/admin/reviews/index.php index 7fca78d..8581d44 100644 --- a/views/admin/reviews/index.php +++ b/views/admin/reviews/index.php @@ -10,6 +10,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $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'])) { @@ -21,7 +22,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { 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); - reviews_cache_clear(); // new settings → next view fetches fresh + $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; @@ -97,6 +106,19 @@ include __DIR__ . '/../../../includes/header.php';
Cada atualização é um pedido pago à Google.
+
+ + +
+
+
+ > + +
+
+
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.