feat(reviews): ordenar por recentes/completas, só com foto, cartão com foto grande

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 <noreply@anthropic.com>
This commit is contained in:
andreandClaude Opus 5.5 committed 2026-09-24 15:46:00 +01:00
1 parent f95c9ebc52
commit 1614bf327b
4 files changed
+99 -35

No files matched your search

+22 -5
View File
@@ -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 [
+47 -24
View File
@@ -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 {
+7 -5
View File
@@ -501,15 +501,17 @@ if ($reviews):
<div class="reviews-grid">
<?php foreach ($reviews['reviews'] as $r): ?>
<figure class="review-card">
<div class="rv-stars" role="img" aria-label="<?= (int) $r['rating'] ?> de 5 estrelas"><?= $stars((float) $r['rating']) ?></div>
<blockquote class="rv-text"><?= nl2br(htmlspecialchars($r['text'])) ?></blockquote>
<?php // Reviewer first: big photo (or initial), name, date, stars — then the text, smaller. ?>
<div class="rv-avatar">
<?php if ($r['author_photo'] !== ''): ?><img src="<?= htmlspecialchars($r['author_photo']) ?>" alt="" loading="lazy" referrerpolicy="no-referrer" width="84" height="84">
<?php else: ?><span aria-hidden="true"><?= htmlspecialchars(mb_strtoupper(mb_substr($r['author'], 0, 1))) ?></span><?php endif; ?>
</div>
<figcaption class="rv-author">
<?php if ($r['author_photo'] !== ''): ?><img src="<?= htmlspecialchars($r['author_photo']) ?>" alt="" loading="lazy" referrerpolicy="no-referrer" width="40" height="40"><?php endif; ?>
<span>
<?php if ($r['author_uri'] !== ''): ?><a href="<?= htmlspecialchars($r['author_uri']) ?>" target="_blank" rel="noopener nofollow"><?= htmlspecialchars($r['author']) ?></a><?php else: ?><?= htmlspecialchars($r['author']) ?><?php endif; ?>
<small><?= htmlspecialchars($r['relative']) ?></small>
</span>
</figcaption>
<div class="rv-stars rv-stars-lg" role="img" aria-label="<?= (int) $r['rating'] ?> de 5 estrelas"><?= $stars((float) $r['rating']) ?></div>
<blockquote class="rv-text"><?= nl2br(htmlspecialchars($r['text'])) ?></blockquote>
</figure>
<?php endforeach; ?>
</div>
+23 -1
View File
@@ -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';
<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>