File: //tmp/bewertemich-RatePage-loop.jsx
import React, { useEffect, useRef, useState } from 'react';
import { apiFetch } from '../app/api.js';
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
import { ReportButton } from './ReportButton.jsx';
import { buildShareUrl, buildUploadPath, getInviteAttribution, getSessionId, getVisitorId, trackEvent } from '../../utils/tracking.js';
import { buildPhotoInvitePlaybook } from './sharePlaybooks.js';
const voteStreakKey = 'bm_vote_streak';
const readVoteStreak = () => {
if (typeof window === 'undefined') return 0;
try {
return Math.max(0, Number(window.localStorage.getItem(voteStreakKey) || 0));
} catch {
return 0;
}
};
const writeVoteStreak = (value) => {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(voteStreakKey, String(Math.max(0, Number(value || 0))));
} catch {}
};
const copyText = async (text) => {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
};
export const RatePage = () => {
const { id } = useParams();
const routeLocation = useLocation();
const navigate = useNavigate();
const category = (new URLSearchParams(routeLocation.search).get('cat') || 'all').toLowerCase();
const inviteAttribution = getInviteAttribution(routeLocation.search);
const [photo, setPhoto] = useState(null);
const [stats, setStats] = useState({ avg_score: null, votes: 0 });
const [msg, setMsg] = useState('');
const [me, setMe] = useState(null);
const [hasVoted, setHasVoted] = useState(false);
const [voteStreak, setVoteStreak] = useState(() => readVoteStreak());
const wsRef = useRef(null);
const canNativeShare = typeof navigator !== 'undefined' && typeof navigator.share === 'function';
const copyShareUrl = buildShareUrl(id, { source: 'copy', surface: 'rate_page' });
const nativeShareUrl = buildShareUrl(id, { source: 'native', surface: 'rate_page' });
const load = async () => {
if (id === 'random') {
try {
const feed = await fetch('/api/photos/feed', { credentials: 'include' }).then(r => r.json());
const next = (feed.items || []).find((item) => item?.id);
if (next?.id) {
navigate(`/rate/${next.id}?cat=${category}`);
return;
}
} catch {}
setMsg('Kein Foto zum Mitbewerten gefunden.');
return;
}
const p = await fetch(`/api/photos/${id}`).then(r => r.json());
if (p.error) return setMsg('Foto nicht verfügbar');
setPhoto(p.photo);
fetch(`/api/votes/${id}`).then(r => r.json()).then(setStats).catch(() => {});
};
useEffect(() => { load(); }, [id, category]);
useEffect(() => {
setHasVoted(false);
setMsg('');
}, [id, category]);
useEffect(() => {
if (!inviteAttribution.isInvite) return;
void trackEvent('invite_landing_view', {
photoId: Number(id),
source: inviteAttribution.source,
surface: inviteAttribution.surface || 'share',
meta: { category }
});
}, [category, id, inviteAttribution.isInvite, inviteAttribution.source, inviteAttribution.surface]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await apiFetch('/api/auth/me');
if (!cancelled && res.ok) {
const data = await res.json().catch(() => ({}));
setMe(data.user || null);
} else if (!cancelled) {
setMe(null);
}
} catch { if (!cancelled) setMe(null); }
})();
return () => { cancelled = true; };
}, [id]);
useEffect(() => {
if (!id) return;
const wsProto = window.location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${wsProto}://${window.location.host}/ws`);
wsRef.current = ws;
ws.addEventListener('open', () => {
ws.send(JSON.stringify({ type: 'subscribe', photoId: id }));
});
ws.addEventListener('message', (ev) => {
try {
const m = JSON.parse(ev.data);
if (m.type === 'vote_update' && String(m.photoId) === String(id)) {
setStats(m.stats || {});
}
} catch {}
});
return () => { try { ws.close(); } catch {} };
}, [id]);
const goToNext = async (nextFromResponse) => {
const candidate = nextFromResponse ?? null;
if (candidate) {
navigate(`/rate/${candidate}?cat=${category}`);
return;
}
try {
const res = await fetch(`/api/photos/${id}/next?category=${encodeURIComponent(category)}`, { credentials: 'include' });
if (res.ok) {
const data = await res.json();
if (data?.nextPhotoId) {
navigate(`/rate/${data.nextPhotoId}?cat=${category}`);
return;
}
}
setMsg('Kein weiteres Foto in dieser Kategorie.');
} catch {
setMsg('Kein weiteres Foto in dieser Kategorie.');
}
};
const incrementVoteStreak = (score) => {
const next = voteStreak + 1;
setVoteStreak(next);
writeVoteStreak(next);
void trackEvent('vote_streak_progress', {
photoId: Number(id),
source: inviteAttribution.isInvite ? inviteAttribution.source : 'community',
surface: 'rate_page',
entryPoint: category,
meta: { streak: next, score }
});
};
const resetVoteStreak = () => {
setVoteStreak(0);
writeVoteStreak(0);
};
const vote = async (score) => {
setMsg('');
const res = await apiFetch(`/api/votes/${id}?category=${encodeURIComponent(category)}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
score,
visitorId: getVisitorId(),
sessionId: getSessionId(),
source: inviteAttribution.isInvite ? inviteAttribution.source : undefined,
surface: inviteAttribution.isInvite ? inviteAttribution.surface || 'share' : undefined
})
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
setHasVoted(true);
setStats(data.stats || stats);
incrementVoteStreak(score);
await goToNext(data.nextPhotoId);
return;
}
if (res.status === 409 && data.code === 'ALREADY_VOTED') {
setHasVoted(true);
if (data.stats) setStats(data.stats);
setMsg('Du hast dieses Foto bereits bewertet.');
await goToNext(data.nextPhotoId);
return;
}
setMsg(data.error || 'Fehler');
};
const isOwnerOrAdmin = !!(me && photo && (me.is_admin || String(me.id) === String(photo.user_id)));
const setVisibility = async (visible) => {
setMsg('');
let res = await apiFetch(`/api/photos/${id}/visibility`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ visible }),
});
// Fallback: Some servers/WAF block PATCH and return 403 or an HTML error page
if (!res.ok) {
const ct = (res.headers && res.headers.get && res.headers.get('content-type')) || '';
if (res.status === 403 || (typeof ct === 'string' && ct.includes('text/html'))) {
res = await apiFetch(`/api/photos/${id}/visibility/set`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ visible }),
});
}
}
const data = await res.json().catch(() => ({}));
if (!res.ok) return setMsg(data.error || 'Fehler beim Aktualisieren');
setPhoto({ ...photo, status: data.status });
setMsg(visible ? 'Sichtbar gemacht.' : 'Foto versteckt.');
};
const deletePhoto = async () => {
if (!confirm('Foto wirklich löschen? Es wird aus der öffentlichen Ansicht entfernt.')) return;
setMsg('');
let res = await apiFetch(`/api/photos/${id}`, { method: 'DELETE' });
// Fallback: Some servers/WAF block DELETE and return 403 or an HTML error page
if (!res.ok) {
const ct = (res.headers && res.headers.get && res.headers.get('content-type')) || '';
if (res.status === 403 || (typeof ct === 'string' && ct.includes('text/html'))) {
res = await apiFetch(`/api/photos/${id}/delete`, { method: 'POST' });
}
}
const data = await res.json().catch(() => ({}));
if (!res.ok) return setMsg(data.error || 'Fehler beim Löschen');
setPhoto({ ...photo, status: 'removed' });
setMsg('Foto gelöscht (entfernt).');
};
const manualReview = async () => {
setMsg('');
const res = await apiFetch(`/api/reports/${id}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ message: 'Manuelle Überprüfung vom Eigentümer angefordert.' }) });
const data = await res.json().catch(() => ({}));
if (!res.ok) return setMsg(data.error || 'Fehler beim Anfordern der Überprüfung');
setMsg('Manuelle Überprüfung angefordert.');
};
const copyShareLink = async () => {
if (!copyShareUrl) return;
const ok = await copyText(copyShareUrl);
if (ok) {
void trackEvent('share_click', {
photoId: Number(id),
source: 'copy',
surface: 'rate_page'
});
}
setMsg(ok ? 'Einladungslink kopiert.' : 'Kopieren fehlgeschlagen.');
};
const nativeShare = async () => {
if (!canNativeShare || !nativeShareUrl) return;
try {
await navigator.share({
title: 'Bewerte dieses Foto',
text: 'Gib diesem Foto bitte ehrliches Feedback auf bewertemich.de.',
url: nativeShareUrl
});
void trackEvent('share_click', {
photoId: Number(id),
source: 'native',
surface: 'rate_page'
});
} catch {}
};
if (!photo) return <div className="mx-auto max-w-4xl px-4 py-10">{msg || 'Lade…'}</div>;
// If photo is not active, only admins get the full page. Owners see a short notice.
if (photo && photo.status !== 'active' && !me?.is_admin) {
return (
<div className="mx-auto max-w-4xl px-4 py-10">
<div className="text-sm text-gray-700">Foto gelöscht oder versteckt. Es ist nicht öffentlich sichtbar.</div>
<div className="mt-3"><a className="btn btn-ghost border" href={`/u/${me?.username || ''}`}>Zurück zum Profil</a></div>
{msg && <div className="mt-3 text-sm text-gray-700">{msg}</div>}
</div>
);
}
const remainingVotes = Math.max(0, 5 - Number(stats?.votes || 0));
const progressPercent = Math.max(8, Math.min(100, Math.round((Number(stats?.votes || 0) / 5) * 100)));
const avgScore = stats.avg_score ? Number(stats.avg_score) : null;
const sharePreviewUrl = `/s/${id}/og.jpg`;
const sharePlaybook = buildPhotoInvitePlaybook({
photoId: Number(id),
avgScore,
votes: Number(stats?.votes || 0),
surface: 'rate_page'
});
const showVoteToUpload = voteStreak >= 3 && !isOwnerOrAdmin;
return (
<div className="mx-auto max-w-4xl px-4 py-10">
<div className="flex items-center justify-between gap-3 mb-3">
<div className="text-sm text-gray-500">Status: {photo.status}</div>
{isOwnerOrAdmin && (
<div className="flex gap-2">
{photo.status === 'active' ? (
<>
<button className="btn btn-ghost border" onClick={() => setVisibility(false)}>Verstecken</button>
<button className="btn btn-ghost border" onClick={deletePhoto}>Löschen</button>
</>
) : (
<>
{!me?.is_admin ? null : (
<button className="btn btn-ghost border" onClick={() => setVisibility(true)}>Sichtbar machen</button>
)}
<button className="btn btn-ghost border" onClick={manualReview}>Manuelle Überprüfung</button>
<button className="btn btn-ghost border" onClick={deletePhoto}>Löschen</button>
</>
)}
</div>
)}
</div>
{showVoteToUpload ? (
<section className="mb-5 rounded-3xl border border-coral-100 bg-gradient-to-br from-coral-50 to-white p-5 shadow-sm">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-xs font-bold uppercase tracking-[0.22em] text-coral-600">Community-Loop</div>
<h2 className="mt-2 text-2xl font-black text-slate-950">Du hast {voteStreak} Fotos bewertet. Hol dir jetzt Stimmen für dein eigenes Bild.</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">Wer selbst mitbewertet, versteht den Flow sofort und ist eher bereit, den eigenen Upload-Link zu teilen. Starte jetzt deinen Profilbild-Test und lade danach 5 Kontakte ein.</p>
</div>
<div className="flex flex-wrap gap-2 md:justify-end">
<Link
to={buildUploadPath('vote_streak_prompt')}
className="btn border-0 bg-coral-500 text-white hover:bg-coral-600"
onClick={() => {
void trackEvent('vote_to_upload_prompt_click', { source: 'rate_page', surface: 'vote_streak', entryPoint: 'upload', meta: { streak: voteStreak } });
resetVoteStreak();
}}
>
Eigenes Foto testen
</Link>
<button
type="button"
className="btn border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
onClick={() => { void trackEvent('vote_to_upload_prompt_skip', { source: 'rate_page', surface: 'vote_streak', entryPoint: 'continue', meta: { streak: voteStreak } }); resetVoteStreak(); }}
>
Weiter bewerten
</button>
</div>
</div>
</section>
) : null}
<img src={`/api/photos/file/${photo.id}`} alt="Foto" className="rounded-xl border w-full max-h-[70vh] object-contain bg-white" />
<div className="mt-4 flex flex-wrap items-center justify-between gap-4">
<div className="text-gray-700">Ø {stats.avg_score ? Number(stats.avg_score).toFixed(1) : '-'} · {stats.votes} Stimmen</div>
<div className="flex flex-wrap gap-2">
{Array.from({ length: 10 }, (_, i) => i + 1).map(n => (
<button key={n} className="btn btn-ghost border" onClick={() => vote(n)} disabled={hasVoted}>{n}</button>
))}
<ReportButton photoId={photo.id} onDone={() => setMsg('Danke – Bild wurde gemeldet und versteckt.')} />
</div>
</div>
{isOwnerOrAdmin ? (
<div className="mt-6 space-y-4">
<div className="rounded-3xl border bg-gradient-to-br from-slate-900 to-slate-700 p-5 text-white shadow-lg">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-xs uppercase tracking-[0.2em] text-white/60">Owner-Karte</div>
<h2 className="mt-2 text-2xl font-black">{avgScore && Number(stats?.votes || 0) >= 5 ? 'Erstes Ergebnis ist da' : 'Invite first, dann auswerten'}</h2>
<p className="mt-2 max-w-2xl text-sm text-white/80">
{remainingVotes > 0
? `Noch ${remainingVotes} Stimme${remainingVotes === 1 ? '' : 'n'} bis zur ersten klareren Tendenz. Starte mit direkten Kontakten auf WhatsApp und Telegram, bevor du in Gruppen gehst.`
: `Aktueller Schnitt: ${avgScore ? avgScore.toFixed(1) : '-'} bei ${stats.votes} Stimmen. Jetzt kannst du breiter teilen oder ein zweites Bild dagegen testen.`}
</p>
</div>
<div className="min-w-[220px]">
<div className="text-sm text-white/80">Fortschritt bis 5 Stimmen</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-white/15">
<div className="h-full rounded-full bg-white" style={{ width: `${progressPercent}%` }} />
</div>
<div className="mt-2 text-sm text-white/80">{stats.votes} / 5 Stimmen</div>
</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-[1.15fr_0.85fr]">
<div className="rounded-3xl border bg-white p-5 shadow-sm">
<div className="text-xs uppercase tracking-[0.2em] text-gray-500">Share-Playbook</div>
<h2 className="mt-2 text-xl font-bold">Direktkontakte zuerst, Gruppe danach</h2>
<div className="mt-4 space-y-3">
{sharePlaybook.map((action) => (
<div key={action.id} className="rounded-2xl border border-gray-200 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-semibold">{action.label}</div>
<div className="text-xs uppercase tracking-[0.16em] text-gray-400">{action.goal}</div>
</div>
{'copyText' in action ? (
<button
type="button"
className="btn btn-sm btn-ghost border"
onClick={async () => {
const ok = await copyText(action.copyText);
if (ok) {
void trackEvent('share_click', {
photoId: Number(id),
source: action.source,
surface: 'rate_page',
meta: { lane: 'owner_copy' }
});
}
setMsg(ok ? 'Reminder kopiert.' : 'Kopieren fehlgeschlagen.');
}}
>
Kopieren
</button>
) : (
<a
className="btn btn-sm btn-ghost border"
href={action.href}
target="_blank"
rel="noreferrer"
onClick={() => {
void trackEvent('share_click', {
photoId: Number(id),
source: action.source,
surface: 'rate_page',
meta: { lane: 'owner_open' }
}, { transport: 'beacon' });
}}
>
Öffnen
</a>
)}
</div>
<div className="mt-2 text-sm text-gray-600">{action.description}</div>
</div>
))}
</div>
</div>
<div className="rounded-3xl border bg-white p-5 shadow-sm">
<div className="text-xs uppercase tracking-[0.2em] text-gray-500">OG-Karte</div>
<h2 className="mt-2 text-xl font-bold">So sieht dein Share-Preview aus</h2>
<img src={sharePreviewUrl} alt="Share-Preview" className="mt-4 w-full rounded-2xl border shadow-sm" />
</div>
</div>
</div>
) : null}
<div className="mt-6 flex items-center gap-3">
<button className="btn btn-primary" onClick={copyShareLink}>Link kopieren</button>
{canNativeShare ? <button className="btn btn-ghost" onClick={nativeShare}>Direkt teilen</button> : null}
{isOwnerOrAdmin ? <a className="btn btn-ghost" href={`/compare/new?photo=${id}`}>A/B-Vergleich</a> : null}
<a className="btn btn-ghost" href={sharePlaybook[0]?.href} target="_blank" rel="noreferrer" onClick={() => { void trackEvent('share_click', { photoId: Number(id), source: sharePlaybook[0]?.source, surface: 'rate_page' }, { transport: 'beacon' }); }}>WhatsApp direkt</a>
<a className="btn btn-ghost" href={sharePlaybook[1]?.href} target="_blank" rel="noreferrer" onClick={() => { void trackEvent('share_click', { photoId: Number(id), source: sharePlaybook[1]?.source, surface: 'rate_page' }, { transport: 'beacon' }); }}>Telegram direkt</a>
</div>
{msg && <div className="mt-3 text-sm text-gray-700">{msg}</div>}
</div>
);
};