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
+101 -37

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_min_rating — only reviews with at least this many stars (default 4)
// google_reviews_max — how many to show (default 3) // google_reviews_max — how many to show (default 3)
// google_reviews_cache_hours — how long a fetch is reused (default 24) // 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: // Two limits of the API shape the section:
// - Place Details returns at most 5 reviews, chosen by Google ("most // - Place Details returns at most 5 reviews, chosen by Google ("most
// relevant"). There is no "only positive" query: we filter those 5 by // relevant"), and the New API has no sort parameter (checked 2026-09:
// rating, so on a bad week fewer than `max` may qualify. // 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 // - 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. // 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))), 'min_rating' => max(1, min(5, (int) setting('google_reviews_min_rating', 4))),
'max' => max(1, min(5, (int) setting('google_reviews_max', 3))), 'max' => max(1, min(5, (int) setting('google_reviews_max', 3))),
'cache_hours' => max(1, (int) setting('google_reviews_cache_hours', 24)), '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 { function reviews_configured(): bool {
$c = reviews_config(); $c = reviews_config();
return $c['api_key'] !== '' && $c['place_id'] !== ''; 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; } try { $data = reviews_get(); } catch (Throwable $e) { error_log('[reviews] ' . $e->getMessage()); return null; }
if (!$data || empty($data['reviews'])) return null; if (!$data || empty($data['reviews'])) return null;
$c = reviews_config(); $c = reviews_config();
$picked = array_values(array_filter($data['reviews'], fn($r) => $r['rating'] >= $c['min_rating'] && trim($r['text']) !== '')); $picked = array_values(array_filter($data['reviews'], fn($r) =>
// Highest rating first; Google's relevance order breaks ties (usort is stable). $r['rating'] >= $c['min_rating'] && trim($r['text']) !== ''
usort($picked, fn($a, $b) => $b['rating'] <=> $a['rating']); && (!$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']); $picked = array_slice($picked, 0, $c['max']);
if (!$picked) return null; if (!$picked) return null;
return [ return [
+47 -24
View File
@@ -3384,42 +3384,46 @@ section[id] {
.review-card { .review-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center;
text-align: center;
margin: 0; margin: 0;
background: var(--fitme-dark-2); background: var(--fitme-dark-2);
border: 1px solid var(--fitme-dark-3); border: 1px solid var(--fitme-dark-3);
border-radius: 6px; border-radius: 6px;
padding: 24px; padding: 28px 24px 24px;
} }
.rv-text { .rv-avatar {
flex: 1 1 auto; width: 84px;
margin: .8rem 0 1.2rem; height: 84px;
font-size: .95rem; border-radius: 50%;
line-height: 1.6;
color: #fff;
display: -webkit-box;
-webkit-line-clamp: 7;
-webkit-box-orient: vertical;
overflow: hidden; 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 { .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; display: flex;
flex-direction: column; flex-direction: column;
font-weight: 700; font-size: 1.05rem;
font-weight: 800;
color: #fff; color: #fff;
} }
@@ -3427,8 +3431,27 @@ section[id] {
.rv-author a:hover { color: var(--fitme-accent); } .rv-author a:hover { color: var(--fitme-accent); }
.rv-author small { .rv-author small {
font-size: .78rem;
font-weight: 400; font-weight: 400;
color: var(--fitme-ash); 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 { .reviews-actions {
+9 -7
View File
@@ -501,15 +501,17 @@ if ($reviews):
<div class="reviews-grid"> <div class="reviews-grid">
<?php foreach ($reviews['reviews'] as $r): ?> <?php foreach ($reviews['reviews'] as $r): ?>
<figure class="review-card"> <figure class="review-card">
<div class="rv-stars" role="img" aria-label="<?= (int) $r['rating'] ?> de 5 estrelas"><?= $stars((float) $r['rating']) ?></div> <?php // Reviewer first: big photo (or initial), name, date, stars — then the text, smaller. ?>
<blockquote class="rv-text"><?= nl2br(htmlspecialchars($r['text'])) ?></blockquote> <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"> <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; ?> <?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; ?>
<span> <small><?= htmlspecialchars($r['relative']) ?></small>
<?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> </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> </figure>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
+23 -1
View File
@@ -10,6 +10,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = (string) ($_POST['action'] ?? ''); $action = (string) ($_POST['action'] ?? '');
if ($action === 'save') { if ($action === 'save') {
$before = reviews_config();
// Key: blank keeps the stored one; the "remover" box clears it. // Key: blank keeps the stored one; the "remover" box clears it.
$key = trim((string) ($_POST['google_places_api_key'] ?? '')); $key = trim((string) ($_POST['google_places_api_key'] ?? ''));
if (!empty($_POST['clear_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_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_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); 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.'); session()->setFlashdata('success', 'Definições guardadas.');
header('Location: ' . base_url('views/admin/reviews/index.php')); header('Location: ' . base_url('views/admin/reviews/index.php'));
exit; 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'] ?>"> <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 class="form-text">Cada atualização é um pedido pago à Google.</div>
</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> </div>
<button class="btn btn-primary mt-3">Guardar</button> <button class="btn btn-primary mt-3">Guardar</button>
</form> </form>