- 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

@@ -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()}