- Wired the checkout wizard for Google “comfort login”: added Socialite controller + dependency, new Google env

hooks in config/services.php/.env.example, and updated wizard steps/controllers to store session payloads,
attach packages, and surface localized success/error states.
- Retooled payment handling for both Stripe and PayPal, adding richer status management in CheckoutController/
PayPalController, fallback flows in the wizard’s PaymentStep.tsx, and fresh feature tests for intent
creation, webhooks, and the wizard CTA.
- Introduced a consent-aware Matomo analytics stack: new consent context, cookie-banner UI, useAnalytics/
useCtaExperiment hooks, and MatomoTracker component, then instrumented marketing pages (Home, Packages,
Checkout) with localized copy and experiment tracking.
- Polished package presentation across marketing UIs by centralizing formatting in PresentsPackages, surfacing
localized description tables/placeholders, tuning badges/layouts, and syncing guest/marketing translations.
- Expanded docs & reference material (docs/prp/*, TODOs, public gallery overview) and added a Playwright smoke
test for the hero CTA while reconciling outstanding checklist items.
This commit is contained in:
Codex Agent
2025-10-19 11:41:03 +02:00
parent ae9b9160ac
commit a949c8d3af
113 changed files with 5169 additions and 712 deletions

View File

@@ -16,8 +16,8 @@ interface EmotionPickerProps {
}
export default function EmotionPicker({ onSelect }: EmotionPickerProps) {
const { token: slug } = useParams<{ token: string }>();
const eventKey = slug ?? '';
const { token } = useParams<{ token: string }>();
const eventKey = token ?? '';
const navigate = useNavigate();
const [emotions, setEmotions] = useState<Emotion[]>([]);
const [loading, setLoading] = useState(true);

View File

@@ -1,14 +1,13 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { getDeviceId } from '../lib/device';
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
type Props = { slug: string };
type Props = { token: string };
export default function GalleryPreview({ slug }: Props) {
const { photos, loading } = usePollGalleryDelta(slug);
export default function GalleryPreview({ token }: Props) {
const { photos, loading } = usePollGalleryDelta(token);
const [mode, setMode] = React.useState<'latest' | 'popular' | 'myphotos'>('latest');
const items = React.useMemo(() => {
@@ -82,7 +81,7 @@ export default function GalleryPreview({ slug }: Props) {
My Photos
</button>
</div>
<Link to={`/e/${encodeURIComponent(slug)}/gallery?mode=${mode}`} className="text-sm text-pink-600 hover:text-pink-700 font-medium">
<Link to={`/e/${encodeURIComponent(token)}/gallery?mode=${mode}`} className="text-sm text-pink-600 hover:text-pink-700 font-medium">
Alle ansehen
</Link>
</div>
@@ -97,7 +96,7 @@ export default function GalleryPreview({ slug }: Props) {
)}
<div className="grid grid-cols-2 gap-3">
{items.map((p: any) => (
<Link key={p.id} to={`/e/${encodeURIComponent(slug)}/gallery?photoId=${p.id}`} className="block">
<Link key={p.id} to={`/e/${encodeURIComponent(token)}/gallery?photoId=${p.id}`} className="block">
<div className="relative">
<img
src={p.thumbnail_path || p.file_path}

View File

@@ -68,12 +68,12 @@ function renderEventAvatar(name: string, icon: unknown) {
);
}
export default function Header({ slug, title = '' }: { slug?: string; title?: string }) {
export default function Header({ eventToken, title = '' }: { eventToken?: string; title?: string }) {
const statsContext = useOptionalEventStats();
const identity = useOptionalGuestIdentity();
const { t } = useTranslation();
if (!slug) {
if (!eventToken) {
const guestName = identity?.name && identity?.hydrated ? identity.name : null;
return (
<div className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40">
@@ -95,7 +95,7 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
const { event, status } = useEventData();
const guestName =
identity && identity.eventKey === slug && identity.hydrated && identity.name ? identity.name : null;
identity && identity.eventKey === eventToken && identity.hydrated && identity.name ? identity.name : null;
if (status === 'loading') {
return (
@@ -114,7 +114,7 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
}
const stats =
statsContext && statsContext.eventKey === slug ? statsContext : undefined;
statsContext && statsContext.eventKey === eventToken ? statsContext : undefined;
return (
<div className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40">

View File

@@ -272,17 +272,17 @@ function SummaryCards({ data }: { data: AchievementsPayload }) {
);
}
function PersonalActions({ slug }: { slug: string }) {
function PersonalActions({ token }: { token: string }) {
return (
<div className="flex flex-wrap gap-3">
<Button asChild>
<Link to={`/e/${encodeURIComponent(slug)}/upload`} className="flex items-center gap-2">
<Link to={`/e/${encodeURIComponent(token)}/upload`} className="flex items-center gap-2">
<Camera className="h-4 w-4" />
Neues Foto hochladen
</Link>
</Button>
<Button variant="outline" asChild>
<Link to={`/e/${encodeURIComponent(slug)}/tasks`} className="flex items-center gap-2">
<Link to={`/e/${encodeURIComponent(token)}/tasks`} className="flex items-center gap-2">
<Sparkles className="h-4 w-4" />
Aufgabe ziehen
</Link>
@@ -292,7 +292,7 @@ function PersonalActions({ slug }: { slug: string }) {
}
export default function AchievementsPage() {
const { token: slug } = useParams<{ token: string }>();
const { token } = useParams<{ token: string }>();
const identity = useGuestIdentity();
const [data, setData] = useState<AchievementsPayload | null>(null);
const [loading, setLoading] = useState(true);
@@ -302,12 +302,12 @@ export default function AchievementsPage() {
const personalName = identity.hydrated && identity.name ? identity.name : undefined;
useEffect(() => {
if (!slug) return;
if (!token) return;
const controller = new AbortController();
setLoading(true);
setError(null);
fetchAchievements(slug, personalName, controller.signal)
fetchAchievements(token, personalName, controller.signal)
.then((payload) => {
setData(payload);
if (!payload.personal) {
@@ -322,11 +322,11 @@ export default function AchievementsPage() {
.finally(() => setLoading(false));
return () => controller.abort();
}, [slug, personalName]);
}, [token, personalName]);
const hasPersonal = Boolean(data?.personal);
if (!slug) {
if (!token) {
return null;
}
@@ -407,7 +407,7 @@ export default function AchievementsPage() {
{data.personal.photos} Fotos | {data.personal.tasks} Aufgaben | {data.personal.likes} Likes
</CardDescription>
</div>
<PersonalActions slug={slug} />
<PersonalActions token={token} />
</CardHeader>
</Card>

View File

@@ -1,4 +1,4 @@
import React, { Dispatch, SetStateAction, useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { Page } from './_util';
import { useParams, useSearchParams } from 'react-router-dom';
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
@@ -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 { token: slug } = useParams<{ token?: string }>();
const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(slug ?? '');
const { token } = useParams<{ token?: string }>();
const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(token ?? '');
const [filter, setFilter] = React.useState<GalleryFilter>('latest');
const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState<number | null>(null);
const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false);
@@ -38,15 +38,15 @@ export default function GalleryPage() {
// Load event and package info
useEffect(() => {
if (!slug) return;
if (!token) return;
const loadEventData = async () => {
try {
setEventLoading(true);
const [eventData, packageData, statsData] = await Promise.all([
fetchEvent(slug),
getEventPackage(slug),
fetchStats(slug),
fetchEvent(token),
getEventPackage(token),
fetchStats(token),
]);
setEvent(eventData);
setEventPackage(packageData);
@@ -59,7 +59,7 @@ export default function GalleryPage() {
};
loadEventData();
}, [slug]);
}, [token]);
const myPhotoIds = React.useMemo(() => {
try {
@@ -99,7 +99,7 @@ export default function GalleryPage() {
}
}
if (!slug) {
if (!token) {
return <Page title="Galerie"><p>Event nicht gefunden.</p></Page>;
}
@@ -236,7 +236,7 @@ export default function GalleryPage() {
currentIndex={currentPhotoIndex}
onClose={() => setCurrentPhotoIndex(null)}
onIndexChange={(index: number) => setCurrentPhotoIndex(index)}
slug={slug}
token={token}
/>
)}
</Page>

View File

@@ -141,7 +141,7 @@ export default function HomePage() {
<EmotionPicker />
<GalleryPreview slug={token} />
<GalleryPreview token={token} />
</div>
);
}

View File

@@ -61,7 +61,11 @@ export default function LandingPage() {
return;
}
const data = await res.json();
const targetKey = data.join_token ?? data.slug ?? normalized;
const targetKey = data.join_token ?? '';
if (!targetKey) {
setErrorKey('eventClosed');
return;
}
const storedName = readGuestName(targetKey);
if (!storedName) {
nav(`/setup/${encodeURIComponent(targetKey)}`);

View File

@@ -23,15 +23,15 @@ interface Props {
currentIndex?: number;
onClose?: () => void;
onIndexChange?: (index: number) => void;
slug?: string;
token?: string;
}
export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexChange, slug }: Props) {
export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexChange, token }: Props) {
const params = useParams<{ token?: string; photoId?: string }>();
const location = useLocation();
const navigate = useNavigate();
const photoId = params.photoId;
const eventSlug = params.token || slug;
const eventToken = params.token || token;
const { t } = useTranslation();
const [standalonePhoto, setStandalonePhoto] = useState<Photo | null>(null);
@@ -53,7 +53,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
// Fetch single photo for standalone mode
useEffect(() => {
if (isStandalone && photoId && !standalonePhoto && eventSlug) {
if (isStandalone && photoId && !standalonePhoto && eventToken) {
const fetchPhoto = async () => {
setLoading(true);
setError(null);
@@ -80,7 +80,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
} else if (!isStandalone) {
setLoading(false);
}
}, [isStandalone, photoId, eventSlug, standalonePhoto, location.state, t]);
}, [isStandalone, photoId, eventToken, standalonePhoto, location.state, t]);
// Update likes when photo changes
React.useEffect(() => {
@@ -133,7 +133,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
// Load task info if photo has task_id and event key is available
React.useEffect(() => {
if (!photo?.task_id || !eventSlug) {
if (!photo?.task_id || !eventToken) {
setTask(null);
setTaskLoading(false);
return;
@@ -144,7 +144,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
(async () => {
setTaskLoading(true);
try {
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventSlug)}/tasks`);
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/tasks`);
if (res.ok) {
const tasks = await res.json();
const foundTask = tasks.find((t: any) => t.id === taskId);
@@ -175,7 +175,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
setTaskLoading(false);
}
})();
}, [photo?.task_id, eventSlug, t]);
}, [photo?.task_id, eventToken, t]);
async function onLike() {
if (liked || !photo) return;

View File

@@ -28,8 +28,8 @@ const TASK_PROGRESS_TARGET = 5;
const TIMER_VIBRATION = [0, 60, 120, 60];
export default function TaskPickerPage() {
const { token: slug } = useParams<{ token: string }>();
const eventKey = slug ?? '';
const { token } = useParams<{ token: string }>();
const eventKey = token ?? '';
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
@@ -92,12 +92,12 @@ export default function TaskPickerPage() {
map.set(task.emotion.slug, task.emotion.name);
}
});
return Array.from(map.entries()).map(([slugValue, name]) => ({ slug: slugValue, name }));
return Array.from(map.entries()).map(([slugValue, name]) => ({ slug: tokenValue, name }));
}, [tasks]);
const filteredTasks = React.useMemo(() => {
if (selectedEmotion === 'all') return tasks;
return tasks.filter((task) => task.emotion?.slug === selectedEmotion);
return tasks.filter((task) => task.emotion?.token === selectedEmotion);
}, [tasks, selectedEmotion]);
const selectRandomTask = React.useCallback(

View File

@@ -56,13 +56,13 @@ const DEFAULT_PREFS: CameraPreferences = {
};
export default function UploadPage() {
const { token: slug } = useParams<{ token: string }>();
const eventKey = slug ?? '';
const { token } = useParams<{ token: string }>();
const eventKey = token ?? '';
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { appearance } = useAppearance();
const isDarkMode = appearance === 'dark';
const { markCompleted } = useGuestTaskProgress(slug);
const { markCompleted } = useGuestTaskProgress(token);
const { t } = useTranslation();
const taskIdParam = searchParams.get('task');
@@ -138,7 +138,7 @@ export default function UploadPage() {
// Load task metadata
useEffect(() => {
if (!slug || !taskId) {
if (!token || !taskId) {
setTaskError(t('upload.loadError.title'));
setLoadingTask(false);
return;
@@ -545,7 +545,7 @@ export default function UploadPage() {
if (!supportsCamera && !task) {
return (
<div className="pb-16">
<Header slug={eventKey} title={t('upload.cameraTitle')} />
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
<main className="px-4 py-6">
<Alert>
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
@@ -559,7 +559,7 @@ export default function UploadPage() {
if (loadingTask) {
return (
<div className="pb-16">
<Header slug={eventKey} title={t('upload.cameraTitle')} />
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
<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">{t('upload.preparing')}</p>
@@ -572,7 +572,7 @@ export default function UploadPage() {
if (!canUpload) {
return (
<div className="pb-16">
<Header slug={eventKey} title={t('upload.cameraTitle')} />
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
<main className="px-4 py-6">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
@@ -638,7 +638,7 @@ export default function UploadPage() {
return (
<div className="pb-16">
<Header slug={eventKey} title={t('upload.cameraTitle')} />
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
<main className="relative flex flex-col gap-4 pb-4">
<div className="absolute left-0 right-0 top-0" aria-hidden="true">
{renderPrimer()}

View File

@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
type Photo = { id: number; file_path?: string; thumbnail_path?: string; created_at?: string };
export function usePollGalleryDelta(slug: string) {
export function usePollGalleryDelta(token: string) {
const [photos, setPhotos] = useState<Photo[]>([]);
const [loading, setLoading] = useState(true);
const [newCount, setNewCount] = useState(0);
@@ -13,14 +13,14 @@ export function usePollGalleryDelta(slug: string) {
);
async function fetchDelta() {
if (!slug) {
if (!token) {
setLoading(false);
return;
}
try {
const qs = latestAt.current ? `?since=${encodeURIComponent(latestAt.current)}` : '';
const res = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/photos${qs}`, {
const res = await fetch(`/api/v1/events/${encodeURIComponent(token)}/photos${qs}`, {
headers: { 'Cache-Control': 'no-store' },
});
@@ -90,7 +90,7 @@ export function usePollGalleryDelta(slug: string) {
}, []);
useEffect(() => {
if (!slug) {
if (!token) {
setPhotos([]);
setLoading(false);
return;
@@ -107,7 +107,7 @@ export function usePollGalleryDelta(slug: string) {
return () => {
if (timer.current) window.clearInterval(timer.current);
};
}, [slug, visible]);
}, [token, visible]);
function acknowledgeNew() { setNewCount(0); }
return { loading, photos, newCount, acknowledgeNew };

View File

@@ -6,7 +6,7 @@ type SyncManager = { register(tag: string): Promise<void>; };
export type QueueItem = {
id?: number;
slug: string;
eventToken: string;
fileName: string;
blob: Blob;
emotion_id?: number | null;
@@ -77,7 +77,7 @@ async function attemptUpload(it: QueueItem): Promise<boolean> {
if (!navigator.onLine) return false;
try {
const json = await createUpload(
`/api/v1/events/${encodeURIComponent(it.slug)}/photos`,
`/api/v1/events/${encodeURIComponent(it.eventToken)}/photos`,
it,
getDeviceId(),
(pct) => {

View File

@@ -97,7 +97,7 @@ function EventBoundary({ token }: { token: string }) {
<LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>
<EventStatsProvider eventKey={token}>
<div className="pb-16">
<Header slug={token} />
<Header eventToken={token} />
<div className="px-4 py-3">
<Outlet />
</div>
@@ -119,7 +119,7 @@ function SetupLayout() {
<LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>
<EventStatsProvider eventKey={token}>
<div className="pb-0">
<Header slug={token} />
<Header eventToken={token} />
<Outlet />
</div>
</EventStatsProvider>

View File

@@ -87,7 +87,7 @@ function safeString(value: unknown): string {
}
export async function fetchAchievements(
slug: string,
eventToken: string,
guestName?: string,
signal?: AbortSignal
): Promise<AchievementsPayload> {
@@ -96,7 +96,7 @@ export async function fetchAchievements(
params.set('guest_name', guestName.trim());
}
const response = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/achievements?${params.toString()}`, {
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/achievements?${params.toString()}`, {
method: 'GET',
headers: {
'X-Device-Id': getDeviceId(),

View File

@@ -183,8 +183,8 @@ export async function fetchStats(eventKey: string): Promise<EventStats> {
};
}
export async function getEventPackage(slug: string): Promise<EventPackage | null> {
const res = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/package`);
export async function getEventPackage(eventToken: string): Promise<EventPackage | null> {
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/package`);
if (!res.ok) {
if (res.status === 404) return null;
throw new Error('Failed to load event package');

View File

@@ -70,14 +70,14 @@ export async function likePhoto(id: number): Promise<number> {
return json.likes_count ?? json.data?.likes_count ?? 0;
}
export async function uploadPhoto(slug: string, file: File, taskId?: number, emotionSlug?: string): Promise<number> {
export async function uploadPhoto(eventToken: string, file: File, taskId?: number, emotionSlug?: string): Promise<number> {
const formData = new FormData();
formData.append('photo', file, `photo-${Date.now()}.jpg`);
if (taskId) formData.append('task_id', taskId.toString());
if (emotionSlug) formData.append('emotion_slug', emotionSlug);
formData.append('device_id', getDeviceId());
const res = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/upload`, {
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/upload`, {
method: 'POST',
credentials: 'include',
body: formData,