Add live Google Reviews, refreshed daily instead of hand-typed testimonials
The homepage's "reviews" section was never real - a fixed array of 10 hand-written quotes in the translation files plus a manually-typed "5,0 von 181 Kunden bewertet" string, none of it sourced from Google. Wires it to the real thing via the Places API (New), cached in the database and refreshed on a schedule rather than fetched live per visitor - the public /google-reviews endpoint only ever reads the last successfully cached result, so page traffic never touches Google's API or its quota. Mirrors the existing audio4cars price-refresh cron exactly (same bootstrap-config-fetch-store shape): refresh_google_reviews.php reads the Place ID + API key from app_settings, calls GooglePlacesReviewsFetcher, and replaces the google_reviews table wholesale on success; refresh_google_reviews.bat is the Task Scheduler entrypoint. A failed refresh leaves the previous good data in place and only records the error message, so a bad night never blanks the section. Admin config lives in a new "Google-Rezensionen" block on the Website settings page (Place ID + API key, matching the existing GA service-account field's "leave blank to keep" behavior for the key) plus a "Jetzt aktualisieren" button to test immediately rather than wait for the nightly job - both hit the same GoogleReviewsController the cron script's service class does. Home.jsx now fetches /google-reviews and swaps in the live rating and quotes only once at least one fetched review has actual text (Google sometimes returns star-only reviews with no comment, which StarRating's quote cards can't render) - until then, or if nothing is configured yet, it falls back to exactly the previous hardcoded testimonials, so the section never looks broken mid-rollout. TestimonialSlider/StarRating now take a per-review rating instead of always drawing 5 stars. Verified end-to-end except the live Google call itself, which needs a real API key: schema migration, settings save/load (including the key's "blank = unchanged" behavior), the CLI script's two failure paths (unconfigured, and a real Google 400 for an invalid key - confirming the Places API (New) request shape is correct), and the full display path by seeding fake "successful" rows directly - homepage correctly switched to the live rating/quotes and back to the fallback after clearing them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
541339879c
commit
bbaf018a0e
15 changed files with 582 additions and 85 deletions
|
|
@ -124,7 +124,7 @@ export default function TestimonialSlider({ testimonials }) {
|
|||
{track.map((t, i) => (
|
||||
<div key={`${t.name}-${i}`} className="w-64 shrink-0 sm:w-72 lg:w-80">
|
||||
<div className="flex h-full flex-col rounded-xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<StarRating className="mb-3 h-4 w-4" />
|
||||
<StarRating className="mb-3 h-4 w-4" count={t.rating || 5} />
|
||||
<p className="mb-4 flex-1 text-sm text-neutral-600 dark:text-neutral-300">„{t.text}"</p>
|
||||
<p className="text-sm font-semibold text-neutral-900 dark:text-white">{t.name}</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ export default {
|
|||
'Individueller Subwoofer-Einbau mit LED-Beleuchtung im Kofferraum',
|
||||
],
|
||||
reviewsRating: '5,0 von 181 Kunden bewertet',
|
||||
reviewsRatingDynamic: (rating, count) => `${rating.toFixed(1).replace('.', ',')} von ${count} Kunden bewertet`,
|
||||
reviewsText: 'Das sagen unsere Kunden über uns.',
|
||||
reviewsLink: 'Alle Bewertungen auf Google ansehen →',
|
||||
testimonials: [
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ export default {
|
|||
'Custom subwoofer installation with LED lighting in the trunk',
|
||||
],
|
||||
reviewsRating: 'Rated 5.0 by 181 customers',
|
||||
reviewsRatingDynamic: (rating, count) => `Rated ${rating.toFixed(1)} by ${count} customers`,
|
||||
reviewsText: 'Here\'s what our customers say about us.',
|
||||
reviewsLink: 'See all reviews on Google →',
|
||||
testimonials: [
|
||||
|
|
|
|||
|
|
@ -38,6 +38,15 @@ export default function WebsiteSettings() {
|
|||
const [gaError, setGaError] = useState('');
|
||||
const [gaSaved, setGaSaved] = useState(false);
|
||||
|
||||
const [reviewsPlaceId, setReviewsPlaceId] = useState('');
|
||||
const [reviewsApiKey, setReviewsApiKey] = useState('');
|
||||
const [reviewsHasApiKey, setReviewsHasApiKey] = useState(false);
|
||||
const [reviewsStatus, setReviewsStatus] = useState(null); // { rating, rating_count, updated_at, error }
|
||||
const [reviewsSaveBusy, setReviewsSaveBusy] = useState(false);
|
||||
const [reviewsSaveError, setReviewsSaveError] = useState('');
|
||||
const [reviewsSaved, setReviewsSaved] = useState(false);
|
||||
const [reviewsRefreshBusy, setReviewsRefreshBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get('/site-settings')
|
||||
|
|
@ -57,8 +66,51 @@ export default function WebsiteSettings() {
|
|||
setGaPropertyId(res.ga_property_id || '');
|
||||
setGaHasCredentials(res.has_credentials);
|
||||
});
|
||||
loadReviewsStatus();
|
||||
}, []);
|
||||
|
||||
const loadReviewsStatus = () => {
|
||||
api.get('/settings/google-reviews').then((res) => {
|
||||
setReviewsPlaceId(res.place_id || '');
|
||||
setReviewsHasApiKey(res.has_api_key);
|
||||
setReviewsStatus(res);
|
||||
});
|
||||
};
|
||||
|
||||
const handleReviewsSave = async () => {
|
||||
setReviewsSaveBusy(true);
|
||||
setReviewsSaveError('');
|
||||
setReviewsSaved(false);
|
||||
try {
|
||||
const res = await api.post('/settings/google-reviews', {
|
||||
place_id: reviewsPlaceId,
|
||||
api_key: reviewsApiKey,
|
||||
});
|
||||
setReviewsHasApiKey(res.has_api_key);
|
||||
setReviewsStatus(res);
|
||||
setReviewsApiKey('');
|
||||
setReviewsSaved(true);
|
||||
} catch (err) {
|
||||
setReviewsSaveError(err.message);
|
||||
} finally {
|
||||
setReviewsSaveBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReviewsRefresh = async () => {
|
||||
setReviewsRefreshBusy(true);
|
||||
setReviewsSaveError('');
|
||||
try {
|
||||
const res = await api.post('/settings/google-reviews/refresh', {});
|
||||
setReviewsStatus(res);
|
||||
} catch (err) {
|
||||
setReviewsSaveError(err.message);
|
||||
loadReviewsStatus(); // Fehler wurde serverseitig auch in app_settings vermerkt - Status nachladen
|
||||
} finally {
|
||||
setReviewsRefreshBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGaDashboardSave = async () => {
|
||||
setGaBusy(true);
|
||||
setGaError('');
|
||||
|
|
@ -276,6 +328,95 @@ export default function WebsiteSettings() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<h2 className="mb-1 font-semibold text-neutral-900 dark:text-white">Google-Rezensionen</h2>
|
||||
<p className="mb-4 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Zeigt die echte Google-Bewertung und die aktuellsten Rezensionen auf der Startseite an, statt der
|
||||
fest eingetragenen Beispiel-Texte. Wird einmal täglich automatisch aktualisiert (nie bei einem
|
||||
Seitenbesuch selbst) – die Seite zeigt also immer den letzten erfolgreich abgerufenen Stand.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Place-ID</label>
|
||||
<input
|
||||
value={reviewsPlaceId}
|
||||
onChange={(e) => { setReviewsPlaceId(e.target.value); setReviewsSaved(false); }}
|
||||
placeholder="z. B. ChIJN1t_tDeuEmsRUsoyG83frY4"
|
||||
className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-neutral-400">
|
||||
Eindeutige Google-Kennung eures Eintrags – zu finden über den{' '}
|
||||
<a
|
||||
href="https://developers.google.com/maps/documentation/places/web-service/place-id#find-id"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-brand-600"
|
||||
>
|
||||
Google Place ID Finder
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
Places-API-Key {reviewsHasApiKey ? '(leer lassen = unverändert)' : ''}
|
||||
</label>
|
||||
<input
|
||||
value={reviewsApiKey}
|
||||
onChange={(e) => { setReviewsApiKey(e.target.value); setReviewsSaved(false); }}
|
||||
placeholder={reviewsHasApiKey ? '••••••••••••••••••••• (bereits hinterlegt)' : 'AIzaSy…'}
|
||||
className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 font-mono text-sm dark:border-neutral-700 dark:bg-neutral-900"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-neutral-400">
|
||||
Aus der Google-Cloud-Console, mit aktivierter „Places API (New)“.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{reviewsSaveError && <p className="mt-3 text-sm text-red-600">{reviewsSaveError}</p>}
|
||||
{reviewsSaved && <p className="mt-3 text-sm text-green-600 dark:text-green-400">Gespeichert.</p>}
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReviewsSave}
|
||||
disabled={reviewsSaveBusy}
|
||||
className="rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-50 disabled:opacity-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
|
||||
>
|
||||
{reviewsSaveBusy ? 'Speichere…' : 'Google-Rezensionen-Einstellungen speichern'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReviewsRefresh}
|
||||
disabled={reviewsRefreshBusy || !reviewsPlaceId || !reviewsHasApiKey}
|
||||
className="rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600 disabled:opacity-50"
|
||||
>
|
||||
{reviewsRefreshBusy ? 'Aktualisiere…' : 'Jetzt aktualisieren'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{reviewsStatus && (
|
||||
<div className="mt-4 rounded-md bg-neutral-50 p-3 text-sm dark:bg-neutral-800/50">
|
||||
{reviewsStatus.rating != null ? (
|
||||
<p className="text-neutral-700 dark:text-neutral-300">
|
||||
Aktuell hinterlegt: <strong>{reviewsStatus.rating} ★</strong> ({reviewsStatus.rating_count} Bewertungen)
|
||||
{reviewsStatus.updated_at && (
|
||||
<> – zuletzt aktualisiert am {new Date(reviewsStatus.updated_at.replace(' ', 'T')).toLocaleString('de-DE')}</>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-neutral-500 dark:text-neutral-400">
|
||||
Noch keine erfolgreiche Aktualisierung – die Startseite zeigt bis dahin weiter die
|
||||
Beispiel-Rezensionen.
|
||||
</p>
|
||||
)}
|
||||
{reviewsStatus.error && (
|
||||
<p className="mt-1 text-red-600">Letzter Fehler: {reviewsStatus.error}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
{saved && <p className="text-sm text-green-600 dark:text-green-400">Gespeichert.</p>}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,8 +45,18 @@ export default function Home() {
|
|||
const stats = t('home.stats');
|
||||
const galleryAlts = t('home.galleryAlts');
|
||||
const gallery = galleryImages.map((img, i) => ({ ...img, alt: galleryAlts[i] }));
|
||||
const testimonials = t('home.testimonials');
|
||||
const [faqs, setFaqs] = useState([]);
|
||||
// Solange noch keine Google-Rezensionen konfiguriert/erfolgreich abgerufen wurden
|
||||
// (googleReviews === null), zeigt die Seite die festen Beispiel-Texte aus den
|
||||
// Uebersetzungen - danach die echten, im Admin-Bereich zwischengespeicherten Rezensionen.
|
||||
const [googleReviews, setGoogleReviews] = useState(null);
|
||||
const testimonials = googleReviews
|
||||
? googleReviews.reviews
|
||||
// Manche Google-Rezensionen sind reine Sterne-Bewertungen ohne Text - als
|
||||
// Zitat-Karte ohne Zitat waeren die nur eine leere Huelle.
|
||||
.filter((r) => r.review_text)
|
||||
.map((r) => ({ name: r.author_name, text: r.review_text, rating: r.rating }))
|
||||
: t('home.testimonials');
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
|
|
@ -62,6 +72,17 @@ export default function Home() {
|
|||
.catch(() => {});
|
||||
}, [language]);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get('/google-reviews')
|
||||
.then((res) => {
|
||||
// Nur uebernehmen, wenn mindestens eine Rezension auch echten Text hat -
|
||||
// sonst bliebe die Zitat-Karten-Liste leer (siehe testimonials-Filter oben).
|
||||
if (res.rating != null && res.reviews.some((r) => r.review_text)) setGoogleReviews(res);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
usePageMeta({
|
||||
title: t('home.metaTitle'),
|
||||
description: t('home.metaDescription'),
|
||||
|
|
@ -298,9 +319,11 @@ export default function Home() {
|
|||
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
||||
<Reveal className="mb-10 text-center">
|
||||
<div className="mb-2 flex items-center justify-center gap-2">
|
||||
<StarRating className="h-6 w-6" />
|
||||
<StarRating className="h-6 w-6" count={googleReviews ? Math.round(googleReviews.rating) : 5} />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white">{t('home.reviewsRating')}</h2>
|
||||
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
||||
{googleReviews ? t('home.reviewsRatingDynamic')(googleReviews.rating, googleReviews.rating_count) : t('home.reviewsRating')}
|
||||
</h2>
|
||||
<p className="mt-1 text-neutral-600 dark:text-neutral-300">{t('home.reviewsText')}</p>
|
||||
<a
|
||||
href="https://www.google.com/maps/search/?api=1&query=Hifi+Planet+Amorbach+Boxbrunner+Stra%C3%9Fe+20a"
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ use App\Controllers\FaqController;
|
|||
use App\Controllers\GalleryBrandController;
|
||||
use App\Controllers\GalleryPhotoController;
|
||||
use App\Controllers\GalleryProjectController;
|
||||
use App\Controllers\GoogleReviewsController;
|
||||
use App\Controllers\MailSettingsController;
|
||||
use App\Controllers\MaintenanceController;
|
||||
use App\Controllers\ModelController;
|
||||
|
|
@ -161,6 +162,10 @@ $router->post('/faqs', $perm('content.manage', fn($p) => FaqController::store())
|
|||
$router->put('/faqs/{id}', $perm('content.manage', fn($p) => FaqController::update($p)));
|
||||
$router->delete('/faqs/{id}', $perm('content.manage', fn($p) => FaqController::destroy($p)));
|
||||
|
||||
// Nur der zuletzt gecachte Stand - kein Live-Aufruf bei Google pro Seitenbesuch
|
||||
// (Aktualisierung laeuft ausschliesslich ueber refresh_google_reviews.php).
|
||||
$router->get('/google-reviews', fn($p) => GoogleReviewsController::publicReviews());
|
||||
|
||||
$router->get('/admin-users', $perm('users.manage', fn($p) => AdminUserController::index()));
|
||||
$router->post('/admin-users', $perm('users.manage', fn($p) => AdminUserController::store()));
|
||||
$router->put('/admin-users/{id}', $perm('users.manage', fn($p) => AdminUserController::update($p)));
|
||||
|
|
@ -191,6 +196,10 @@ $router->post('/settings/mail/test', $perm('settings.manage', fn($p) => MailSett
|
|||
|
||||
$router->get('/settings/analytics', $perm('settings.manage', fn($p) => AnalyticsController::show()));
|
||||
$router->post('/settings/analytics', $perm('settings.manage', fn($p) => AnalyticsController::update()));
|
||||
|
||||
$router->get('/settings/google-reviews', $perm('settings.manage', fn($p) => GoogleReviewsController::show()));
|
||||
$router->post('/settings/google-reviews', $perm('settings.manage', fn($p) => GoogleReviewsController::update()));
|
||||
$router->post('/settings/google-reviews/refresh', $perm('settings.manage', fn($p) => GoogleReviewsController::refresh()));
|
||||
$router->get('/analytics/report', $admin(fn($p) => AnalyticsController::report()));
|
||||
|
||||
$router->get('/maintenance', fn($p) => MaintenanceController::status());
|
||||
|
|
|
|||
2
hifi/api/scripts/refresh_google_reviews.bat
Normal file
2
hifi/api/scripts/refresh_google_reviews.bat
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
@echo off
|
||||
"C:\xamppHIFI\php\php.exe" "C:\xamppHIFI\htdocs\hifi\api\scripts\refresh_google_reviews.php" >> "C:\xamppHIFI\htdocs\hifi\api\scripts\refresh_google_reviews.log" 2>&1
|
||||
27
hifi/api/scripts/refresh_google_reviews.php
Normal file
27
hifi/api/scripts/refresh_google_reviews.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
use App\Config\Database;
|
||||
use App\Services\GooglePlacesReviewsFetcher;
|
||||
|
||||
$db = Database::connection();
|
||||
$row = $db->query(
|
||||
'SELECT google_place_id, google_places_api_key FROM app_settings WHERE id = 1'
|
||||
)->fetch();
|
||||
|
||||
$placeId = $row['google_place_id'] ?? '';
|
||||
$apiKey = $row['google_places_api_key'] ?? '';
|
||||
|
||||
if ($placeId === '' || $apiKey === '') {
|
||||
echo "Google-Rezensionen-Refresh übersprungen: Place-ID oder API-Key ist im Admin-Bereich noch nicht hinterlegt.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$result = (new GooglePlacesReviewsFetcher())->refreshAndStore($db, $apiKey, $placeId);
|
||||
|
||||
if ($result['ok']) {
|
||||
echo "Google-Rezensionen-Refresh erfolgreich: Bewertung {$result['rating']} ({$result['rating_count']} Bewertungen gesamt), {$result['reviews_count']} Rezensionen gespeichert.\n";
|
||||
} else {
|
||||
echo "Google-Rezensionen-Refresh fehlgeschlagen: {$result['error']}\n";
|
||||
}
|
||||
96
hifi/api/src/Controllers/GoogleReviewsController.php
Normal file
96
hifi/api/src/Controllers/GoogleReviewsController.php
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Config\Database;
|
||||
use App\Services\GooglePlacesReviewsFetcher;
|
||||
use App\Support\Http;
|
||||
|
||||
class GoogleReviewsController
|
||||
{
|
||||
/** Admin: aktuelle Konfiguration + letzter Abruf-Stand (nie den API-Key selbst zurückgeben). */
|
||||
public static function show(): void
|
||||
{
|
||||
$row = self::settingsRow();
|
||||
|
||||
Http::send([
|
||||
'place_id' => $row['google_place_id'] ?? null,
|
||||
'has_api_key' => ($row['google_places_api_key'] ?? '') !== '',
|
||||
'rating' => $row['google_rating'] !== null ? (float) $row['google_rating'] : null,
|
||||
'rating_count' => $row['google_rating_count'] !== null ? (int) $row['google_rating_count'] : null,
|
||||
'updated_at' => $row['google_reviews_updated_at'] ?? null,
|
||||
'error' => $row['google_reviews_error'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Admin: Place-ID + (optional) API-Key speichern. Leeres Key-Feld = bestehenden Key behalten. */
|
||||
public static function update(): void
|
||||
{
|
||||
$body = Http::jsonBody();
|
||||
$placeId = trim($body['place_id'] ?? '');
|
||||
$apiKey = trim($body['api_key'] ?? '');
|
||||
|
||||
$db = Database::connection();
|
||||
$db->exec('INSERT IGNORE INTO app_settings (id) VALUES (1)');
|
||||
|
||||
if ($apiKey !== '') {
|
||||
$stmt = $db->prepare('UPDATE app_settings SET google_place_id = ?, google_places_api_key = ? WHERE id = 1');
|
||||
$stmt->execute([$placeId ?: null, $apiKey]);
|
||||
} else {
|
||||
$stmt = $db->prepare('UPDATE app_settings SET google_place_id = ? WHERE id = 1');
|
||||
$stmt->execute([$placeId ?: null]);
|
||||
}
|
||||
|
||||
self::show();
|
||||
}
|
||||
|
||||
/** Admin: Refresh sofort auslösen (nicht erst auf den naechtlichen Cron warten). */
|
||||
public static function refresh(): void
|
||||
{
|
||||
$row = self::settingsRow();
|
||||
$placeId = $row['google_place_id'] ?? '';
|
||||
$apiKey = $row['google_places_api_key'] ?? '';
|
||||
|
||||
if ($placeId === '' || $apiKey === '') {
|
||||
Http::error('Bitte zuerst Place-ID und API-Key eintragen und speichern.', 422);
|
||||
}
|
||||
|
||||
$result = (new GooglePlacesReviewsFetcher())->refreshAndStore(Database::connection(), $apiKey, $placeId);
|
||||
if (!$result['ok']) {
|
||||
Http::error($result['error'], 502);
|
||||
}
|
||||
|
||||
self::show();
|
||||
}
|
||||
|
||||
/** Öffentlich: was die Startseite anzeigt - ausschließlich der zuletzt erfolgreich gecachte Stand. */
|
||||
public static function publicReviews(): void
|
||||
{
|
||||
$row = self::settingsRow();
|
||||
$rating = $row['google_rating'] !== null ? (float) $row['google_rating'] : null;
|
||||
$ratingCount = $row['google_rating_count'] !== null ? (int) $row['google_rating_count'] : null;
|
||||
|
||||
$reviews = [];
|
||||
if ($rating !== null) {
|
||||
$stmt = Database::connection()->query(
|
||||
'SELECT author_name, profile_photo_url, rating, review_text FROM google_reviews ORDER BY sort_order'
|
||||
);
|
||||
$reviews = $stmt->fetchAll();
|
||||
}
|
||||
|
||||
Http::send(['rating' => $rating, 'rating_count' => $ratingCount, 'reviews' => $reviews]);
|
||||
}
|
||||
|
||||
private static function settingsRow(): array
|
||||
{
|
||||
try {
|
||||
$stmt = Database::connection()->query(
|
||||
'SELECT google_place_id, google_places_api_key, google_rating, google_rating_count, google_reviews_updated_at, google_reviews_error FROM app_settings WHERE id = 1'
|
||||
);
|
||||
return $stmt->fetch() ?: [];
|
||||
} catch (\Throwable $e) {
|
||||
// Spalten fehlen noch (vor Schema-Migration).
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
157
hifi/api/src/Services/GooglePlacesReviewsFetcher.php
Normal file
157
hifi/api/src/Services/GooglePlacesReviewsFetcher.php
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Holt Gesamtbewertung + die (von Google gewaehlten, max. 5) neuesten Rezensionen
|
||||
* eines Google-Business-Eintrags ueber die Places API (New) und cached sie in der
|
||||
* eigenen Datenbank (Tabelle google_reviews + app_settings.google_rating*). Die
|
||||
* oeffentliche Seite liest ausschliesslich aus diesem Cache - nie live von Google,
|
||||
* damit kein Seitenaufruf ein API-Kontingent verbraucht (siehe refresh_google_reviews.php
|
||||
* fuer den geplanten, taeglichen Refresh).
|
||||
*/
|
||||
class GooglePlacesReviewsFetcher
|
||||
{
|
||||
private const ENDPOINT = 'https://places.googleapis.com/v1/places/';
|
||||
private const FIELD_MASK = 'displayName,rating,userRatingCount,reviews';
|
||||
|
||||
private int $timeout;
|
||||
|
||||
public function __construct(int $timeout = 12)
|
||||
{
|
||||
$this->timeout = $timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reiner API-Aufruf ohne Datenbankzugriff - eigenstaendig testbar.
|
||||
* @return array{ok: bool, rating?: float, rating_count?: int, reviews?: array, error?: string}
|
||||
*/
|
||||
public function fetch(string $apiKey, string $placeId): array
|
||||
{
|
||||
$ch = curl_init(self::ENDPOINT . rawurlencode($placeId));
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'X-Goog-Api-Key: ' . $apiKey,
|
||||
'X-Goog-FieldMask: ' . self::FIELD_MASK,
|
||||
'Accept-Language: de',
|
||||
],
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($body === false || $curlError !== '') {
|
||||
return ['ok' => false, 'error' => 'Verbindung zu Google fehlgeschlagen: ' . $curlError];
|
||||
}
|
||||
|
||||
$json = json_decode($body, true);
|
||||
|
||||
if ($status !== 200) {
|
||||
$message = is_array($json) ? ($json['error']['message'] ?? null) : null;
|
||||
return ['ok' => false, 'error' => "Google-Antwort HTTP $status" . ($message ? ": $message" : '')];
|
||||
}
|
||||
|
||||
if (!is_array($json)) {
|
||||
return ['ok' => false, 'error' => 'Ungültige Antwort von Google (kein JSON)'];
|
||||
}
|
||||
|
||||
$reviews = [];
|
||||
foreach ((array) ($json['reviews'] ?? []) as $review) {
|
||||
$reviews[] = [
|
||||
'google_review_id' => is_string($review['name'] ?? null) ? $review['name'] : null,
|
||||
'author_name' => (string) ($review['authorAttribution']['displayName'] ?? 'Google-Nutzer'),
|
||||
'profile_photo_url' => $review['authorAttribution']['photoUri'] ?? null,
|
||||
'rating' => (int) ($review['rating'] ?? 5),
|
||||
'review_text' => $review['text']['text'] ?? ($review['originalText']['text'] ?? null),
|
||||
'relative_time_description' => $review['relativePublishTimeDescription'] ?? null,
|
||||
'review_time' => isset($review['publishTime']) ? strtotime((string) $review['publishTime']) ?: null : null,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'rating' => isset($json['rating']) ? (float) $json['rating'] : null,
|
||||
'rating_count' => isset($json['userRatingCount']) ? (int) $json['userRatingCount'] : null,
|
||||
'reviews' => $reviews,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft fetch() auf und schreibt das Ergebnis in die Datenbank. Bei einem Fehler
|
||||
* bleiben zuvor erfolgreich geladene Rezensionen unangetastet (die Seite zeigt
|
||||
* dann weiter den letzten guten Stand) - nur die Fehlermeldung wird vermerkt,
|
||||
* damit sie im Admin-Bereich sichtbar ist.
|
||||
* @return array{ok: bool, error?: string, rating?: float, rating_count?: int, reviews_count?: int}
|
||||
*/
|
||||
public function refreshAndStore(PDO $db, string $apiKey, string $placeId): array
|
||||
{
|
||||
if ($apiKey === '' || $placeId === '') {
|
||||
$error = 'Kein API-Key oder keine Place-ID hinterlegt';
|
||||
$this->recordError($db, $error);
|
||||
return ['ok' => false, 'error' => $error];
|
||||
}
|
||||
|
||||
$result = $this->fetch($apiKey, $placeId);
|
||||
if (!$result['ok']) {
|
||||
$this->recordError($db, $result['error']);
|
||||
return $result;
|
||||
}
|
||||
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$db->exec('DELETE FROM google_reviews');
|
||||
$stmt = $db->prepare(
|
||||
'INSERT INTO google_reviews
|
||||
(google_review_id, author_name, profile_photo_url, rating, review_text, relative_time_description, review_time, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
foreach ($result['reviews'] as $i => $review) {
|
||||
$stmt->execute([
|
||||
$review['google_review_id'],
|
||||
$review['author_name'],
|
||||
$review['profile_photo_url'],
|
||||
$review['rating'],
|
||||
$review['review_text'],
|
||||
$review['relative_time_description'],
|
||||
$review['review_time'],
|
||||
$i,
|
||||
]);
|
||||
}
|
||||
|
||||
$db->exec('INSERT IGNORE INTO app_settings (id) VALUES (1)');
|
||||
$update = $db->prepare(
|
||||
'UPDATE app_settings
|
||||
SET google_rating = ?, google_rating_count = ?, google_reviews_updated_at = NOW(), google_reviews_error = NULL
|
||||
WHERE id = 1'
|
||||
);
|
||||
$update->execute([$result['rating'], $result['rating_count']]);
|
||||
|
||||
$db->commit();
|
||||
} catch (\Throwable $e) {
|
||||
$db->rollBack();
|
||||
$error = 'Speichern fehlgeschlagen: ' . $e->getMessage();
|
||||
$this->recordError($db, $error);
|
||||
return ['ok' => false, 'error' => $error];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'rating' => $result['rating'],
|
||||
'rating_count' => $result['rating_count'],
|
||||
'reviews_count' => count($result['reviews']),
|
||||
];
|
||||
}
|
||||
|
||||
private function recordError(PDO $db, string $error): void
|
||||
{
|
||||
$db->exec('INSERT IGNORE INTO app_settings (id) VALUES (1)');
|
||||
$stmt = $db->prepare('UPDATE app_settings SET google_reviews_error = ? WHERE id = 1');
|
||||
$stmt->execute([$error]);
|
||||
}
|
||||
}
|
||||
|
|
@ -37,6 +37,12 @@ class Schema
|
|||
hero_image_path VARCHAR(255) NULL,
|
||||
ga_measurement_id VARCHAR(20) NULL,
|
||||
ga_property_id VARCHAR(30) NULL,
|
||||
google_place_id VARCHAR(255) NULL,
|
||||
google_places_api_key VARCHAR(255) NULL,
|
||||
google_rating DECIMAL(2,1) NULL,
|
||||
google_rating_count INT NULL,
|
||||
google_reviews_updated_at DATETIME NULL,
|
||||
google_reviews_error VARCHAR(500) NULL,
|
||||
package_card_theme VARCHAR(30) NOT NULL DEFAULT 'graphite',
|
||||
package_card_layout VARCHAR(20) NOT NULL DEFAULT 'strip',
|
||||
mail_host VARCHAR(255) NULL,
|
||||
|
|
@ -231,6 +237,22 @@ class Schema
|
|||
CONSTRAINT fk_contact_package FOREIGN KEY (package_id) REFERENCES packages(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_contact_product FOREIGN KEY (package_product_id) REFERENCES package_products(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB",
|
||||
|
||||
// Reiner Cache der letzten Google-Places-Abfrage (siehe GooglePlacesReviewsFetcher) -
|
||||
// wird bei jedem erfolgreichen Refresh komplett neu befuellt (DELETE + INSERT),
|
||||
// keine eigenen Fremdschluessel-Bezuege von aussen.
|
||||
'google_reviews' => "CREATE TABLE google_reviews (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
google_review_id VARCHAR(255) NULL,
|
||||
author_name VARCHAR(150) NOT NULL,
|
||||
profile_photo_url VARCHAR(500) NULL,
|
||||
rating TINYINT NOT NULL,
|
||||
review_text TEXT NULL,
|
||||
relative_time_description VARCHAR(100) NULL,
|
||||
review_time INT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB",
|
||||
];
|
||||
|
||||
// Erwartete Spalten je Tabelle (ohne Primary Key / Unique / Foreign-Key-Klauseln –
|
||||
|
|
@ -263,6 +285,12 @@ class Schema
|
|||
'hero_image_path' => 'VARCHAR(255) NULL',
|
||||
'ga_measurement_id' => 'VARCHAR(20) NULL',
|
||||
'ga_property_id' => 'VARCHAR(30) NULL',
|
||||
'google_place_id' => 'VARCHAR(255) NULL',
|
||||
'google_places_api_key' => 'VARCHAR(255) NULL',
|
||||
'google_rating' => 'DECIMAL(2,1) NULL',
|
||||
'google_rating_count' => 'INT NULL',
|
||||
'google_reviews_updated_at' => 'DATETIME NULL',
|
||||
'google_reviews_error' => 'VARCHAR(500) NULL',
|
||||
'package_card_theme' => "VARCHAR(30) NOT NULL DEFAULT 'graphite'",
|
||||
'package_card_layout' => "VARCHAR(20) NOT NULL DEFAULT 'strip'",
|
||||
'mail_host' => 'VARCHAR(255) NULL',
|
||||
|
|
@ -424,5 +452,17 @@ class Schema
|
|||
'status' => "ENUM('new','in_progress','done') NOT NULL DEFAULT 'new'",
|
||||
'created_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP',
|
||||
],
|
||||
'google_reviews' => [
|
||||
'id' => 'INT AUTO_INCREMENT PRIMARY KEY',
|
||||
'google_review_id' => 'VARCHAR(255) NULL',
|
||||
'author_name' => 'VARCHAR(150) NOT NULL',
|
||||
'profile_photo_url' => 'VARCHAR(500) NULL',
|
||||
'rating' => 'TINYINT NOT NULL',
|
||||
'review_text' => 'TEXT NULL',
|
||||
'relative_time_description' => 'VARCHAR(100) NULL',
|
||||
'review_time' => 'INT NULL',
|
||||
'sort_order' => 'INT NOT NULL DEFAULT 0',
|
||||
'created_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
78
hifi/assets/index-S937Oaaw.js
Normal file
78
hifi/assets/index-S937Oaaw.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -43,9 +43,9 @@
|
|||
"sameAs": ["https://www.youtube.com/@hifiplanet2812"]
|
||||
}
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-KOjZhXM_.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-S937Oaaw.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/lucide-icons-66ioQXWI.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C78wIVRz.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-oq69fOHH.css">
|
||||
</head>
|
||||
<body class="bg-white text-slate-900 dark:bg-slate-950 dark:text-slate-100">
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue