HEX
Server: LiteSpeed
System: Linux houston.panomity.com 6.8.0-100-generic #100-Ubuntu SMP PREEMPT_DYNAMIC Tue Jan 13 16:40:06 UTC 2026 x86_64
User: nudepix (1011)
PHP: 7.4.33
Disabled: pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wifcontinued,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_get_handler,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_getpriority,pcntl_setpriority,pcntl_async_signals,pcntl_unshare,
Upload Files
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>
	);
};