// ─── Kundenbewertungen (AudioFlow review site → app) ────────────────────── // Records match the review site's shape 1:1: // { id, ts, ratings:{sound,condition,advice,handover,price,overall}, average, comment, lang } // Stored in the app under 'rf-reviews'. The review site writes 'audioflow_reviews_v1'; // when both are served from the same origin we auto-merge those in by id. const REVIEW_QIDS = ['sound', 'condition', 'advice', 'handover', 'price', 'overall']; const REVIEW_TITLES = { sound: 'Klangqualität', condition: 'Zustand der Geräte', advice: 'Kommunikation', handover: 'Lieferung & Abholung', price: 'Preis-Leistung', overall: 'Gesamteindruck', }; const REVIEW_SITE_KEY = 'audioflow_reviews_v1'; // Merge helper — dedupes by id, keeps chronological order. function mergeReviews(a = [], b = []) { const dismissed = loadDismissedReviews(); const map = new Map(); [...a, ...b].forEach((r) => { if (r && r.id != null && !dismissed.has(String(r.id))) map.set(String(r.id), r); }); return [...map.values()].sort((x, y) => (x.ts || 0) - (y.ts || 0)); } // Pull whatever the review site left in this browser (same-origin case). function loadSiteReviews() { try { const raw = localStorage.getItem(REVIEW_SITE_KEY); return raw ? JSON.parse(raw) : []; } catch (e) { return []; } } // Permanently dismissed review ids — deleted reviews must never reappear on import/auto-merge. // Server-gespeichert (RFDB), damit ein Löschen auf einem Gerät auf allen Geräten gilt. const REVIEW_DISMISSED_KEY = 'rf-reviews-dismissed'; function loadDismissedReviews() { const v = window.RFDB ? window.RFDB.get(REVIEW_DISMISSED_KEY) : undefined; return new Set(Array.isArray(v) ? v : []); } function addDismissedReview(id) { const s = loadDismissedReviews(); s.add(String(id)); if (window.RFDB) window.RFDB.set(REVIEW_DISMISSED_KEY, [...s]).catch(() => {}); } function reviewStars(n) { // returns 5 booleans for filled stars given a 0..5 value (rounded) const r = Math.round(n); return [1, 2, 3, 4, 5].map((i) => i <= r); } function StarRow({ value, size = 16 }) { return (
{reviewStars(value).map((on, i) => ( ))}
); } // ─── Overview card for the home tab ─────────────────────────────────────── function ReviewsCard({ reviews = [], onOpen }) { const t = useTheme(); const count = reviews.length; const avg = count ? reviews.reduce((a, r) => a + (Number(r.average) || 0), 0) / count : 0; const fmt = (n) => (Math.round(n * 10) / 10).toFixed(1).replace('.', ','); return (
Kundenbewertungen
{count ? ( <>
{fmt(avg)} / 5
{count} {count === 1 ? 'Bewertung' : 'Bewertungen'}
) : (
Noch keine Bewertungen
)}
); } // ─── Detail sheet: per-category analysis + individual submissions + import ── function ReviewsSheet({ open, onClose, reviews = [], setReviews, toast }) { const t = useTheme(); const fileRef = React.useRef(null); const count = reviews.length; const overall = count ? reviews.reduce((a, r) => a + (Number(r.average) || 0), 0) / count : 0; const fmt = (n) => (Math.round(n * 10) / 10).toFixed(1).replace('.', ','); const breakdown = REVIEW_QIDS.map((id) => { const vals = reviews.map((r) => r.ratings && r.ratings[id]).filter((v) => typeof v === 'number'); const avg = vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : 0; return { id, title: REVIEW_TITLES[id], avg, has: vals.length > 0 }; }); const importFromBrowser = () => { const site = loadSiteReviews(); if (!site.length) { toast && toast('Keine Bewertungen in diesem Browser gefunden'); return; } const before = reviews.length; const merged = mergeReviews(reviews, site); setReviews(merged); toast && toast(`${merged.length - before} neue Bewertung(en) übernommen`); }; const importFromFile = (e) => { const file = e.target.files && e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { try { const data = JSON.parse(reader.result); const list = Array.isArray(data) ? data : (data.reviews || []); const before = reviews.length; const merged = mergeReviews(reviews, list); setReviews(merged); toast && toast(`${merged.length - before} neue Bewertung(en) importiert`); } catch (err) { toast && toast('Datei konnte nicht gelesen werden'); } }; reader.readAsText(file); e.target.value = ''; }; const deleteReview = (id) => { if (id != null) addDismissedReview(id); setReviews((prev) => prev.filter((r) => String(r.id) !== String(id))); toast && toast('Bewertung gelöscht'); }; return (
Intern · Übersicht
Bewertungen
{/* Summary */}
Gesamt
{count}
Durchschnitt
{count ? fmt(overall) : '–'} / 5
{/* Per-category breakdown */}
Auswertung pro Kategorie
{breakdown.map((b) => (
{b.title} {b.has ? fmt(b.avg) : '–'}
))}
{/* Individual submissions */}
Einzelne Bewertungen
{count === 0 ? (
Noch keine Bewertungen. Über „Import" unten übernehmen.
) : (
{reviews.slice().reverse().map((r) => (
{new Date(r.ts).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
{fmt(Number(r.average) || 0)} / 5 deleteReview(r.id)} scale={0.85}>
{REVIEW_QIDS.map((id) => ( {REVIEW_TITLES[id]} {(r.ratings && r.ratings[id]) || '–'} ))}
{r.comment ? (

{r.comment}

) : null}
))}
)} {/* Import controls */}
Bewertungen übernehmen
Aus diesem Browser übernehmen
fileRef.current && fileRef.current.click()} scale={0.98}>
JSON-Export importieren

Die Bewertungsseite speichert jede Bewertung. Läuft sie auf derselben Domain wie diese App, werden neue Bewertungen automatisch übernommen — sonst per JSON-Export/-Import oder über eine zentrale Datenbank-Anbindung.

); } Object.assign(window, { ReviewsCard, ReviewsSheet, REVIEW_QIDS, REVIEW_TITLES, mergeReviews, loadSiteReviews, loadDismissedReviews });