File: //proc/self/root/tmp/bewertemich-UploadPage-loop.jsx
import React, { useRef, useState, useCallback, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { apiFetch } from '../app/api.js';
import { getCaptchaToken } from '../app/captcha.js';
import { AuthModal } from '../auth/AuthModal.jsx';
import { getSessionId, getStoredInviteAttribution, getUploadEntry, getVisitorId, trackEvent } from '../../utils/tracking.js';
import { getPhotoUrl } from '../../utils/imageUrl.js';
export const UploadPage = () => {
const navigate = useNavigate();
const location = useLocation();
const inputRef = useRef(null);
const [file, setFile] = useState(null);
const [preview, setPreview] = useState('');
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState('');
const [error, setError] = useState('');
const [website, setWebsite] = useState('');
const [authOpen, setAuthOpen] = useState(false);
const lastActionRef = useRef(null); // to retry after auth
const [dragOver, setDragOver] = useState(false);
const [heicSupported, setHeicSupported] = useState(null); // null=unknown, boolean otherwise
const [accepts, setAccepts] = useState('image/jpeg,image/jpg');
const [showcase, setShowcase] = useState([]);
const entryPoint = getUploadEntry(location.search);
const inviteAttribution = getStoredInviteAttribution();
useEffect(() => {
void trackEvent('upload_page_view', {
entryPoint,
source: inviteAttribution?.source,
surface: inviteAttribution?.surface,
meta: inviteAttribution ? { sourcePhotoId: inviteAttribution.sourcePhotoId || '', capturedAt: inviteAttribution.capturedAt || '' } : undefined
});
}, [entryPoint]);
useEffect(() => {
(async () => {
try {
const res = await apiFetch('/api/health');
const j = await res.json().catch(() => ({}));
const heic = !!j?.capabilities?.uploads?.heicSupported;
setHeicSupported(heic);
setAccepts(heic ? 'image/jpeg,image/jpg,image/heic,image/heif' : 'image/jpeg,image/jpg');
} catch {
setHeicSupported(null);
setAccepts('image/jpeg,image/jpg');
}
})();
}, []);
useEffect(() => {
let cancelled = false;
fetch('/api/photos/feed')
.then((res) => res.json())
.then((json) => {
if (!cancelled) setShowcase((json.items || []).slice(0, 4));
})
.catch(() => {});
return () => { cancelled = true; };
}, []);
const pickFile = () => inputRef.current?.click();
const setChosenFile = (f) => {
if (!f) return;
// Prevent selecting HEIC when server cannot handle it
if (heicSupported === false && (f.type === 'image/heic' || f.type === 'image/heif')) {
setFile(null);
setPreview('');
setMsg('');
setError('HEIC wird auf dem Server derzeit nicht unterstützt. Bitte als JPG hochladen.');
return;
}
setFile(f);
setPreview(URL.createObjectURL(f));
setMsg('');
setError('');
void trackEvent('upload_file_selected', {
entryPoint,
meta: {
file_type: f.type || 'unknown',
size_kb: Math.round((Number(f.size) || 0) / 1024)
}
});
};
const onFileChange = (e) => setChosenFile(e.target.files?.[0]);
const onDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
setDragOver(true);
};
const onDragLeave = (e) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
};
const onDrop = (e) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
const f = e.dataTransfer?.files?.[0];
if (f) setChosenFile(f);
};
const doUpload = useCallback(async () => {
if (!file) return;
setBusy(true);
setMsg('');
setError('');
void trackEvent('upload_submit', {
entryPoint,
meta: {
file_type: file.type || 'unknown'
}
});
const fd = new FormData();
fd.append('photo', file);
fd.append('entryPoint', entryPoint);
if (inviteAttribution?.sourcePhotoId) fd.append('sourcePhotoId', inviteAttribution.sourcePhotoId);
if (inviteAttribution?.source) fd.append('inviteSource', inviteAttribution.source);
if (inviteAttribution?.surface) fd.append('inviteSurface', inviteAttribution.surface);
fd.append('visitorId', getVisitorId());
fd.append('sessionId', getSessionId());
if (website) fd.append('website', website);
const captchaToken = await getCaptchaToken('upload_photo');
if (captchaToken) fd.append('captchaToken', captchaToken);
const res = await apiFetch('/api/photos/upload', { method: 'POST', body: fd });
const data = await res.json().catch(() => ({}));
setBusy(false);
if (res.ok) {
setMsg('Upload erfolgreich!');
if (data?.photo_id) navigate(`/upload/success/${data.photo_id}?entry=${encodeURIComponent(entryPoint)}`);
return true;
}
// If unauthorized or CSRF failed, prompt auth
if (res.status === 401 || (res.status === 403 && (data?.error || '').toLowerCase().includes('csrf'))) {
lastActionRef.current = () => doUpload();
setAuthOpen(true);
return false;
}
setError(data.error || 'Fehler beim Upload');
return false;
}, [entryPoint, file, inviteAttribution?.source, inviteAttribution?.sourcePhotoId, inviteAttribution?.surface, navigate]);
const onSubmit = async (e) => {
e.preventDefault();
if (!file) return;
lastActionRef.current = null;
await doUpload();
};
const onAuthSuccess = async () => {
// After auth, retry the last action if any
const act = lastActionRef.current;
setAuthOpen(false);
if (typeof act === 'function') {
await act();
lastActionRef.current = null;
}
};
return (
<div className="mx-auto max-w-5xl px-4 py-10 space-y-6">
<section className="rounded-3xl border bg-gradient-to-br from-slate-900 via-slate-800 to-rose-700 p-6 text-white shadow-xl md:p-8">
<div className="badge border-white/20 bg-white/10 text-white">Schritt 1 von 3</div>
<h1 className="mt-3 text-3xl font-black tracking-tight md:text-4xl">Profilbild hochladen und in Minuten echtes Feedback bekommen</h1>
<p className="mt-3 max-w-3xl text-white/85">
Nach dem Upload bekommst du sofort deinen persönlichen Invite-Link. Der schnellste Weg zur ersten klaren Tendenz sind 5 direkte Einladungen an Kontakte, die ehrlich abstimmen.
</p>
<div className="mt-6 grid gap-3 md:grid-cols-3">
<div className="rounded-2xl bg-white/10 p-4 backdrop-blur">
<div className="text-xs uppercase tracking-[0.2em] text-white/70">1. Hochladen</div>
<div className="mt-2 text-lg font-bold">Foto wählen</div>
<div className="mt-1 text-sm text-white/80">JPG bis 10 MB. Du siehst vor dem Upload eine Vorschau.</div>
</div>
<div className="rounded-2xl bg-white/10 p-4 backdrop-blur">
<div className="text-xs uppercase tracking-[0.2em] text-white/70">2. Invite first</div>
<div className="mt-2 text-lg font-bold">5 Kontakte einladen</div>
<div className="mt-1 text-sm text-white/80">WhatsApp und Telegram funktionieren in der Regel am schnellsten.</div>
</div>
<div className="rounded-2xl bg-white/10 p-4 backdrop-blur">
<div className="text-xs uppercase tracking-[0.2em] text-white/70">3. Auswerten</div>
<div className="mt-2 text-lg font-bold">Erstes Signal sehen</div>
<div className="mt-1 text-sm text-white/80">Ab 5 Stimmen wird die Richtung meist deutlich belastbarer.</div>
</div>
</div>
</section>
<section className="grid gap-6 lg:grid-cols-[1.15fr_0.85fr]">
<form className="space-y-6 rounded-3xl border bg-white p-6 shadow-sm" onSubmit={onSubmit}>
<div>
<h2 className="text-2xl font-bold">Dein Upload</h2>
<p className="mt-2 text-sm text-gray-600">Keine Nacktheit. Bikini- oder Strandfotos sind ok, solange sie nicht explizit sind.</p>
</div>
<div
className={`group relative block w-full rounded-3xl border-2 border-dashed p-6 text-center transition-colors ${dragOver ? 'border-brand bg-pink-50' : 'border-gray-300 hover:border-brand/70'}`}
onClick={pickFile}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
<input
ref={inputRef}
type="file"
className="hidden"
accept={accepts}
onChange={onFileChange}
/>
<div className="flex flex-col items-center justify-center gap-2">
<div className="inline-flex h-14 w-14 items-center justify-center rounded-full bg-gray-100 text-gray-600">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-7 w-7"><path d="M12 16a1 1 0 0 1-1-1V9.41l-1.3 1.3a1 1 0 1 1-1.4-1.42l3-3a1 1 0 0 1 1.4 0l3 3a1 1 0 0 1-1.4 1.42L13 9.4V15a1 1 0 0 1-1 1Z"/><path d="M20 12a1 1 0 0 0-2 0 6 6 0 1 1-6-6 1 1 0 0 0 0-2 8 8 0 1 0 8 8Z"/></svg>
</div>
<p className="text-sm"><span className="font-medium text-brand">Klicke zum Auswählen</span> oder ziehe dein Bild hierher</p>
<p className="text-xs text-gray-500">
{heicSupported === false && 'Nur JPG (HEIC wird nicht unterstützt) • bis 10 MB'}
{heicSupported === true && 'JPG oder HEIC • HEIC wird automatisch in JPG konvertiert • bis 10 MB'}
{heicSupported === null && 'Nur JPG (HEIC wird ggf. konvertiert) • bis 10 MB'}
</p>
</div>
</div>
<div className="absolute left-[-9999px] top-auto h-px w-px overflow-hidden" aria-hidden="true">
<label htmlFor="upload-website">Website</label>
<input
id="upload-website"
type="text"
tabIndex={-1}
autoComplete="off"
value={website}
onChange={(e) => setWebsite(e.target.value)}
/>
</div>
{preview && (
<div className="rounded-3xl border bg-base-100 p-4 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-gray-900">Bereit zum Upload</div>
<div className="text-xs text-gray-500">Nach dem Upload landest du direkt auf deiner Share- und Ergebnis-Seite.</div>
</div>
<div className="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">Vorschau aktiv</div>
</div>
<img src={preview} alt="Preview" className="mt-4 w-full rounded-2xl border" />
</div>
)}
<div className="flex flex-wrap items-center gap-3">
<button className={`btn btn-primary ${busy ? 'loading' : ''}`} disabled={busy || !file}>
{busy ? 'Lädt…' : 'Upload starten'}
</button>
<button type="button" className="btn btn-ghost" onClick={pickFile}>Datei wählen</button>
</div>
{msg && <div className="alert alert-success text-sm">{msg}</div>}
{error && <div className="alert alert-error text-sm">{error}</div>}
</form>
<aside className="space-y-4">
<div className="rounded-3xl border bg-white p-5 shadow-sm">
<h2 className="text-xl font-bold">Was direkt nach dem Upload passiert</h2>
<ul className="mt-4 space-y-3 text-sm text-gray-700">
<li className="flex gap-3"><span className="mt-0.5 h-2.5 w-2.5 rounded-full bg-primary" />Du erhältst einen Link mit echter Bildvorschau für WhatsApp, Telegram und Storys.</li>
<li className="flex gap-3"><span className="mt-0.5 h-2.5 w-2.5 rounded-full bg-secondary" />Du siehst live, wie viele Stimmen schon eingegangen sind.</li>
<li className="flex gap-3"><span className="mt-0.5 h-2.5 w-2.5 rounded-full bg-emerald-500" />Sobald 5 Stimmen da sind, wird die erste Tendenz deutlich belastbarer.</li>
</ul>
</div>
<div className="rounded-3xl border bg-slate-50 p-5 shadow-sm">
<h2 className="text-xl font-bold">Was gut funktioniert</h2>
<div className="mt-4 grid gap-3 text-sm text-gray-700">
<div className="rounded-2xl bg-white p-4 shadow-sm">
<div className="font-semibold">Klares Gesicht oder Oberkörper</div>
<div className="mt-1 text-gray-600">Fotos mit erkennbarem Ausdruck bekommen meist schneller belastbare Bewertungen.</div>
</div>
<div className="rounded-2xl bg-white p-4 shadow-sm">
<div className="font-semibold">Sofort nach Upload teilen</div>
<div className="mt-1 text-gray-600">Invite first schlägt passives Warten fast immer bei den ersten Stimmen.</div>
</div>
<div className="rounded-2xl bg-white p-4 shadow-sm">
<div className="font-semibold">Ein Foto pro Aussage</div>
<div className="mt-1 text-gray-600">Teste lieber mehrere klare Varianten nacheinander statt ein unruhiges Mischbild.</div>
</div>
</div>
</div>
{showcase.length > 0 ? (
<div className="rounded-3xl border bg-white p-5 shadow-sm">
<h2 className="text-xl font-bold">Aktuelle Foto-Referenzen</h2>
<div className="mt-2 text-sm text-gray-600">Ein paar Beispiele aus dem aktuellen Feed. Die Seite wirkt sofort lebendiger, wenn echte Bilder sichtbar sind.</div>
<div className="mt-4 grid grid-cols-2 gap-3">
{showcase.map((item) => (
<img key={item.id} src={getPhotoUrl(item)} alt="Feed Referenz" className="aspect-square w-full rounded-2xl object-cover" />
))}
</div>
</div>
) : null}
</aside>
</section>
<AuthModal open={authOpen} onClose={() => setAuthOpen(false)} onSuccess={onAuthSuccess} />
</div>
);
};