Added opaque join-token support across backend and frontend: new migration/model/service/endpoints, guest controllers now resolve tokens, and the demo seeder seeds a token. Tenant event details list/manage tokens with copy/revoke actions, and the guest PWA uses tokens end-to-end (routing, storage, uploads, achievements, etc.). Docs TODO updated to reflect completed steps.
This commit is contained in:
@@ -292,7 +292,7 @@ function PersonalActions({ slug }: { slug: string }) {
|
||||
}
|
||||
|
||||
export default function AchievementsPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { token: slug } = useParams<{ token: string }>();
|
||||
const identity = useGuestIdentity();
|
||||
const [data, setData] = useState<AchievementsPayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -12,8 +12,8 @@ import PhotoLightbox from './PhotoLightbox';
|
||||
import { fetchEvent, getEventPackage, fetchStats, type EventData, type EventPackage, type EventStats } from '../services/eventApi';
|
||||
|
||||
export default function GalleryPage() {
|
||||
const { slug } = useParams();
|
||||
const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(slug!);
|
||||
const { token: slug } = useParams<{ token?: string }>();
|
||||
const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(slug ?? '');
|
||||
const [filter, setFilter] = React.useState<GalleryFilter>('latest');
|
||||
const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState<number | null>(null);
|
||||
const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false);
|
||||
@@ -99,6 +99,10 @@ export default function GalleryPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!slug) {
|
||||
return <Page title="Galerie"><p>Event nicht gefunden.</p></Page>;
|
||||
}
|
||||
|
||||
if (eventLoading) {
|
||||
return <Page title="Galerie"><p>Lade Event-Info...</p></Page>;
|
||||
}
|
||||
|
||||
@@ -12,13 +12,13 @@ import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
|
||||
import { Sparkles, UploadCloud, Images, CheckCircle2, Users, TimerReset } from 'lucide-react';
|
||||
|
||||
export default function HomePage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const { name, hydrated } = useGuestIdentity();
|
||||
const stats = useEventStats();
|
||||
const { event } = useEventData();
|
||||
const { completedCount } = useGuestTaskProgress(slug);
|
||||
const { completedCount } = useGuestTaskProgress(token);
|
||||
|
||||
if (!slug) return null;
|
||||
if (!token) return null;
|
||||
|
||||
const displayName = hydrated && name ? name : 'Gast';
|
||||
const latestUploadText = formatLatestUpload(stats.latestPhotoAt);
|
||||
@@ -125,7 +125,7 @@ export default function HomePage() {
|
||||
|
||||
<EmotionPicker />
|
||||
|
||||
<GalleryPreview slug={slug} />
|
||||
<GalleryPreview slug={token} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,28 +10,58 @@ import { readGuestName } from '../context/GuestIdentityContext';
|
||||
|
||||
export default function LandingPage() {
|
||||
const nav = useNavigate();
|
||||
const [slug, setSlug] = useState('');
|
||||
const [eventCode, setEventCode] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [scanner, setScanner] = useState<Html5Qrcode | null>(null);
|
||||
|
||||
async function join(eventSlug?: string) {
|
||||
const s = (eventSlug ?? slug).trim();
|
||||
if (!s) return;
|
||||
function extractEventKey(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
const inviteParam = url.searchParams.get('invite') ?? url.searchParams.get('token');
|
||||
if (inviteParam) {
|
||||
return inviteParam;
|
||||
}
|
||||
const segments = url.pathname.split('/').filter(Boolean);
|
||||
const eventIndex = segments.findIndex((segment) => segment === 'e');
|
||||
if (eventIndex >= 0 && segments.length > eventIndex + 1) {
|
||||
return decodeURIComponent(segments[eventIndex + 1]);
|
||||
}
|
||||
if (segments.length > 0) {
|
||||
return decodeURIComponent(segments[segments.length - 1]);
|
||||
}
|
||||
} catch {
|
||||
// Not a URL, treat as raw code
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
async function join(input?: string) {
|
||||
const provided = input ?? eventCode;
|
||||
const normalized = extractEventKey(provided);
|
||||
if (!normalized) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(s)}`);
|
||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(normalized)}`);
|
||||
if (!res.ok) {
|
||||
setError('Event nicht gefunden oder geschlossen.');
|
||||
return;
|
||||
}
|
||||
const storedName = readGuestName(s);
|
||||
const data = await res.json();
|
||||
const targetKey = data.join_token ?? data.slug ?? normalized;
|
||||
const storedName = readGuestName(targetKey);
|
||||
if (!storedName) {
|
||||
nav(`/setup/${encodeURIComponent(s)}`);
|
||||
nav(`/setup/${encodeURIComponent(targetKey)}`);
|
||||
} else {
|
||||
nav(`/e/${encodeURIComponent(s)}`);
|
||||
nav(`/e/${encodeURIComponent(targetKey)}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Join request failed', e);
|
||||
@@ -136,14 +166,14 @@ export default function LandingPage() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={slug}
|
||||
onChange={(event) => setSlug(event.target.value)}
|
||||
value={eventCode}
|
||||
onChange={(event) => setEventCode(event.target.value)}
|
||||
placeholder="Event-Code eingeben"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
className="w-full bg-gradient-to-r from-pink-500 to-pink-600 text-white hover:from-pink-600 hover:to-pink-700"
|
||||
disabled={loading || !slug.trim()}
|
||||
disabled={loading || !eventCode.trim()}
|
||||
onClick={() => join()}
|
||||
>
|
||||
{loading ? 'Pruefe...' : 'Event beitreten'}
|
||||
|
||||
@@ -13,12 +13,13 @@ export default function LegalPage() {
|
||||
if (!page) {
|
||||
return;
|
||||
}
|
||||
const slug = page;
|
||||
const controller = new AbortController();
|
||||
|
||||
async function loadLegal() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch(`/api/v1/legal/${encodeURIComponent(page)}?lang=de`, {
|
||||
const res = await fetch(`/api/v1/legal/${encodeURIComponent(slug)}?lang=de`, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
@@ -45,8 +46,10 @@ export default function LegalPage() {
|
||||
return () => controller.abort();
|
||||
}, [page]);
|
||||
|
||||
const fallbackTitle = page ? `Rechtliches: ${page}` : 'Rechtliche Informationen';
|
||||
|
||||
return (
|
||||
<Page title={title || `Rechtliches: ${page}` }>
|
||||
<Page title={title || fallbackTitle}>
|
||||
{loading ? <p>Laedt...</p> : <LegalMarkdown markdown={body} />}
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -26,11 +26,11 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexChange, slug }: Props) {
|
||||
const params = useParams();
|
||||
const params = useParams<{ token?: string; photoId?: string }>();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const photoId = params.photoId;
|
||||
const eventSlug = params.slug || slug;
|
||||
const eventSlug = params.token || slug;
|
||||
|
||||
const [standalonePhoto, setStandalonePhoto] = useState<Photo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -129,9 +129,9 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
||||
};
|
||||
|
||||
|
||||
// Load task info if photo has task_id and slug is available
|
||||
// Load task info if photo has task_id and event key is available
|
||||
React.useEffect(() => {
|
||||
if (!photo?.task_id || !slug) {
|
||||
if (!photo?.task_id || !eventSlug) {
|
||||
setTask(null);
|
||||
setTaskLoading(false);
|
||||
return;
|
||||
@@ -142,7 +142,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
||||
(async () => {
|
||||
setTaskLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/events/${slug}/tasks`);
|
||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventSlug)}/tasks`);
|
||||
if (res.ok) {
|
||||
const tasks = await res.json();
|
||||
const foundTask = tasks.find((t: any) => t.id === taskId);
|
||||
@@ -173,7 +173,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
||||
setTaskLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [photo?.task_id, slug]);
|
||||
}, [photo?.task_id, eventSlug]);
|
||||
|
||||
async function onLike() {
|
||||
if (liked || !photo) return;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Label } from '@/components/ui/label';
|
||||
import Header from '../components/Header';
|
||||
|
||||
export default function ProfileSetupPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const nav = useNavigate();
|
||||
const { event, loading, error } = useEventData();
|
||||
const { name: storedName, setName: persistName, hydrated } = useGuestIdentity();
|
||||
@@ -17,11 +17,11 @@ export default function ProfileSetupPage() {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) {
|
||||
if (!token) {
|
||||
nav('/');
|
||||
return;
|
||||
}
|
||||
}, [slug, nav]);
|
||||
}, [token, nav]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hydrated) {
|
||||
@@ -34,14 +34,14 @@ export default function ProfileSetupPage() {
|
||||
}
|
||||
|
||||
function submitName() {
|
||||
if (!slug) return;
|
||||
if (!token) return;
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
persistName(trimmedName);
|
||||
nav(`/e/${slug}`);
|
||||
nav(`/e/${token}`);
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Speichern des Namens:', e);
|
||||
setSubmitting(false);
|
||||
@@ -67,7 +67,7 @@ export default function ProfileSetupPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-pink-50 to-purple-50 flex flex-col">
|
||||
<Header slug={slug!} />
|
||||
<Header slug={token!} />
|
||||
<div className="flex-1 flex flex-col justify-center items-center px-4 py-8">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center space-y-2">
|
||||
|
||||
@@ -28,11 +28,12 @@ const TASK_PROGRESS_TARGET = 5;
|
||||
const TIMER_VIBRATION = [0, 60, 120, 60];
|
||||
|
||||
export default function TaskPickerPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { token: slug } = useParams<{ token: string }>();
|
||||
const eventKey = slug ?? '';
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const { completedCount, markCompleted, isCompleted } = useGuestTaskProgress(slug);
|
||||
const { completedCount, markCompleted, isCompleted } = useGuestTaskProgress(eventKey);
|
||||
|
||||
const [tasks, setTasks] = React.useState<Task[]>([]);
|
||||
const [currentTask, setCurrentTask] = React.useState<Task | null>(null);
|
||||
@@ -48,12 +49,12 @@ export default function TaskPickerPage() {
|
||||
const initialEmotionRef = React.useRef(false);
|
||||
|
||||
const fetchTasks = React.useCallback(async () => {
|
||||
if (!slug) return;
|
||||
if (!eventKey) return;
|
||||
setIsFetching(true);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/tasks`);
|
||||
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/tasks`);
|
||||
if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.');
|
||||
const payload = await response.json();
|
||||
if (Array.isArray(payload)) {
|
||||
@@ -69,7 +70,7 @@ export default function TaskPickerPage() {
|
||||
setIsFetching(false);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [slug]);
|
||||
}, [eventKey]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchTasks();
|
||||
@@ -206,8 +207,9 @@ export default function TaskPickerPage() {
|
||||
};
|
||||
|
||||
const handleStartUpload = () => {
|
||||
if (!currentTask || !slug) return;
|
||||
navigate(`/e/${encodeURIComponent(slug)}/upload?task=${currentTask.id}&emotion=${currentTask.emotion?.slug || ''}`);
|
||||
if (!currentTask || !eventKey) return;
|
||||
if (!eventKey) return;
|
||||
navigate(`/e/${encodeURIComponent(eventKey)}/upload?task=${currentTask.id}&emotion=${currentTask.emotion?.slug || ''}`);
|
||||
};
|
||||
|
||||
const handleMarkCompleted = () => {
|
||||
|
||||
@@ -55,7 +55,8 @@ const DEFAULT_PREFS: CameraPreferences = {
|
||||
};
|
||||
|
||||
export default function UploadPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { token: slug } = useParams<{ token: string }>();
|
||||
const eventKey = slug ?? '';
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { appearance } = useAppearance();
|
||||
@@ -65,8 +66,8 @@ export default function UploadPage() {
|
||||
const taskIdParam = searchParams.get('task');
|
||||
const emotionSlug = searchParams.get('emotion') || '';
|
||||
|
||||
const primerStorageKey = slug ? `guestCameraPrimerDismissed_${slug}` : 'guestCameraPrimerDismissed';
|
||||
const prefsStorageKey = slug ? `guestCameraPrefs_${slug}` : 'guestCameraPrefs';
|
||||
const primerStorageKey = eventKey ? `guestCameraPrimerDismissed_${eventKey}` : 'guestCameraPrimerDismissed';
|
||||
const prefsStorageKey = eventKey ? `guestCameraPrefs_${eventKey}` : 'guestCameraPrefs';
|
||||
|
||||
const supportsCamera = typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia;
|
||||
|
||||
@@ -148,7 +149,7 @@ export default function UploadPage() {
|
||||
setLoadingTask(true);
|
||||
setTaskError(null);
|
||||
|
||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(slug!)}/tasks`);
|
||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/tasks`);
|
||||
if (!res.ok) throw new Error('Tasks konnten nicht geladen werden');
|
||||
const tasks = await res.json();
|
||||
const found = Array.isArray(tasks) ? tasks.find((entry: any) => entry.id === taskId!) : null;
|
||||
@@ -203,15 +204,15 @@ export default function UploadPage() {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [slug, taskId, emotionSlug]);
|
||||
}, [eventKey, taskId, emotionSlug]);
|
||||
|
||||
// Check upload limits
|
||||
useEffect(() => {
|
||||
if (!slug || !task) return;
|
||||
if (!eventKey || !task) return;
|
||||
|
||||
const checkLimits = async () => {
|
||||
try {
|
||||
const pkg = await getEventPackage(slug);
|
||||
const pkg = await getEventPackage(eventKey);
|
||||
setEventPackage(pkg);
|
||||
if (pkg && pkg.used_photos >= pkg.package.max_photos) {
|
||||
setCanUpload(false);
|
||||
@@ -227,7 +228,7 @@ export default function UploadPage() {
|
||||
};
|
||||
|
||||
checkLimits();
|
||||
}, [slug, task]);
|
||||
}, [eventKey, task]);
|
||||
|
||||
const stopStream = useCallback(() => {
|
||||
if (streamRef.current) {
|
||||
@@ -444,19 +445,19 @@ export default function UploadPage() {
|
||||
|
||||
const navigateAfterUpload = useCallback(
|
||||
(photoId: number | undefined) => {
|
||||
if (!slug || !task) return;
|
||||
if (!eventKey || !task) return;
|
||||
const params = new URLSearchParams();
|
||||
params.set('uploaded', 'true');
|
||||
if (task.id) params.set('task', String(task.id));
|
||||
if (photoId) params.set('photo', String(photoId));
|
||||
if (emotionSlug) params.set('emotion', emotionSlug);
|
||||
navigate(`/e/${encodeURIComponent(slug!)}/gallery?${params.toString()}`);
|
||||
navigate(`/e/${encodeURIComponent(eventKey)}/gallery?${params.toString()}`);
|
||||
},
|
||||
[emotionSlug, navigate, slug, task]
|
||||
[emotionSlug, navigate, eventKey, task]
|
||||
);
|
||||
|
||||
const handleUsePhoto = useCallback(async () => {
|
||||
if (!slug || !reviewPhoto || !task || !canUpload) return;
|
||||
if (!eventKey || !reviewPhoto || !task || !canUpload) return;
|
||||
setMode('uploading');
|
||||
setUploadProgress(5);
|
||||
setUploadError(null);
|
||||
@@ -470,7 +471,7 @@ export default function UploadPage() {
|
||||
}, 400);
|
||||
|
||||
try {
|
||||
const photoId = await uploadPhoto(slug, reviewPhoto.file, task.id, emotionSlug || undefined);
|
||||
const photoId = await uploadPhoto(eventKey, reviewPhoto.file, task.id, emotionSlug || undefined);
|
||||
setUploadProgress(100);
|
||||
setStatusMessage('Upload abgeschlossen.');
|
||||
markCompleted(task.id);
|
||||
@@ -487,7 +488,7 @@ export default function UploadPage() {
|
||||
}
|
||||
setStatusMessage('');
|
||||
}
|
||||
}, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, slug, stopStream, task, canUpload]);
|
||||
}, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, eventKey, stopStream, task, canUpload]);
|
||||
|
||||
const handleGalleryPick = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!canUpload) return;
|
||||
@@ -532,7 +533,7 @@ export default function UploadPage() {
|
||||
if (!supportsCamera && !task) {
|
||||
return (
|
||||
<div className="pb-16">
|
||||
<Header slug={slug} title="Kamera" />
|
||||
<Header slug={eventKey} title="Kamera" />
|
||||
<main className="px-4 py-6">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
@@ -548,7 +549,7 @@ export default function UploadPage() {
|
||||
if (loadingTask) {
|
||||
return (
|
||||
<div className="pb-16">
|
||||
<Header slug={slug} title="Kamera" />
|
||||
<Header slug={eventKey} title="Kamera" />
|
||||
<main className="px-4 py-6 flex flex-col items-center justify-center text-center">
|
||||
<Loader2 className="h-10 w-10 animate-spin text-pink-500 mb-4" />
|
||||
<p className="text-sm text-muted-foreground">Aufgabe und Kamera werden vorbereitet ...</p>
|
||||
@@ -561,7 +562,7 @@ export default function UploadPage() {
|
||||
if (!canUpload) {
|
||||
return (
|
||||
<div className="pb-16">
|
||||
<Header slug={slug} title="Kamera" />
|
||||
<Header slug={eventKey} title="Kamera" />
|
||||
<main className="px-4 py-6">
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
@@ -629,7 +630,7 @@ export default function UploadPage() {
|
||||
|
||||
return (
|
||||
<div className="pb-16">
|
||||
<Header slug={slug} title="Kamera" />
|
||||
<Header slug={eventKey} title="Kamera" />
|
||||
<main className="relative flex flex-col gap-4 pb-4">
|
||||
<div className="absolute left-0 right-0 top-0" aria-hidden="true">
|
||||
{renderPrimer()}
|
||||
|
||||
Reference in New Issue
Block a user