- Reworked the tenant admin login page

- Updated the User model to implement Filament’s tenancy contracts
- Seeded a ready-to-use demo tenant (user, tenant, active package, purchase)
- Introduced a branded, translated 403 error page to replace the generic forbidden message for unauthorised admin hits
- Removed the public “Register” links from the marketing header
- hardened join event logic and improved error handling in the guest pwa.
This commit is contained in:
Codex Agent
2025-10-13 12:50:46 +02:00
parent 9394c3171e
commit 64a5411fb9
69 changed files with 5447 additions and 588 deletions

View File

@@ -2,6 +2,21 @@ import { authorizedFetch } from './auth/tokens';
type JsonValue = Record<string, any>;
export type EventJoinTokenLayout = {
id: string;
name: string;
description: string;
subtitle: string;
preview: {
background: string | null;
background_gradient: { angle: number; stops: string[] } | null;
accent: string | null;
text: string | null;
};
formats: string[];
download_urls: Record<string, string>;
};
export type TenantEvent = {
id: number;
name: string | Record<string, string>;
@@ -139,6 +154,8 @@ export type EventJoinToken = {
is_active: boolean;
created_at: string | null;
metadata: Record<string, unknown>;
layouts: EventJoinTokenLayout[];
layouts_url: string | null;
};
type CreatedEventResponse = { message: string; data: TenantEvent; balance: number };
type PhotoResponse = { message: string; data: TenantPhoto };
@@ -271,6 +288,30 @@ function normalizeMember(member: JsonValue): EventMember {
}
function normalizeJoinToken(raw: JsonValue): EventJoinToken {
const rawLayouts = Array.isArray(raw.layouts) ? raw.layouts : [];
const layouts: EventJoinTokenLayout[] = rawLayouts
.map((layout: any) => {
const formats = Array.isArray(layout.formats)
? layout.formats.map((format: unknown) => String(format ?? '')).filter((format: string) => format.length > 0)
: [];
return {
id: String(layout.id ?? ''),
name: String(layout.name ?? ''),
description: String(layout.description ?? ''),
subtitle: String(layout.subtitle ?? ''),
preview: {
background: layout.preview?.background ?? null,
background_gradient: layout.preview?.background_gradient ?? null,
accent: layout.preview?.accent ?? null,
text: layout.preview?.text ?? null,
},
formats,
download_urls: (layout.download_urls ?? {}) as Record<string, string>,
};
})
.filter((layout: EventJoinTokenLayout) => layout.id.length > 0);
return {
id: Number(raw.id ?? 0),
token: String(raw.token ?? ''),
@@ -283,6 +324,8 @@ function normalizeJoinToken(raw: JsonValue): EventJoinToken {
is_active: Boolean(raw.is_active),
created_at: raw.created_at ?? null,
metadata: (raw.metadata ?? {}) as Record<string, unknown>,
layouts,
layouts_url: typeof raw.layouts_url === 'string' ? raw.layouts_url : null,
};
}

View File

@@ -10,6 +10,8 @@ import deOnboarding from './locales/de/onboarding.json';
import enOnboarding from './locales/en/onboarding.json';
import deManagement from './locales/de/management.json';
import enManagement from './locales/en/management.json';
import deAuth from './locales/de/auth.json';
import enAuth from './locales/en/auth.json';
const DEFAULT_NAMESPACE = 'common';
@@ -19,12 +21,14 @@ const resources = {
dashboard: deDashboard,
onboarding: deOnboarding,
management: deManagement,
auth: deAuth,
},
en: {
common: enCommon,
dashboard: enDashboard,
onboarding: enOnboarding,
management: enManagement,
auth: enAuth,
},
} as const;

View File

@@ -0,0 +1,10 @@
{
"login": {
"title": "Tenant-Admin",
"lead": "Melde dich mit deinem Fotospiel-Account an. Du wirst zur sicheren OAuth-Anmeldung weitergeleitet und anschließend zur Admin-Oberfläche zurückgebracht.",
"cta": "Mit Tenant-Account anmelden",
"loading": "Bitte warten …",
"oauth_error": "Anmeldung fehlgeschlagen: {{message}}",
"appearance_label": "Darstellung"
}
}

View File

@@ -163,7 +163,7 @@
"successTitle": "Gratis-Paket aktiviert",
"successDescription": "Deine Credits wurden hinzugefügt. Weiter geht's mit dem Event-Setup.",
"failureTitle": "Aktivierung fehlgeschlagen",
"errorMessage": "Kostenloses Paket konnte nicht aktiviert werden.",
"errorMessage": "Kostenloses Paket konnte nicht aktiviert werden."
},
"stripe": {
"sectionTitle": "Kartenzahlung (Stripe)",

View File

@@ -0,0 +1,10 @@
{
"login": {
"title": "Tenant Admin",
"lead": "Sign in with your Fotospiel account. We will redirect you to the secure OAuth login and bring you back to the admin dashboard afterwards.",
"cta": "Sign in with tenant account",
"loading": "Please wait …",
"oauth_error": "Sign-in failed: {{message}}",
"appearance_label": "Appearance"
}
}

View File

@@ -13,6 +13,12 @@ initializeTheme();
const rootEl = document.getElementById('root')!;
const queryClient = new QueryClient();
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/admin-sw.js').catch(() => {});
});
}
createRoot(rootEl).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>

View File

@@ -0,0 +1,70 @@
import React from 'react';
import { describe, expect, beforeEach, afterEach, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import WelcomeLandingPage from '../pages/WelcomeLandingPage';
import { OnboardingProgressProvider } from '..';
import {
ADMIN_EVENTS_PATH,
ADMIN_WELCOME_PACKAGES_PATH,
} from '../../constants';
const navigateMock = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return {
...actual,
useNavigate: () => navigateMock,
useLocation: () => ({ pathname: '/event-admin', search: '', hash: '', state: null, key: 'test' }),
};
});
vi.mock('../../components/LanguageSwitcher', () => ({
LanguageSwitcher: () => <div data-testid="language-switcher" />,
}));
describe('WelcomeLandingPage', () => {
beforeEach(() => {
localStorage.clear();
navigateMock.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
});
function renderPage() {
return render(
<OnboardingProgressProvider>
<WelcomeLandingPage />
</OnboardingProgressProvider>
);
}
it('marks the welcome step as seen on mount', () => {
renderPage();
const stored = localStorage.getItem('tenant-admin:onboarding-progress');
expect(stored).toBeTruthy();
expect(stored).toContain('"welcomeSeen":true');
expect(stored).toContain('"lastStep":"landing"');
});
it('navigates to package selection when the primary CTA is clicked', async () => {
renderPage();
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /hero.primary.label/i }));
expect(navigateMock).toHaveBeenCalledWith(ADMIN_WELCOME_PACKAGES_PATH);
});
it('navigates to events when secondary CTA in hero or footer is used', async () => {
renderPage();
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /hero.secondary.label/i }));
expect(navigateMock).toHaveBeenCalledWith(ADMIN_EVENTS_PATH);
navigateMock.mockClear();
await user.click(screen.getByRole('button', { name: /layout.jumpToDashboard/i }));
expect(navigateMock).toHaveBeenCalledWith(ADMIN_EVENTS_PATH);
});
});

View File

