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
+101
-37
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_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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user