File: //tmp/bewertemich-ComparePage-loop.jsx
import React, { useEffect, useState } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { apiFetch } from '../app/api.js';
import { buildComparisonShareUrl, getInviteAttribution, getSessionId, getVisitorId, trackEvent } from '../../utils/tracking.js';
import { buildComparisonInvitePlaybook } from './sharePlaybooks.js';
const copyText = async (text) => {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
};
export const ComparePage = () => {
const { id } = useParams();
const location = useLocation();
const [comparison, setComparison] = useState(null);
const [stats, setStats] = useState({ leftVotes: 0, rightVotes: 0, totalVotes: 0 });
const [me, setMe] = useState(null);
const [msg, setMsg] = useState('');
const [busy, setBusy] = useState(false);
const numericId = Number(id);
const inviteAttribution = getInviteAttribution(location.search);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [compareRes, meRes] = await Promise.all([
apiFetch(`/api/comparisons/${numericId}`),
apiFetch('/api/auth/me')
]);
const compareJson = await compareRes.json().catch(() => ({}));
const meJson = await meRes.json().catch(() => ({}));
if (cancelled) return;
if (!compareRes.ok || !compareJson?.comparison) {
setMsg(compareJson.error || 'Vergleich nicht gefunden.');
return;
}
setComparison(compareJson.comparison);
setStats(compareJson.stats || { leftVotes: 0, rightVotes: 0, totalVotes: 0 });
setMe(meJson.user || null);
} catch {
if (!cancelled) setMsg('Vergleich nicht gefunden.');
}
})();
return () => { cancelled = true; };
}, [numericId]);
useEffect(() => {
if (!inviteAttribution.isInvite) return;
void trackEvent('comparison_invite_landing_view', {
source: inviteAttribution.source,
surface: inviteAttribution.surface || 'compare_page',
meta: { comparison_id: numericId }
});
}, [inviteAttribution.isInvite, inviteAttribution.source, inviteAttribution.surface, numericId]);
const vote = async (chosenPhotoId) => {
if (!comparison) return;
setBusy(true);
setMsg('');
try {
const res = await apiFetch(`/api/comparisons/${comparison.id}/vote`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
chosenPhotoId,
visitorId: getVisitorId(),
sessionId: getSessionId(),
source: inviteAttribution.isInvite ? inviteAttribution.source : 'comparison',
surface: inviteAttribution.isInvite ? inviteAttribution.surface || 'compare_page' : 'compare_page'
})
});
const json = await res.json().catch(() => ({}));
if (res.ok || (res.status === 409 && json.code === 'ALREADY_VOTED')) {
setStats(json.stats || stats);
setMsg(res.ok ? 'Danke für deine Auswahl.' : 'Du hast bereits abgestimmt.');
return;
}
throw new Error(json.error || 'Abstimmung fehlgeschlagen.');
} catch (err) {
setMsg(String(err.message || err));
} finally {
setBusy(false);
}
};
if (!comparison) {
return <div className="mx-auto max-w-4xl px-4 py-10">{msg || 'Lade…'}</div>;
}
const isOwner = !!(me && comparison && String(me.id) === String(comparison.user_id));
const leftPercent = stats.totalVotes > 0 ? Math.round((stats.leftVotes / stats.totalVotes) * 100) : 50;
const rightPercent = stats.totalVotes > 0 ? 100 - leftPercent : 50;
const sharePreviewUrl = `/s/compare/${comparison.id}/og.jpg`;
const compareShareUrl = buildComparisonShareUrl(comparison.id, { source: 'copy', surface: 'compare_page' });
const sharePlaybook = buildComparisonInvitePlaybook({
comparisonId: comparison.id,
title: comparison.title,
leftPercent,
rightPercent,
totalVotes: stats.totalVotes,
surface: 'compare_page'
});
const onCopy = async () => {
const ok = await copyText(compareShareUrl);
if (ok) {
void trackEvent('share_click', {
source: 'copy',
surface: 'compare_page',
meta: { comparison_id: comparison.id, lane: 'primary' }
});
}
setMsg(ok ? 'Vergleichslink kopiert.' : 'Kopieren fehlgeschlagen.');
};
return (
<div className="mx-auto max-w-6xl px-4 py-10 space-y-6">
<section className="rounded-3xl border bg-gradient-to-br from-primary to-secondary p-6 text-white shadow-lg">
<div className="badge border-white/20 bg-white/10 text-white">A/B-Vergleich</div>
<h1 className="mt-3 text-3xl font-black tracking-tight">{comparison.title || 'Welches Profilbild wirkt besser?'}</h1>
<p className="mt-2 max-w-3xl text-white/85">
{isOwner
? 'Teile diesen Vergleich zuerst an direkte Kontakte. Das direkte Duell gibt dir schneller eine Entscheidung als ein einzelner Score.'
: `${comparison.display_name} testet zwei Bilder direkt gegeneinander. Wähle das stärkere Foto.`}
</p>
</section>
<section className="grid gap-6 lg:grid-cols-2">
{[
{ key: 'A', photoId: comparison.left_photo_id, votes: stats.leftVotes, percent: leftPercent },
{ key: 'B', photoId: comparison.right_photo_id, votes: stats.rightVotes, percent: rightPercent }
].map((item) => (
<div key={item.key} className="rounded-3xl border bg-white p-4 shadow-sm">
<img src={`/api/photos/file/${item.photoId}`} alt={`Bild ${item.key}`} className="aspect-[4/5] w-full rounded-2xl object-cover" />
<div className="mt-4 flex items-center justify-between gap-3">
<div>
<div className="text-xs uppercase tracking-[0.2em] text-gray-500">Bild {item.key}</div>
<div className="mt-1 text-2xl font-black">{item.percent}%</div>
<div className="text-sm text-gray-500">{item.votes} Stimmen</div>
</div>
{!isOwner ? (
<button type="button" className={`btn btn-primary ${busy ? 'loading' : ''}`} disabled={busy} onClick={() => vote(item.photoId)}>Bild {item.key} wählen</button>
) : null}
</div>
</div>
))}
</section>
<section className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
<div className="rounded-3xl border bg-white p-5 shadow-sm">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-xl font-bold">Zwischenstand</h2>
<div className="mt-2 text-sm text-gray-600">{stats.totalVotes} Gesamtstimmen</div>
</div>
<div className="flex flex-wrap gap-3">
<button type="button" className="btn btn-primary" onClick={onCopy}>Vergleichslink kopieren</button>
{isOwner ? <a className="btn btn-ghost border" href={`/compare/new?photo=${comparison.left_photo_id}`}>Neuen Vergleich starten</a> : null}
</div>
</div>
{msg && <div className="mt-3 text-sm text-gray-700">{msg}</div>}
{isOwner ? (
<div className="mt-5 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', {
source: action.source,
surface: 'compare_page',
meta: { comparison_id: comparison.id, lane: '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', {
source: action.source,
surface: 'compare_page',
meta: { comparison_id: comparison.id, lane: 'open' }
}, { transport: 'beacon' });
}}
>
Öffnen
</a>
)}
</div>
<div className="mt-2 text-sm text-gray-600">{action.description}</div>
</div>
))}
</div>
) : null}
</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">Share-Preview für den Vergleich</h2>
<p className="mt-2 text-sm text-gray-600">
Der Vergleich hat jetzt eine eigene Preview-Karte. Das macht A/B-Links deutlich klarer und klickstaerker in Messengern.
</p>
<img src={sharePreviewUrl} alt="Vergleichs-Preview" className="mt-4 w-full rounded-2xl border shadow-sm" />
</div>
</section>
</div>
);
};