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:
1 parent
f95c9ebc52
commit
1614bf327b
4 files changed
+99
-35
No files matched your search
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user