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:
Maaxxs 2026-07-21 01:40:08 +02:00
parent 541339879c
commit bbaf018a0e
15 changed files with 582 additions and 85 deletions

View file

@ -124,7 +124,7 @@ export default function TestimonialSlider({ testimonials }) {
{track.map((t, i) => ( {track.map((t, i) => (
<div key={`${t.name}-${i}`} className="w-64 shrink-0 sm:w-72 lg:w-80"> <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"> <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="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> <p className="text-sm font-semibold text-neutral-900 dark:text-white">{t.name}</p>
</div> </div>

View file

@ -103,6 +103,7 @@ export default {
'Individueller Subwoofer-Einbau mit LED-Beleuchtung im Kofferraum', 'Individueller Subwoofer-Einbau mit LED-Beleuchtung im Kofferraum',
], ],
reviewsRating: '5,0 von 181 Kunden bewertet', 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.', reviewsText: 'Das sagen unsere Kunden über uns.',
reviewsLink: 'Alle Bewertungen auf Google ansehen →', reviewsLink: 'Alle Bewertungen auf Google ansehen →',
testimonials: [ testimonials: [

View file

@ -103,6 +103,7 @@ export default {
'Custom subwoofer installation with LED lighting in the trunk', 'Custom subwoofer installation with LED lighting in the trunk',
], ],
reviewsRating: 'Rated 5.0 by 181 customers', 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.', reviewsText: 'Here\'s what our customers say about us.',
reviewsLink: 'See all reviews on Google →', reviewsLink: 'See all reviews on Google →',
testimonials: [ testimonials: [

View file

@ -38,6 +38,15 @@ export default function WebsiteSettings() {
const [gaError, setGaError] = useState(''); const [gaError, setGaError] = useState('');
const [gaSaved, setGaSaved] = useState(false); 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(() => { useEffect(() => {
api api
.get('/site-settings') .get('/site-settings')
@ -57,8 +66,51 @@ export default function WebsiteSettings() {
setGaPropertyId(res.ga_property_id || ''); setGaPropertyId(res.ga_property_id || '');
setGaHasCredentials(res.has_credentials); 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 () => { const handleGaDashboardSave = async () => {
setGaBusy(true); setGaBusy(true);
setGaError(''); setGaError('');
@ -276,6 +328,95 @@ export default function WebsiteSettings() {
</div> </div>
</section> </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>} {error && <p className="text-sm text-red-600">{error}</p>}
{saved && <p className="text-sm text-green-600 dark:text-green-400">Gespeichert.</p>} {saved && <p className="text-sm text-green-600 dark:text-green-400">Gespeichert.</p>}

View file

@ -45,8 +45,18 @@ export default function Home() {
const stats = t('home.stats'); const stats = t('home.stats');
const galleryAlts = t('home.galleryAlts'); const galleryAlts = t('home.galleryAlts');
const gallery = galleryImages.map((img, i) => ({ ...img, alt: galleryAlts[i] })); const gallery = galleryImages.map((img, i) => ({ ...img, alt: galleryAlts[i] }));
const testimonials = t('home.testimonials');
const [faqs, setFaqs] = useState([]); 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(() => { useEffect(() => {
api api
@ -62,6 +72,17 @@ export default function Home() {
.catch(() => {}); .catch(() => {});
}, [language]); }, [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({ usePageMeta({
title: t('home.metaTitle'), title: t('home.metaTitle'),
description: t('home.metaDescription'), description: t('home.metaDescription'),
@ -298,9 +319,11 @@ export default function Home() {
<div className="mx-auto max-w-6xl px-4 sm:px-6"> <div className="mx-auto max-w-6xl px-4 sm:px-6">
<Reveal className="mb-10 text-center"> <Reveal className="mb-10 text-center">
<div className="mb-2 flex items-center justify-center gap-2"> <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> </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> <p className="mt-1 text-neutral-600 dark:text-neutral-300">{t('home.reviewsText')}</p>
<a <a
href="https://www.google.com/maps/search/?api=1&query=Hifi+Planet+Amorbach+Boxbrunner+Stra%C3%9Fe+20a" href="https://www.google.com/maps/search/?api=1&query=Hifi+Planet+Amorbach+Boxbrunner+Stra%C3%9Fe+20a"

View file

@ -18,6 +18,7 @@ use App\Controllers\FaqController;
use App\Controllers\GalleryBrandController; use App\Controllers\GalleryBrandController;
use App\Controllers\GalleryPhotoController; use App\Controllers\GalleryPhotoController;
use App\Controllers\GalleryProjectController; use App\Controllers\GalleryProjectController;
use App\Controllers\GoogleReviewsController;
use App\Controllers\MailSettingsController; use App\Controllers\MailSettingsController;
use App\Controllers\MaintenanceController; use App\Controllers\MaintenanceController;
use App\Controllers\ModelController; 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->put('/faqs/{id}', $perm('content.manage', fn($p) => FaqController::update($p)));
$router->delete('/faqs/{id}', $perm('content.manage', fn($p) => FaqController::destroy($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->get('/admin-users', $perm('users.manage', fn($p) => AdminUserController::index()));
$router->post('/admin-users', $perm('users.manage', fn($p) => AdminUserController::store())); $router->post('/admin-users', $perm('users.manage', fn($p) => AdminUserController::store()));
$router->put('/admin-users/{id}', $perm('users.manage', fn($p) => AdminUserController::update($p))); $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->get('/settings/analytics', $perm('settings.manage', fn($p) => AnalyticsController::show()));
$router->post('/settings/analytics', $perm('settings.manage', fn($p) => AnalyticsController::update())); $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('/analytics/report', $admin(fn($p) => AnalyticsController::report()));
$router->get('/maintenance', fn($p) => MaintenanceController::status()); $router->get('/maintenance', fn($p) => MaintenanceController::status());

View 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

View 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";
}

View 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 [];
}
}
}

View 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]);
}
}

View file

@ -37,6 +37,12 @@ class Schema
hero_image_path VARCHAR(255) NULL, hero_image_path VARCHAR(255) NULL,
ga_measurement_id VARCHAR(20) NULL, ga_measurement_id VARCHAR(20) NULL,
ga_property_id VARCHAR(30) 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_theme VARCHAR(30) NOT NULL DEFAULT 'graphite',
package_card_layout VARCHAR(20) NOT NULL DEFAULT 'strip', package_card_layout VARCHAR(20) NOT NULL DEFAULT 'strip',
mail_host VARCHAR(255) NULL, 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_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 CONSTRAINT fk_contact_product FOREIGN KEY (package_product_id) REFERENCES package_products(id) ON DELETE SET NULL
) ENGINE=InnoDB", ) 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 // Erwartete Spalten je Tabelle (ohne Primary Key / Unique / Foreign-Key-Klauseln
@ -263,6 +285,12 @@ class Schema
'hero_image_path' => 'VARCHAR(255) NULL', 'hero_image_path' => 'VARCHAR(255) NULL',
'ga_measurement_id' => 'VARCHAR(20) NULL', 'ga_measurement_id' => 'VARCHAR(20) NULL',
'ga_property_id' => 'VARCHAR(30) 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_theme' => "VARCHAR(30) NOT NULL DEFAULT 'graphite'",
'package_card_layout' => "VARCHAR(20) NOT NULL DEFAULT 'strip'", 'package_card_layout' => "VARCHAR(20) NOT NULL DEFAULT 'strip'",
'mail_host' => 'VARCHAR(255) NULL', 'mail_host' => 'VARCHAR(255) NULL',
@ -424,5 +452,17 @@ class Schema
'status' => "ENUM('new','in_progress','done') NOT NULL DEFAULT 'new'", 'status' => "ENUM('new','in_progress','done') NOT NULL DEFAULT 'new'",
'created_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP', '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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -43,9 +43,9 @@
"sameAs": ["https://www.youtube.com/@hifiplanet2812"] "sameAs": ["https://www.youtube.com/@hifiplanet2812"]
} }
</script> </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="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> </head>
<body class="bg-white text-slate-900 dark:bg-slate-950 dark:text-slate-100"> <body class="bg-white text-slate-900 dark:bg-slate-950 dark:text-slate-100">
<div id="root"></div> <div id="root"></div>