@@ -0,0 +1,176 @@
import React from 'react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import {
StripeCheckoutForm,
PayPalCheckout,
} from '../pages/WelcomeOrderSummaryPage';
const stripeRef: { current: any } = { current: null };
const elementsRef: { current: any } = { current: null };
const paypalPropsRef: { current: any } = { current: null };
const {
confirmPaymentMock,
completePurchaseMock,
createPayPalOrderMock,
capturePayPalOrderMock,
} = vi.hoisted(() => ({
confirmPaymentMock: vi.fn(),
completePurchaseMock: vi.fn(),
createPayPalOrderMock: vi.fn(),
capturePayPalOrderMock: vi.fn(),
}));
vi.mock('@stripe/react-stripe-js', () => ({
useStripe: () => stripeRef.current,
useElements: () => elementsRef.current,
PaymentElement: () => <div data-testid="stripe-payment-element" />,
Elements: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock('@paypal/react-paypal-js', () => ({
PayPalScriptProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
PayPalButtons: (props: any) => {
paypalPropsRef.current = props;
return <button type="button" data-testid="paypal-button">PayPal</button>;
},
}));
vi.mock('../../api', () => ({
completeTenantPackagePurchase: completePurchaseMock,
createTenantPackagePaymentIntent: vi.fn(),
assignFreeTenantPackage: vi.fn(),
createTenantPayPalOrder: createPayPalOrderMock,
captureTenantPayPalOrder: capturePayPalOrderMock,
}));
describe('StripeCheckoutForm', () => {
beforeEach(() => {
confirmPaymentMock.mockReset();
completePurchaseMock.mockReset();
stripeRef.current = { confirmPayment: confirmPaymentMock };
elementsRef.current = {};
});
const renderStripeForm = (overrides?: Partial<React.ComponentProps<typeof StripeCheckoutForm>>) =>
render(
<StripeCheckoutForm
clientSecret="secret"
packageId={42}
onSuccess={vi.fn()}
t={(key: string) => key}
{...overrides}
/>
);
it('completes the purchase when Stripe reports a successful payment', async () => {
const onSuccess = vi.fn();
confirmPaymentMock.mockResolvedValue({
error: null,
paymentIntent: { payment_method: 'pm_123' },
});
completePurchaseMock.mockResolvedValue(undefined);
const { container } = renderStripeForm({ onSuccess });
const form = container.querySelector('form');
expect(form).toBeTruthy();
fireEvent.submit(form!);
await waitFor(() => {
expect(completePurchaseMock).toHaveBeenCalledWith({
packageId: 42,
paymentMethodId: 'pm_123',
});
});
expect(onSuccess).toHaveBeenCalled();
});
it('shows Stripe errors returned by confirmPayment', async () => {
confirmPaymentMock.mockResolvedValue({
error: { message: 'Card declined' },
});
const { container } = renderStripeForm();
fireEvent.submit(container.querySelector('form')!);
await waitFor(() => {
expect(screen.getByText('Card declined')).toBeInTheDocument();
});
expect(completePurchaseMock).not.toHaveBeenCalled();
});
it('reports missing payment method id', async () => {
confirmPaymentMock.mockResolvedValue({
error: null,
paymentIntent: {},
});
const { container } = renderStripeForm();
fireEvent.submit(container.querySelector('form')!);
await waitFor(() => {
expect(screen.getByText('summary.stripe.missingPaymentId')).toBeInTheDocument();
});
expect(completePurchaseMock).not.toHaveBeenCalled();
});
});
describe('PayPalCheckout', () => {
beforeEach(() => {
paypalPropsRef.current = null;
createPayPalOrderMock.mockReset();
capturePayPalOrderMock.mockReset();
});
it('creates and captures a PayPal order successfully', async () => {
createPayPalOrderMock.mockResolvedValue('ORDER-123');
capturePayPalOrderMock.mockResolvedValue(undefined);
const onSuccess = vi.fn();
render(
<PayPalCheckout
packageId={99}
onSuccess={onSuccess}
t={(key: string) => key}
/>
);
expect(paypalPropsRef.current).toBeTruthy();
const { createOrder, onApprove } = paypalPropsRef.current;
await act(async () => {
const orderId = await createOrder();
expect(orderId).toBe('ORDER-123');
});
await act(async () => {
await onApprove({ orderID: 'ORDER-123' });
});
await waitFor(() => {
expect(createPayPalOrderMock).toHaveBeenCalledWith(99);
expect(capturePayPalOrderMock).toHaveBeenCalledWith('ORDER-123');
expect(onSuccess).toHaveBeenCalled();
});
});
it('surfaces missing order id errors', async () => {
createPayPalOrderMock.mockResolvedValue('ORDER-123');
render(
<PayPalCheckout
packageId={99}
onSuccess={vi.fn()}
t={(key: string) => key}
/>
);
const { onApprove } = paypalPropsRef.current;
await act(async () => {
await onApprove({ orderID: undefined });
});
await waitFor(() => {
expect(screen.getByText('summary.paypal.missingOrderId')).toBeInTheDocument();
});
expect(capturePayPalOrderMock).not.toHaveBeenCalled();
});
});

View File

@@ -633,3 +633,5 @@ export default function WelcomeOrderSummaryPage() {
</TenantWelcomeLayout>
);
}
export { StripeCheckoutForm, PayPalCheckout };

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { ArrowLeft, Camera, Heart, Loader2, RefreshCw, Share2, Sparkles } from 'lucide-react';
import { ArrowLeft, Camera, Download, Heart, Loader2, RefreshCw, Share2, Sparkles } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
@@ -260,25 +260,41 @@ export default function EventDetailPage() {
</CardContent>
</Card>
<Card className="border-0 bg-white/90 shadow-xl shadow-amber-100/60">
<Card id="join-invites" className="border-0 bg-white/90 shadow-xl shadow-amber-100/60">
<CardHeader className="space-y-2">
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Share2 className="h-5 w-5 text-amber-500" /> Einladungen
<Share2 className="h-5 w-5 text-amber-500" /> Einladungen & Drucklayouts
</CardTitle>
<CardDescription className="text-sm text-slate-600">
Generiere Links um Gaeste direkt in das Event zu fuehren.
Verwalte Join-Tokens fuer dein Event. Jede Einladung enthaelt einen eindeutigen Token, QR-Code und
downloadbare PDF/SVG-Layouts.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-slate-700">
<CardContent className="space-y-4 text-sm text-slate-700">
<div className="space-y-2 rounded-xl border border-amber-100 bg-amber-50/70 p-3 text-xs text-amber-800">
<p>
Teile den generierten Link oder drucke die Layouts aus, um Gaeste sicher ins Event zu leiten. Tokens lassen
sich jederzeit rotieren oder deaktivieren.
</p>
{tokens.length > 0 && (
<p className="flex items-center gap-2 text-[11px] uppercase tracking-wide text-amber-600">
Aktive Tokens: {tokens.filter((token) => token.is_active && !token.revoked_at).length} · Gesamt:{' '}
{tokens.length}
</p>
)}
</div>
<Button onClick={handleInvite} disabled={creatingToken} className="w-full">
{creatingToken ? <Loader2 className="h-4 w-4 animate-spin" /> : <Share2 className="h-4 w-4" />}
Einladungslink erzeugen
Join-Token erzeugen
</Button>
{inviteLink && (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 font-mono text-xs text-amber-800">
{inviteLink}
</p>
)}
<div className="space-y-3">
{tokens.length > 0 ? (
tokens.map((token) => (
@@ -291,9 +307,10 @@ export default function EventDetailPage() {
/>
))
) : (
<p className="text-xs text-slate-500">
Noch keine Einladungen erstellt. Nutze den Button, um einen neuen QR-Link zu generieren.
</p>
<div className="rounded-lg border border-slate-200 bg-white/80 p-4 text-xs text-slate-500">
Noch keine Tokens vorhanden. Erzeuge jetzt den ersten Token, um QR-Codes und Drucklayouts
herunterzuladen.
</div>
)}
</div>
</CardContent>
@@ -366,9 +383,11 @@ function JoinTokenRow({
revoking: boolean;
}) {
const status = getTokenStatus(token);
const availableLayouts = Array.isArray(token.layouts) ? token.layouts : [];
return (
<div className="flex flex-col gap-3 rounded-xl border border-amber-100 bg-amber-50/60 p-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-2">
<div className="flex flex-col gap-3 rounded-xl border border-amber-100 bg-amber-50/60 p-3">
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-slate-800">{token.label || `Einladung #${token.id}`}</span>
<span
@@ -392,8 +411,81 @@ function JoinTokenRow({
{token.expires_at && <span>Gültig bis {formatDateTime(token.expires_at)}</span>}
{token.created_at && <span>Erstellt {formatDateTime(token.created_at)}</span>}
</div>
{availableLayouts.length > 0 && (
<div className="space-y-3 rounded-xl border border-amber-100 bg-white/80 p-3">
<div className="text-xs font-semibold uppercase tracking-wide text-amber-600">Drucklayouts</div>
<div className="grid gap-3 sm:grid-cols-2">
{availableLayouts.map((layout) => {
const formatEntries = Array.isArray(layout.formats)
? layout.formats
.map((format) => {
const normalized = String(format ?? '').toLowerCase();
const href =
layout.download_urls?.[normalized] ??
layout.download_urls?.[String(format ?? '')] ??
null;
return {
format: normalized,
label: String(format ?? '').toUpperCase(),
href,
};
})
.filter((entry) => Boolean(entry.href))
: [];
if (formatEntries.length === 0) {
return null;
}
return (
<div key={layout.id} className="flex flex-col gap-2 rounded-lg border border-amber-200 bg-white p-3 shadow-sm">
<div>
<div className="text-sm font-semibold text-slate-800">{layout.name}</div>
{layout.subtitle && <div className="text-xs text-slate-500">{layout.subtitle}</div>}
</div>
<div className="flex flex-wrap gap-2">
{formatEntries.map((entry) => (
<Button
asChild
key={`${layout.id}-${entry.format}`}
size="sm"
variant="outline"
className="border-amber-200 text-amber-700 hover:bg-amber-100"
>
<a href={entry.href as string} target="_blank" rel="noreferrer">
<Download className="mr-1 h-3 w-3" />
{entry.label}
</a>
</Button>
))}
</div>
</div>
);
})}
</div>
</div>
)}
{!availableLayouts.length && token.layouts_url && (
<div className="rounded-xl border border-amber-100 bg-white/70 p-3 text-xs text-slate-600">
Drucklayouts stehen für diesen Token bereit. Öffne den Layout-Link, um PDF- oder SVG-Versionen zu laden.
</div>
)}
</div>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2 md:items-center md:justify-start">
{token.layouts_url && (
<Button
asChild
size="sm"
variant="outline"
className="border-amber-200 text-amber-700 hover:bg-amber-100"
>
<a href={token.layouts_url} target="_blank" rel="noreferrer">
<Download className="h-3 w-3" />
<span className="ml-1">Layouts</span>
</a>
</Button>
)}
<Button variant="outline" size="sm" onClick={onCopy} className="border-amber-200 text-amber-700 hover:bg-amber-100">
Kopieren
</Button>

View File

@@ -197,14 +197,17 @@ export default function EventFormPage() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="event-slug">Slug / URL-Endung</Label>
<Label htmlFor="event-slug">Slug / interne Kennung</Label>
<Input
id="event-slug"
placeholder="sommerfest-2025"
value={form.slug}
onChange={(e) => handleSlugChange(e.target.value)}
/>
<p className="text-xs text-slate-500">Das Event ist spaeter unter /e/{form.slug || 'dein-event'} erreichbar.</p>
<p className="text-xs text-slate-500">
Diese Kennung wird intern verwendet. Gaeste erhalten Zugriff ausschliesslich ueber Join-Tokens und deren
QR-/Layout-Downloads.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="event-date">Datum</Label>

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { ArrowRight, CalendarDays, Plus, Settings, Sparkles } from 'lucide-react';
import { ArrowRight, CalendarDays, Plus, Settings, Sparkles, Share2 } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
@@ -156,9 +156,9 @@ function EventCard({ event }: { event: TenantEvent }) {
<Link to={ADMIN_EVENT_TASKS_PATH(slug)}>Tasks</Link>
</Button>
<Button asChild variant="outline" className="border-slate-200 text-slate-700 hover:bg-slate-50">
<a href={`/e/${slug}`} target="_blank" rel="noreferrer">
Oeffnen im Gastportal
</a>
<Link to={`${ADMIN_EVENT_VIEW_PATH(slug)}#join-invites`}>
<Share2 className="h-3.5 w-3.5" /> Einladungen
</Link>
</Button>
</div>
</div>

View File

@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
import { useAuth } from '../auth/context';
import { ADMIN_HOME_PATH } from '../constants';
import { useTranslation } from 'react-i18next';
interface LocationState {
from?: Location;
@@ -11,6 +12,7 @@ interface LocationState {
export default function LoginPage() {
const { status, login } = useAuth();
const { t } = useTranslation('auth');
const location = useLocation();
const navigate = useNavigate();
const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]);
@@ -36,17 +38,14 @@ export default function LoginPage() {
return (
<div className="mx-auto flex min-h-screen max-w-sm flex-col justify-center p-6">
<div className="mb-6 flex items-center justify-between">
<h1 className="text-lg font-semibold">Tenant Admin</h1>
<h1 className="text-lg font-semibold">{t('login.title')}</h1>
<AppearanceToggleDropdown />
</div>
<div className="space-y-4 text-sm text-muted-foreground">
<p>
Melde dich mit deinem Fotospiel-Account an. Du wirst zur sicheren OAuth-Anmeldung weitergeleitet und danach
wieder zur Admin-Oberflaeche gebracht.
</p>
<p>{t('login.lead')}</p>
{oauthError && (
<div className="rounded border border-red-300 bg-red-50 p-2 text-sm text-red-700">
Anmeldung fehlgeschlagen: {oauthError}
{t('login.oauth_error', { message: oauthError })}
</div>
)}
<Button
@@ -54,7 +53,7 @@ export default function LoginPage() {
disabled={status === 'loading'}
onClick={() => login(redirectTarget)}
>
{status === 'loading' ? 'Bitte warten ...' : 'Mit Tenant-Account anmelden'}
{status === 'loading' ? t('login.loading') : t('login.cta')}
</Button>
</div>
</div>

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import DashboardPage from '../DashboardPage';
import { ADMIN_WELCOME_BASE_PATH } from '../../constants';
const navigateMock = vi.fn();
const markStepMock = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return {
...actual,
useNavigate: () => navigateMock,
useLocation: () => ({ pathname: '/event-admin', search: '', hash: '', state: null, key: 'test' }),
};
});
vi.mock('../../components/AdminLayout', () => ({
AdminLayout: ({ children }: { children: React.ReactNode }) => <div data-testid="admin-layout">{children}</div>,
}));
vi.mock('../../auth/context', () => ({
useAuth: () => ({ status: 'authenticated', user: { name: 'Test Tenant' } }),
}));
vi.mock('../../onboarding', () => ({
useOnboardingProgress: () => ({
progress: {
welcomeSeen: false,
packageSelected: false,
eventCreated: false,
lastStep: null,
selectedPackage: null,
},
setProgress: vi.fn(),
markStep: markStepMock,
reset: vi.fn(),
}),
}));
vi.mock('../../api', () => ({
getDashboardSummary: vi.fn().mockResolvedValue(null),
getEvents: vi.fn().mockResolvedValue([]),
getCreditBalance: vi.fn().mockResolvedValue({ balance: 0 }),
getTenantPackagesOverview: vi.fn().mockResolvedValue({ packages: [], activePackage: null }),
}));
describe('DashboardPage onboarding guard', () => {
beforeEach(() => {
navigateMock.mockReset();
markStepMock.mockReset();
});
it('redirects to the welcome flow when no events exist and onboarding is incomplete', async () => {
render(<DashboardPage />);
await waitFor(() => {
expect(navigateMock).toHaveBeenCalledWith(ADMIN_WELCOME_BASE_PATH, { replace: true });
});
expect(markStepMock).not.toHaveBeenCalled();
});
});

View File

@@ -1,13 +1,12 @@
import React from 'react';
import { NavLink, useParams, useLocation } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { CheckSquare, GalleryHorizontal, Home, Trophy } from 'lucide-react';
import { useEventData } from '../hooks/useEventData';
function TabLink({
to,
children,
isActive
isActive,
}: {
to: string;
children: React.ReactNode;
@@ -31,9 +30,11 @@ function TabLink({
export default function BottomNav() {
const { token } = useParams();
const location = useLocation();
const { event } = useEventData();
if (!token) return null; // Only show bottom nav within event context
const { event, status } = useEventData();
const isReady = status === 'ready' && !!event;
if (!token || !isReady) return null; // Only show bottom nav within event context
const base = `/e/${encodeURIComponent(token)}`;
const currentPath = location.pathname;
const locale = event?.default_locale || 'de';

View File

@@ -28,11 +28,11 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
);
}
const { event, loading: eventLoading, error: eventError } = useEventData();
const stats = statsContext && statsContext.eventKey === slug ? statsContext : undefined;
const guestName = identity && identity.eventKey === slug && identity.hydrated && identity.name ? identity.name : null;
const { event, status } = useEventData();
const guestName =
identity && identity.eventKey === slug && identity.hydrated && identity.name ? identity.name : null;
if (eventLoading) {
if (status === 'loading') {
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">
<div className="font-semibold">Lade Event...</div>
@@ -44,18 +44,13 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
);
}
if (eventError || !event) {
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">
<div className="font-semibold text-red-600">Event nicht gefunden</div>
<div className="flex items-center gap-2">
<AppearanceToggleDropdown />
<SettingsSheet />
</div>
</div>
);
if (status !== 'ready' || !event) {
return null;
}
const stats =
statsContext && statsContext.eventKey === slug ? statsContext : undefined;
const getEventAvatar = (event: any) => {
if (event.type?.icon) {
return (

View File

@@ -1,40 +1,90 @@
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { fetchEvent, EventData } from '../services/eventApi';
import {
fetchEvent,
EventData,
FetchEventError,
FetchEventErrorCode,
} from '../services/eventApi';
export function useEventData() {
type EventDataStatus = 'loading' | 'ready' | 'error';
interface UseEventDataResult {
event: EventData | null;
status: EventDataStatus;
loading: boolean;
error: string | null;
errorCode: FetchEventErrorCode | null;
token: string | null;
}
const NO_TOKEN_ERROR_MESSAGE = 'Es wurde kein Einladungscode übergeben.';
export function useEventData(): UseEventDataResult {
const { token } = useParams<{ token: string }>();
const [event, setEvent] = useState<EventData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<EventDataStatus>(token ? 'loading' : 'error');
const [errorMessage, setErrorMessage] = useState<string | null>(token ? null : NO_TOKEN_ERROR_MESSAGE);
const [errorCode, setErrorCode] = useState<FetchEventErrorCode | null>(token ? null : 'invalid_token');
useEffect(() => {
if (!token) {
setError('No event token provided');
setLoading(false);
setEvent(null);
setStatus('error');
setErrorCode('invalid_token');
setErrorMessage(NO_TOKEN_ERROR_MESSAGE);
return;
}
let cancelled = false;
const loadEvent = async () => {
setStatus('loading');
setErrorCode(null);
setErrorMessage(null);
try {
setLoading(true);
setError(null);
const eventData = await fetchEvent(token);
if (cancelled) {
return;
}
setEvent(eventData);
setStatus('ready');
} catch (err) {
console.error('Failed to load event:', err);
setError(err instanceof Error ? err.message : 'Failed to load event');
} finally {
setLoading(false);
if (cancelled) {
return;
}
setEvent(null);
setStatus('error');
if (err instanceof FetchEventError) {
setErrorCode(err.code);
setErrorMessage(err.message);
} else if (err instanceof Error) {
setErrorCode('unknown');
setErrorMessage(err.message || 'Event konnte nicht geladen werden.');
} else {
setErrorCode('unknown');
setErrorMessage('Event konnte nicht geladen werden.');
}
}
};
loadEvent();
return () => {
cancelled = true;
};
}, [token]);
return {
event,
loading,
error,
status,
loading: status === 'loading',
error: errorMessage,
errorCode,
token: token ?? null,
};
}

View File

@@ -28,6 +28,12 @@ export function usePollStats(eventKey: string | null | undefined) {
headers: { 'Cache-Control': 'no-store' },
});
if (res.status === 304) return;
if (!res.ok) {
if (res.status === 404) {
setData({ onlineGuests: 0, tasksSolved: 0, latestPhotoAt: null });
}
return;
}
const json: StatsResponse = await res.json();
setData({
onlineGuests: json.online_guests ?? 0,

View File

@@ -1,7 +1,11 @@
import React from 'react';
import { createBrowserRouter, Outlet, useParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { createBrowserRouter, Outlet, useParams, Link } from 'react-router-dom';
import Header from './components/Header';
import BottomNav from './components/BottomNav';
import { useEventData } from './hooks/useEventData';
import { AlertTriangle, Loader2 } from 'lucide-react';
import type { FetchEventErrorCode } from './services/eventApi';
import { EventStatsProvider } from './context/EventStatsContext';
import { GuestIdentityProvider } from './context/GuestIdentityContext';
import LandingPage from './pages/LandingPage';
@@ -36,15 +40,7 @@ function HomeLayout() {
return (
<GuestIdentityProvider eventKey={token}>
<EventStatsProvider eventKey={token}>
<div className="pb-16">
<Header slug={token} />
<div className="px-4 py-3">
<Outlet />
</div>
<BottomNav />
</div>
</EventStatsProvider>
<EventBoundary token={token} />
</GuestIdentityProvider>
);
}
@@ -78,6 +74,30 @@ export const router = createBrowserRouter([
{ path: '*', element: <NotFoundPage /> },
]);
function EventBoundary({ token }: { token: string }) {
const { event, status, error, errorCode } = useEventData();
if (status === 'loading') {
return <EventLoadingView />;
}
if (status === 'error' || !event) {
return <EventErrorView code={errorCode} message={error} />;
}
return (
<EventStatsProvider eventKey={token}>
<div className="pb-16">
<Header slug={token} />
<div className="px-4 py-3">
<Outlet />
</div>
<BottomNav />
</div>
</EventStatsProvider>
);
}
function SetupLayout() {
const { token } = useParams<{ token: string }>();
if (!token) return null;
@@ -93,6 +113,95 @@ function SetupLayout() {
);
}
function EventLoadingView() {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 px-6 text-center">
<Loader2 className="h-10 w-10 animate-spin text-muted-foreground" aria-hidden />
<div className="space-y-1">
<p className="text-lg font-semibold text-foreground">Wir prüfen deinen Zugang...</p>
<p className="text-sm text-muted-foreground">Einen Moment bitte.</p>
</div>
</div>
);
}
interface EventErrorViewProps {
code: FetchEventErrorCode | null;
message: string | null;
}
function EventErrorView({ code, message }: EventErrorViewProps) {
const content = getErrorContent(code, message);
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-6 px-6 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-red-100 text-red-600">
<AlertTriangle className="h-8 w-8" aria-hidden />
</div>
<div className="space-y-2">
<h2 className="text-2xl font-semibold text-foreground">{content.title}</h2>
<p className="text-sm text-muted-foreground">{content.description}</p>
{content.hint && (
<p className="text-xs text-muted-foreground">{content.hint}</p>
)}
</div>
{content.ctaHref && content.ctaLabel && (
<Button asChild>
<Link to={content.ctaHref}>{content.ctaLabel}</Link>
</Button>
)}
</div>
);
}
function getErrorContent(
code: FetchEventErrorCode | null,
message: string | null,
) {
const base = (fallbackTitle: string, fallbackDescription: string, options?: { ctaLabel?: string; ctaHref?: string; hint?: string }) => ({
title: fallbackTitle,
description: message ?? fallbackDescription,
ctaLabel: options?.ctaLabel,
ctaHref: options?.ctaHref,
hint: options?.hint ?? null,
});
switch (code) {
case 'invalid_token':
return base('Zugriffscode ungültig', 'Der eingegebene Code konnte nicht verifiziert werden.', {
ctaLabel: 'Neuen Code anfordern',
ctaHref: '/event',
});
case 'token_revoked':
return base('Zugriffscode deaktiviert', 'Dieser Code wurde zurückgezogen. Bitte fordere einen neuen Code an.', {
ctaLabel: 'Neuen Code anfordern',
ctaHref: '/event',
});
case 'token_expired':
return base('Zugriffscode abgelaufen', 'Der Code ist nicht mehr gültig. Aktualisiere deinen Code, um fortzufahren.', {
ctaLabel: 'Code aktualisieren',
ctaHref: '/event',
});
case 'token_rate_limited':
return base('Zu viele Versuche', 'Es gab zu viele Eingaben in kurzer Zeit. Warte kurz und versuche es erneut.', {
hint: 'Tipp: Eine erneute Eingabe ist in wenigen Minuten möglich.',
});
case 'event_not_public':
return base('Event nicht öffentlich', 'Dieses Event ist aktuell nicht öffentlich zugänglich.', {
hint: 'Nimm Kontakt mit den Veranstalter:innen auf, um Zugang zu erhalten.',
});
case 'network_error':
return base('Verbindungsproblem', 'Wir konnten keine Verbindung zum Server herstellen. Prüfe deine Internetverbindung und versuche es erneut.');
case 'server_error':
return base('Server nicht erreichbar', 'Der Server reagiert derzeit nicht. Versuche es später erneut.');
default:
return base('Event nicht erreichbar', 'Wir konnten dein Event nicht laden. Bitte versuche es erneut.', {
ctaLabel: 'Zur Code-Eingabe',
ctaHref: '/event',
});
}
}
function SimpleLayout({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="pb-16">

View File

@@ -34,10 +34,138 @@ export interface EventStats {
latestPhotoAt: string | null;
}
export type FetchEventErrorCode =
| 'invalid_token'
| 'token_expired'
| 'token_revoked'
| 'token_rate_limited'
| 'event_not_public'
| 'network_error'
| 'server_error'
| 'unknown';
interface FetchEventErrorOptions {
code: FetchEventErrorCode;
message: string;
status?: number;
}
export class FetchEventError extends Error {
readonly code: FetchEventErrorCode;
readonly status?: number;
constructor({ code, message, status }: FetchEventErrorOptions) {
super(message);
this.name = 'FetchEventError';
this.code = code;
this.status = status;
}
}
const API_ERROR_CODES: FetchEventErrorCode[] = [
'invalid_token',
'token_expired',
'token_revoked',
'token_rate_limited',
'event_not_public',
];
function resolveErrorCode(rawCode: unknown, status: number): FetchEventErrorCode {
if (typeof rawCode === 'string') {
const normalized = rawCode.toLowerCase() as FetchEventErrorCode;
if ((API_ERROR_CODES as string[]).includes(normalized)) {
return normalized;
}
}
if (status === 429) return 'token_rate_limited';
if (status === 404) return 'event_not_public';
if (status === 410) return 'token_expired';
if (status === 401) return 'invalid_token';
if (status === 403) return 'token_revoked';
if (status >= 500) return 'server_error';
return 'unknown';
}
function defaultMessageForCode(code: FetchEventErrorCode): string {
switch (code) {
case 'invalid_token':
return 'Der eingegebene Zugriffscode ist ungültig.';
case 'token_revoked':
return 'Dieser Zugriffscode wurde deaktiviert. Bitte fordere einen neuen Code an.';
case 'token_expired':
return 'Dieser Zugriffscode ist abgelaufen.';
case 'token_rate_limited':
return 'Zu viele Versuche in kurzer Zeit. Bitte warte einen Moment und versuche es erneut.';
case 'event_not_public':
return 'Dieses Event ist nicht öffentlich verfügbar.';
case 'network_error':
return 'Keine Verbindung zum Server. Prüfe deine Internetverbindung und versuche es erneut.';
case 'server_error':
return 'Der Server ist gerade nicht erreichbar. Bitte versuche es später erneut.';
case 'unknown':
default:
return 'Event konnte nicht geladen werden.';
}
}
export async function fetchEvent(eventKey: string): Promise<EventData> {
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}`);
if (!res.ok) throw new Error('Event fetch failed');
return await res.json();
try {
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}`);
if (!res.ok) {
let apiMessage: string | null = null;
let rawCode: unknown;
try {
const data = await res.json();
rawCode = data?.error?.code ?? data?.code;
const message = data?.error?.message ?? data?.message;
if (typeof message === 'string' && message.trim() !== '') {
apiMessage = message.trim();
}
} catch {
// ignore parse errors and fall back to defaults
}
const code = resolveErrorCode(rawCode, res.status);
const message = apiMessage ?? defaultMessageForCode(code);
throw new FetchEventError({
code,
message,
status: res.status,
});
}
return await res.json();
} catch (error) {
if (error instanceof FetchEventError) {
throw error;
}
if (error instanceof TypeError) {
throw new FetchEventError({
code: 'network_error',
message: defaultMessageForCode('network_error'),
status: 0,
});
}
if (error instanceof Error) {
throw new FetchEventError({
code: 'unknown',
message: error.message || defaultMessageForCode('unknown'),
status: 0,
});
}
throw new FetchEventError({
code: 'unknown',
message: defaultMessageForCode('unknown'),
status: 0,
});
}
}
export async function fetchStats(eventKey: string): Promise<EventStats> {

View File

@@ -11,8 +11,18 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Sheet, SheetClose, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import { Separator } from '@/components/ui/separator';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { Sun, Moon, Menu, X, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
} from '@/components/ui/navigation-menu';
const Header: React.FC = () => {
const { auth } = usePage().props as any;
@@ -111,37 +121,50 @@ const Header: React.FC = () => {
<Link href={localizedPath('/')} className="text-2xl font-bold text-gray-800 dark:text-gray-200">
Die Fotospiel.App
</Link>
<nav className="hidden lg:flex items-center space-x-8">
{navItems.map((item) => (
item.children ? (
<DropdownMenu key={item.key}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="text-gray-700 hover:text-pink-600 hover:bg-pink-50 dark:text-gray-300 dark:hover:text-pink-400 dark:hover:bg-pink-950/20 font-sans-marketing text-lg font-medium transition-all duration-200">
{item.label}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{item.children.map((child) => (
<DropdownMenuItem asChild key={child.key}>
<Link href={child.href} className="w-full flex items-center">
{child.label}
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button
asChild
key={item.key}
variant="ghost"
className="text-gray-700 hover:text-pink-600 hover:bg-pink-50 dark:text-gray-300 dark:hover:text-pink-400 dark:hover:bg-pink-950/20 font-sans-marketing text-lg font-medium transition-all duration-200"
>
<Link href={item.href}>{item.label}</Link>
</Button>
)
))}
</nav>
<NavigationMenu className="hidden lg:flex flex-1 justify-center" viewport={false}>
<NavigationMenuList className="gap-2">
{navItems.map((item) => (
<NavigationMenuItem key={item.key}>
{item.children ? (
<>
<NavigationMenuTrigger className="bg-transparent text-gray-700 hover:text-pink-600 hover:bg-pink-50 dark:text-gray-300 dark:hover:text-pink-400 dark:hover:bg-pink-950/20 font-sans-marketing !text-lg font-medium">
{item.label}
</NavigationMenuTrigger>
<NavigationMenuContent className="min-w-[220px] rounded-md border bg-popover p-3 shadow-lg">
<ul className="flex flex-col gap-1">
{item.children.map((child) => (
<li key={child.key}>
<NavigationMenuLink asChild>
<Link
href={child.href}
className="flex items-center justify-between rounded-md px-3 py-2 !text-lg font-medium text-gray-700 transition hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900/60 font-sans-marketing"
>
{child.label}
<ChevronRight className="h-4 w-4" />
</Link>
</NavigationMenuLink>
</li>
))}
</ul>
</NavigationMenuContent>
</>
) : (
<NavigationMenuLink asChild>
<Link
href={item.href}
className={cn(
navigationMenuTriggerStyle(),
"bg-transparent !text-lg font-medium text-gray-700 hover:bg-pink-50 hover:text-pink-600 dark:text-gray-300 dark:hover:bg-pink-950/20 dark:hover:text-pink-400 font-sans-marketing"
)}
>
{item.label}
</Link>
</NavigationMenuLink>
)}
</NavigationMenuItem>
))}
</NavigationMenuList>
</NavigationMenu>
<div className="hidden lg:flex items-center space-x-4">
<Button
variant="ghost"
@@ -180,18 +203,18 @@ const Header: React.FC = () => {
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<DropdownMenuItem asChild className="font-sans-marketing">
<Link href={localizedPath('/profile')}>
Profil
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<DropdownMenuItem asChild className="font-sans-marketing">
<Link href={localizedPath('/profile/orders')}>
Bestellungen
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<DropdownMenuItem onClick={handleLogout} className="font-sans-marketing">
Abmelden
</DropdownMenuItem>
</DropdownMenuContent>
@@ -200,16 +223,10 @@ const Header: React.FC = () => {
<>
<Link
href={localizedPath('/login')}
className="text-gray-700 hover:text-pink-600 dark:text-gray-300 dark:hover:text-pink-400 font-medium transition-colors duration-200"
className="text-gray-700 hover:text-pink-600 dark:text-gray-300 dark:hover:text-pink-400 font-medium transition-colors duration-200 font-sans-marketing"
>
{t('header.login')}
</Link>
<Link
href={localizedPath('/register')}
className="bg-pink-500 text-white px-4 py-2 rounded hover:bg-pink-600 dark:bg-pink-600 dark:hover:bg-pink-700"
>
{t('header.register')}
</Link>
</>
)}
</div>
@@ -229,31 +246,40 @@ const Header: React.FC = () => {
<SheetHeader className="text-left">
<SheetTitle className="text-xl font-semibold">Menü</SheetTitle>
</SheetHeader>
<nav className="flex flex-col gap-4">
<nav className="flex flex-col gap-2">
{navItems.map((item) => (
item.children ? (
<div key={item.key} className="space-y-2">
<p className="text-sm font-semibold uppercase text-muted-foreground">{item.label}</p>
<div className="flex flex-col gap-2">
{item.children.map((child) => (
<SheetClose asChild key={child.key}>
<Link
href={child.href}
className="flex items-center justify-between rounded-md border border-transparent bg-gray-50 px-3 py-2 text-base font-medium text-gray-700 transition hover:border-gray-200 hover:bg-gray-100 dark:bg-gray-900/40 dark:text-gray-200 dark:hover:bg-gray-800"
onClick={handleNavSelect}
>
<span>{child.label}</span>
<ChevronRight className="h-4 w-4" />
</Link>
</SheetClose>
))}
</div>
</div>
<Accordion
key={item.key}
type="single"
collapsible
className="w-full"
>
<AccordionItem value={`${item.key}-group`}>
<AccordionTrigger className="flex w-full items-center justify-between rounded-md border border-transparent bg-gray-50 px-3 py-2 text-base font-semibold text-gray-700 transition hover:border-gray-200 hover:bg-gray-100 dark:bg-gray-900/40 dark:text-gray-200 dark:hover:bg-gray-800 font-sans-marketing">
{item.label}
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-2 pt-2">
{item.children.map((child) => (
<SheetClose asChild key={child.key}>
<Link
href={child.href}
className="flex items-center justify-between rounded-md border border-transparent px-3 py-2 text-base font-medium text-gray-700 transition hover:border-gray-200 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900/60 font-sans-marketing"
onClick={handleNavSelect}
>
<span>{child.label}</span>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</Link>
</SheetClose>
))}
</AccordionContent>
</AccordionItem>
</Accordion>
) : (
<SheetClose asChild key={item.key}>
<Link
href={item.href}
className="rounded-md border border-transparent px-3 py-2 text-base font-medium text-gray-700 transition hover:border-gray-200 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900/60"
className="rounded-md border border-transparent px-3 py-2 text-base font-medium text-gray-700 transition hover:border-gray-200 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900/60 font-sans-marketing"
onClick={handleNavSelect}
>
{item.label}
@@ -295,7 +321,7 @@ const Header: React.FC = () => {
<SheetClose asChild>
<Link
href={localizedPath('/profile')}
className="rounded-md border border-transparent px-3 py-2 text-base font-medium text-gray-700 transition hover:border-gray-200 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900/60"
className="rounded-md border border-transparent px-3 py-2 text-base font-medium text-gray-700 transition hover:border-gray-200 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900/60 font-sans-marketing"
onClick={handleNavSelect}
>
Profil
@@ -304,13 +330,13 @@ const Header: React.FC = () => {
<SheetClose asChild>
<Link
href={localizedPath('/profile/orders')}
className="rounded-md border border-transparent px-3 py-2 text-base font-medium text-gray-700 transition hover:border-gray-200 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900/60"
className="rounded-md border border-transparent px-3 py-2 text-base font-medium text-gray-700 transition hover:border-gray-200 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900/60 font-sans-marketing"
onClick={handleNavSelect}
>
Bestellungen
</Link>
</SheetClose>
<Button variant="destructive" onClick={handleLogout}>
<Button variant="destructive" onClick={handleLogout} className="font-sans-marketing">
Abmelden
</Button>
</>
@@ -319,21 +345,12 @@ const Header: React.FC = () => {
<SheetClose asChild>
<Link
href={localizedPath('/login')}
className="rounded-md border border-transparent px-3 py-2 text-base font-medium text-gray-700 transition hover:border-gray-200 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900/60"
className="rounded-md border border-transparent px-3 py-2 text-base font-medium text-gray-700 transition hover:border-gray-200 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900/60 font-sans-marketing"
onClick={handleNavSelect}
>
{t('header.login')}
</Link>
</SheetClose>
<SheetClose asChild>
<Link
href={localizedPath('/register')}
className="rounded-md bg-pink-500 px-3 py-2 text-base font-semibold text-white transition hover:bg-pink-600"
onClick={handleNavSelect}
>
{t('header.register')}
</Link>
</SheetClose>
</div>
)}
</div>

View File

@@ -0,0 +1,22 @@
import '@testing-library/jest-dom';
import { vi } from 'vitest';
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
return {
...actual,
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (options && typeof options.defaultValue === 'string') {
return options.defaultValue;
}
return key;
},
i18n: {
language: 'de',
changeLanguage: vi.fn(),
},
}),
Trans: ({ children }: { children: React.ReactNode }) => children,
};
});

View File

@@ -70,6 +70,8 @@ return [
'table' => [
'tenant' => 'Mandant',
'join' => 'Beitreten',
'join_tokens_total' => 'Join-Tokens: :count',
'join_tokens_missing' => 'Noch keine Join-Tokens erstellt',
],
'actions' => [
'toggle_active' => 'Aktiv umschalten',
@@ -82,9 +84,21 @@ return [
'join_link_copied' => 'Beitrittslink kopiert',
],
'join_link' => [
'event_label' => 'Veranstaltung',
'deprecated_notice' => 'Der direkte Zugriff über den Event-Slug :slug wurde deaktiviert. Teile die Join-Tokens unten oder öffne in der Admin-App „QR & Einladungen“, um neue Codes zu verwalten.',
'open_admin' => 'Admin-App öffnen',
'link_label' => 'Beitrittslink',
'copy_link' => 'Kopieren',
'no_tokens' => 'Noch keine Join-Tokens vorhanden. Erstelle im Admin-Bereich ein Token, um dein Event zu teilen.',
'token_default' => 'Einladung #:id',
'token_usage' => 'Nutzung: :usage / :limit',
'token_active' => 'Aktiv',
'token_inactive' => 'Deaktiviert',
'qr_code_label' => 'QRCode',
'note_html' => 'Hinweis: Der QRCode wird über einen externen QRService generiert. Für eine selbst gehostete Lösung können wir später eine interne QRGenerierung ergänzen.',
'layouts_heading' => 'Drucklayouts',
'layouts_fallback' => 'Layout-Übersicht öffnen',
'token_expiry' => 'Läuft ab am :date',
],
],
@@ -219,4 +233,13 @@ return [
'shell' => [
'tenant_admin_title' => 'TenantAdmin',
],
'errors' => [
'forbidden' => [
'title' => 'Kein Zugriff',
'message' => 'Du hast keine Berechtigung, diesen Bereich des Admin-Panels zu öffnen.',
'hint' => 'Bitte prüfe, ob dein Mandantenpaket aktiv ist oder wende dich an den Support, wenn du Hilfe benötigst.',
'cta' => 'Zur Startseite',
],
],
];

View File

@@ -12,6 +12,7 @@ return [
'username_or_email' => 'Username oder E-Mail',
'password' => 'Passwort',
'remember' => 'Angemeldet bleiben',
'remember_me' => 'Angemeldet bleiben',
'submit' => 'Anmelden',
],
@@ -34,6 +35,8 @@ return [
'notice' => 'Bitte bestätigen Sie Ihre E-Mail-Adresse.',
'resend' => 'E-Mail erneut senden',
],
'verify_email' => 'Bitte bestätigen Sie Ihre E-Mail-Adresse. Wir haben dir eine Bestätigungs-E-Mail geschickt.',
'no_tenant_associated' => 'Deinem Konto ist kein Tenant zugeordnet. Bitte kontaktiere den Support.',
'header' => [
'home' => 'Startseite',
'packages' => 'Pakete',

View File

@@ -70,6 +70,8 @@ return [
'table' => [
'tenant' => 'Tenant',
'join' => 'Join',
'join_tokens_total' => 'Join tokens: :count',
'join_tokens_missing' => 'No join tokens created yet',
],
'actions' => [
'toggle_active' => 'Toggle Active',
@@ -82,9 +84,20 @@ return [
'join_link_copied' => 'Join link copied',
],
'join_link' => [
'event_label' => 'Event',
'slug_label' => 'Slug: :slug',
'link_label' => 'Join Link',
'qr_code_label' => 'QR Code',
'note_html' => 'Note: The QR code is generated via an external QR service. For a self-hosted option, we can add internal generation later.',
'copy_link' => 'Copy',
'no_tokens' => 'No tokens available yet. Create a token in the admin app to share your event.',
'token_default' => 'Invitation #:id',
'token_usage' => 'Usage: :usage / :limit',
'token_active' => 'Active',
'token_inactive' => 'Inactive',
'layouts_heading' => 'Printable layouts',
'layouts_fallback' => 'Open layout overview',
'token_expiry' => 'Expires at :date',
'deprecated_notice' => 'Direct access via slug :slug has been retired. Share the join tokens below or manage QR layouts in the admin app.',
'open_admin' => 'Open admin app',
],
],
@@ -206,4 +219,13 @@ return [
'shell' => [
'tenant_admin_title' => 'Tenant Admin',
],
'errors' => [
'forbidden' => [
'title' => 'Access denied',
'message' => 'You do not have permission to access this area of the admin panel.',
'hint' => 'Please verify that your tenant subscription is active or contact support if you believe this is a mistake.',
'cta' => 'Return to start page',
],
],
];

View File

@@ -6,4 +6,14 @@ return [
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
'login_success' => 'You are now logged in.',
'login_failed' => 'These credentials do not match our records.',
'login' => [
'title' => 'Sign in',
'username_or_email' => 'Username or email address',
'password' => 'Password',
'remember' => 'Stay signed in',
'remember_me' => 'Stay signed in',
'submit' => 'Sign in',
],
'verify_email' => 'Your email address is not verified. Please check your inbox for the verification link.',
'no_tenant_associated' => 'We could not find a tenant for your account. Please contact support.',
];

View File

@@ -0,0 +1,12 @@
<?php
return [
'title' => 'Tenant-Login',
'form' => [
'actions' => [
'authenticate' => [
'label' => 'Anmelden',
],
],
],
];

View File

@@ -0,0 +1,12 @@
<?php
return [
'title' => 'Tenant Login',
'form' => [
'actions' => [
'authenticate' => [
'label' => 'Sign in',
],
],
],
];

View File

@@ -5,6 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ __('admin.shell.tenant_admin_title') }}</title>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#f43f5e">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
@viteReactRefresh
@vite('resources/js/admin/main.tsx')
</head>

View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ __('admin.errors.forbidden.title') }}</title>
@vite('resources/css/app.css')
</head>
<body class="min-h-screen bg-slate-950 text-slate-100">
<div class="flex min-h-screen items-center justify-center px-6 py-12">
<div class="max-w-lg rounded-3xl border border-white/10 bg-white/5 p-10 shadow-2xl backdrop-blur">
<p class="text-sm uppercase tracking-widest text-pink-400">403</p>
<h1 class="mt-2 text-3xl font-semibold text-white">{{ __('admin.errors.forbidden.title') }}</h1>
<p class="mt-4 text-base text-slate-200">{{ __('admin.errors.forbidden.message') }}</p>
<p class="mt-2 text-sm text-slate-400">{{ __('admin.errors.forbidden.hint') }}</p>
<div class="mt-8">
<a href="{{ url('/') }}" class="inline-flex items-center rounded-full bg-pink-500 px-5 py-2 text-sm font-semibold text-white shadow-lg transition hover:bg-pink-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-pink-500">
{{ __('admin.errors.forbidden.cta') }}
</a>
</div>
</div>
</div>
</body>
</html>

View File

@@ -1,16 +1,125 @@
<div class="space-y-3">
<div class="text-sm">{{ __('admin.events.join_link.link_label') }}</div>
<div class="rounded border bg-gray-50 p-2 text-sm dark:bg-gray-900">
<a href="{{ $link }}" target="_blank" class="underline">
{{ $link }}
<div class="space-y-5">
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 dark:border-amber-400/60 dark:bg-amber-500/10 dark:text-amber-100">
<div class="flex flex-col gap-1">
<div class="text-xs font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-300">{{ __('admin.events.join_link.event_label') }}</div>
<div class="text-base font-semibold text-amber-900 dark:text-amber-100">{{ $event->name }}</div>
</div>
<p class="mt-3 text-xs leading-relaxed text-amber-700 dark:text-amber-200">
{{ __('admin.events.join_link.deprecated_notice', ['slug' => $event->slug]) }}
</p>
<a
href="{{ url('/event-admin/events/' . $event->slug) }}"
target="_blank"
rel="noreferrer"
class="mt-3 inline-flex items-center gap-2 rounded bg-amber-600 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-white transition hover:bg-amber-700 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 dark:hover:bg-amber-500"
>
{{ __('admin.events.join_link.open_admin') }}
</a>
</div>
<div class="text-sm">{{ __('admin.events.join_link.qr_code_label') }}</div>
<div class="flex items-center justify-center">
{!! \SimpleSoftwareIO\QrCode\Facades\QrCode::size(300)->generate($link) !!}
</div>
<div class="text-xs text-muted-foreground">
{!! __('admin.events.join_link.note_html') !!}
</div>
</div>
@if ($tokens->isEmpty())
<div class="rounded border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 dark:border-amber-400/60 dark:bg-amber-500/10 dark:text-amber-100">
{{ __('admin.events.join_link.no_tokens') }}
</div>
@else
<div class="space-y-4">
@foreach ($tokens as $token)
<div class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-700 dark:bg-slate-900/80">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold text-slate-800 dark:text-slate-100">
{{ $token['label'] ?? __('admin.events.join_link.token_default', ['id' => $token['id']]) }}
</div>
<div class="text-xs text-slate-500 dark:text-slate-400">
{{ __('admin.events.join_link.token_usage', [
'usage' => $token['usage_count'],
'limit' => $token['usage_limit'] ?? '∞',
]) }}
</div>
</div>
<div>
@if ($token['is_active'])
<span class="rounded-full bg-emerald-100 px-3 py-1 text-xs font-medium text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-200">
{{ __('admin.events.join_link.token_active') }}
</span>
@else
<span class="rounded-full bg-slate-200 px-3 py-1 text-xs font-medium text-slate-700 dark:bg-slate-700 dark:text-slate-200">
{{ __('admin.events.join_link.token_inactive') }}
</span>
@endif
</div>
</div>
<div class="mt-3 space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
{{ __('admin.events.join_link.link_label') }}
</div>
<div class="flex flex-wrap items-center gap-3">
<code class="rounded bg-slate-100 px-2 py-1 text-xs text-slate-700 dark:bg-slate-800 dark:text-slate-100">
{{ $token['url'] }}
</code>
<button
x-data
@click.prevent="navigator.clipboard.writeText('{{ $token['url'] }}')"
class="rounded border border-slate-200 px-2 py-1 text-xs font-medium text-slate-600 transition hover:bg-slate-100 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
>
{{ __('admin.events.join_link.copy_link') }}
</button>
</div>
</div>
@if (!empty($token['layouts']))
<div class="mt-4 space-y-3">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
{{ __('admin.events.join_link.layouts_heading') }}
</div>
<div class="grid gap-3 md:grid-cols-2">
@foreach ($token['layouts'] as $layout)
<div class="rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs text-slate-700 dark:border-slate-700 dark:bg-slate-800/70 dark:text-slate-200">
<div class="font-semibold text-slate-900 dark:text-slate-100">
{{ $layout['name'] }}
</div>
@if (!empty($layout['subtitle']))
<div class="text-[11px] text-slate-500 dark:text-slate-400">
{{ $layout['subtitle'] }}
</div>
@endif
<div class="mt-2 flex flex-wrap gap-2">
@foreach ($layout['download_urls'] as $format => $href)
<a
href="{{ $href }}"
target="_blank"
rel="noreferrer"
class="inline-flex items-center gap-1 rounded border border-amber-300 bg-amber-100 px-2 py-1 text-[11px] font-medium text-amber-800 transition hover:bg-amber-200 dark:border-amber-500/50 dark:bg-amber-500/10 dark:text-amber-200 dark:hover:bg-amber-500/20"
>
{{ strtoupper($format) }}
</a>
@endforeach
</div>
</div>
@endforeach
</div>
</div>
@elseif(!empty($token['layouts_url']))
<div class="mt-4">
<a
href="{{ $token['layouts_url'] }}"
target="_blank"
rel="noreferrer"
class="inline-flex items-center gap-1 text-xs font-medium text-amber-700 underline decoration-dotted hover:text-amber-800 dark:text-amber-300"
>
{{ __('admin.events.join_link.layouts_fallback') }}
</a>
</div>
@endif
@if ($token['expires_at'])
<div class="mt-4 text-xs text-slate-500 dark:text-slate-400">
{{ __('admin.events.join_link.token_expiry', ['date' => \Carbon\Carbon::parse($token['expires_at'])->isoFormat('LLL')]) }}
</div>
@endif
</div>
@endforeach
</div>
@endif
</div>

View File

@@ -0,0 +1,206 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>{{ $eventName }} Einladungs-QR</title>
<style>
:root {
--accent: {{ $layout['accent'] }};
--secondary: {{ $layout['secondary'] }};
--text: {{ $layout['text'] }};
--badge: {{ $layout['badge'] }};
--container-padding: 48px;
--qr-size: 340px;
--background: {{ $backgroundStyle }};
}
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
color: var(--text);
}
body {
min-height: 100%;
position: relative;
}
.layout-wrapper {
width: 100%;
height: 100%;
padding: var(--container-padding);
display: flex;
flex-direction: column;
justify-content: space-between;
background: var(--background);
}
.header {
display: flex;
flex-direction: column;
gap: 12px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 10px;
background: var(--badge);
color: #fff;
padding: 10px 18px;
border-radius: 999px;
font-size: 15px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
width: fit-content;
}
.event-title {
font-size: 72px;
font-weight: 700;
line-height: 1.05;
margin: 0;
}
.subtitle {
font-size: 24px;
font-weight: 500;
color: rgba(17, 24, 39, 0.7);
margin: 0;
}
.content {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 40px;
margin-top: 48px;
align-items: center;
}
.info-card {
background: rgba(255, 255, 255, 0.65);
border-radius: 32px;
padding: 32px;
display: flex;
flex-direction: column;
gap: 18px;
box-shadow: 0 18px 50px rgba(15, 23, 42, 0.08);
}
.info-card h2 {
margin: 0;
font-size: 32px;
font-weight: 700;
}
.info-card p {
margin: 0;
font-size: 18px;
line-height: 1.6;
}
.instructions {
margin: 0;
padding-left: 22px;
display: flex;
flex-direction: column;
gap: 12px;
}
.instructions li {
font-size: 18px;
line-height: 1.5;
}
.link-box {
background: var(--secondary);
color: var(--text);
font-family: "Courier New", Courier, monospace;
border-radius: 16px;
padding: 18px 20px;
font-size: 18px;
word-break: break-all;
}
.qr-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
}
.qr-wrapper img {
width: var(--qr-size);
height: var(--qr-size);
}
.cta {
font-size: 20px;
font-weight: 600;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.footer {
margin-top: 48px;
display: flex;
justify-content: space-between;
align-items: flex-end;
font-size: 16px;
color: rgba(17, 24, 39, 0.6);
}
.footer strong {
color: var(--accent);
}
</style>
</head>
<body>
<div class="layout-wrapper">
<div class="header">
<span class="badge">Digitale Gästebox</span>
<h1 class="event-title">{{ $eventName }}</h1>
@if(!empty($layout['subtitle']))
<p class="subtitle">{{ $layout['subtitle'] }}</p>
@endif
</div>
<div class="content">
<div class="info-card">
<h2>So funktioniert&rsquo;s</h2>
<p>{{ $layout['description'] }}</p>
@if(!empty($layout['instructions']))
<ul class="instructions">
@foreach($layout['instructions'] as $step)
<li>{{ $step }}</li>
@endforeach
</ul>
@endif
<div>
<div class="cta">Alternative zum Einscannen</div>
<div class="link-box">{{ $tokenUrl }}</div>
</div>
</div>
<div class="qr-wrapper">
<img src="{{ $qrPngDataUri }}" alt="QR-Code zum Event {{ $eventName }}">
<div class="cta">Scan mich & starte direkt</div>
</div>
</div>
<div class="footer">
<div>
<strong>{{ config('app.name', 'Fotospiel') }}</strong> Gästebox & Fotochallenges
</div>
<div>Einladungsgültigkeit: {{ $joinToken->expires_at ? $joinToken->expires_at->isoFormat('LLL') : 'bis Widerruf' }}</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,159 @@
@php
$width = $layout['svg']['width'] ?? 1080;
$height = $layout['svg']['height'] ?? 1520;
$background = $layout['background'] ?? '#FFFFFF';
$gradient = $layout['background_gradient'] ?? null;
$gradientId = $gradient ? 'bg-gradient-'.uniqid() : null;
$accent = $layout['accent'] ?? '#000000';
$secondary = $layout['secondary'] ?? '#E5E7EB';
$textColor = $layout['text'] ?? '#111827';
$badgeColor = $layout['badge'] ?? $accent;
$instructions = $layout['instructions'] ?? [];
$description = $layout['description'] ?? '';
$subtitle = $layout['subtitle'] ?? '';
$titleLines = explode("\n", wordwrap($eventName, 18, "\n", true));
$subtitleLines = $subtitle !== '' ? explode("\n", wordwrap($subtitle, 36, "\n", true)) : [];
$descriptionLines = $description !== '' ? explode("\n", wordwrap($description, 40, "\n", true)) : [];
$instructionStartY = 870;
$instructionSpacing = 56;
if ($gradient) {
$angle = (float) ($gradient['angle'] ?? 180);
$angleRad = deg2rad($angle);
$x1 = 0.5 - cos($angleRad) / 2;
$y1 = 0.5 - sin($angleRad) / 2;
$x2 = 0.5 + cos($angleRad) / 2;
$y2 = 0.5 + sin($angleRad) / 2;
$x1Attr = number_format($x1, 4, '.', '');
$y1Attr = number_format($y1, 4, '.', '');
$x2Attr = number_format($x2, 4, '.', '');
$y2Attr = number_format($y2, 4, '.', '');
}
@endphp
<svg width="{{ $width }}" height="{{ $height }}" viewBox="0 0 {{ $width }} {{ $height }}" xmlns="http://www.w3.org/2000/svg">
<defs>
@if($gradientId)
<linearGradient id="{{ $gradientId }}" x1="{{ $x1Attr }}" y1="{{ $y1Attr }}" x2="{{ $x2Attr }}" y2="{{ $y2Attr }}">
@php
$stops = $gradient['stops'] ?? [];
$stopCount = max(count($stops) - 1, 1);
@endphp
@foreach($stops as $index => $stopColor)
@php
$offset = $stopCount > 0 ? ($index / $stopCount) * 100 : 0;
@endphp
<stop offset="{{ number_format($offset, 2, '.', '') }}%" stop-color="{{ $stopColor }}"/>
@endforeach
</linearGradient>
@endif
</defs>
<style>
.title-line {
font-family: 'Montserrat', 'Helvetica Neue', Arial, sans-serif;
font-size: 82px;
font-weight: 700;
letter-spacing: -1px;
}
.subtitle-line {
font-family: 'Lora', 'Georgia', serif;
font-size: 32px;
font-weight: 500;
}
.description-line {
font-family: 'Montserrat', 'Helvetica Neue', Arial, sans-serif;
font-size: 30px;
font-weight: 400;
line-height: 1.4;
}
.badge-text {
font-family: 'Montserrat', 'Helvetica Neue', Arial, sans-serif;
font-size: 28px;
font-weight: 600;
letter-spacing: 4px;
text-transform: uppercase;
}
.instruction-bullet {
font-family: 'Montserrat', 'Helvetica Neue', Arial, sans-serif;
font-size: 30px;
font-weight: 600;
}
.instruction-text {
font-family: 'Montserrat', 'Helvetica Neue', Arial, sans-serif;
font-size: 30px;
font-weight: 400;
}
.small-label {
font-family: 'Montserrat', 'Helvetica Neue', Arial, sans-serif;
font-size: 28px;
font-weight: 600;
letter-spacing: 3px;
text-transform: uppercase;
}
.link-text {
font-family: 'Courier New', monospace;
font-size: 30px;
}
.footer-text {
font-family: 'Montserrat', 'Helvetica Neue', Arial, sans-serif;
font-size: 26px;
font-weight: 400;
}
.footer-strong {
font-weight: 700;
}
</style>
<rect x="0" y="0" width="{{ $width }}" height="{{ $height }}" fill="{{ $gradientId ? 'url(#'.$gradientId.')' : $background }}" />
<rect x="70" y="380" width="500" height="600" rx="46" fill="rgba(255,255,255,0.78)" />
<rect x="600" y="420" width="380" height="380" rx="36" fill="rgba(255,255,255,0.88)" />
<rect x="640" y="780" width="300" height="6" rx="3" fill="{{ $accent }}" opacity="0.6" />
<rect x="80" y="120" width="250" height="70" rx="35" fill="{{ $badgeColor }}" />
<text x="205" y="165" text-anchor="middle" fill="#FFFFFF" class="badge-text">Digitale Gästebox</text>
@foreach($titleLines as $index => $line)
<text x="80" y="{{ 260 + $index * 88 }}" fill="{{ $textColor }}" class="title-line">{{ e($line) }}</text>
@endforeach
@php
$subtitleOffset = 260 + count($titleLines) * 88 + 40;
@endphp
@foreach($subtitleLines as $index => $line)
<text x="80" y="{{ $subtitleOffset + $index * 44 }}" fill="{{ $secondary }}" class="subtitle-line">{{ e($line) }}</text>
@endforeach
@php
$descriptionOffset = $subtitleOffset + (count($subtitleLines) ? count($subtitleLines) * 44 + 60 : 40);
@endphp
@foreach($descriptionLines as $index => $line)
<text x="110" y="{{ $descriptionOffset + $index * 48 }}" fill="{{ $textColor }}" class="description-line">{{ e($line) }}</text>
@endforeach
<text x="120" y="760" fill="{{ $accent }}" class="small-label">SO FUNKTIONIERT'S</text>
@foreach($instructions as $index => $step)
@php
$lineY = $instructionStartY + $index * $instructionSpacing;
@endphp
<circle cx="120" cy="{{ $lineY - 18 }}" r="10" fill="{{ $accent }}" />
<text x="150" y="{{ $lineY }}" fill="{{ $textColor }}" class="instruction-text">{{ e($step) }}</text>
@endforeach
<text x="640" y="760" fill="{{ $accent }}" class="small-label">ALTERNATIVER LINK</text>
<rect x="630" y="790" width="320" height="120" rx="22" fill="rgba(0,0,0,0.08)" />
<text x="650" y="850" fill="{{ $textColor }}" class="link-text">{{ e($tokenUrl) }}</text>
<image href="{{ $qrPngDataUri }}" x="620" y="440" width="{{ $layout['qr']['size_px'] ?? 340 }}" height="{{ $layout['qr']['size_px'] ?? 340 }}" />
<text x="820" y="820" text-anchor="middle" fill="{{ $accent }}" class="small-label">JETZT SCANNEN</text>
<text x="120" y="{{ $height - 160 }}" fill="rgba(17,24,39,0.6)" class="footer-text">
<tspan class="footer-strong" fill="{{ $accent }}">{{ e(config('app.name', 'Fotospiel')) }}</tspan>
&nbsp; Gästebox & Fotochallenges
</text>
<text x="{{ $width - 120 }}" y="{{ $height - 160 }}" text-anchor="end" fill="rgba(17,24,39,0.6)" class="footer-text">
Einladung gültig: {{ $joinToken->expires_at ? $joinToken->expires_at->isoFormat('LLL') : 'bis Widerruf' }}
</text>
</svg>