- 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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
10
resources/js/admin/i18n/locales/de/auth.json
Normal file
10
resources/js/admin/i18n/locales/de/auth.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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)",
|
||||
|
||||
10
resources/js/admin/i18n/locales/en/auth.json
Normal file
10
resources/js/admin/i18n/locales/en/auth.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -633,3 +633,5 @@ export default function WelcomeOrderSummaryPage() {
|
||||
</TenantWelcomeLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export { StripeCheckoutForm, PayPalCheckout };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>
|
||||
|
||||
22
resources/js/setupTests.ts
Normal file
22
resources/js/setupTests.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
@@ -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' => 'QR‑Code',
|
||||
'note_html' => 'Hinweis: Der QR‑Code wird über einen externen QR‑Service generiert. Für eine selbst gehostete Lösung können wir später eine interne QR‑Generierung 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' => 'Tenant‑Admin',
|
||||
],
|
||||
|
||||
'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',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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.',
|
||||
];
|
||||
|
||||
12
resources/lang/vendor/filament-panels/de/pages/auth/login.php
vendored
Normal file
12
resources/lang/vendor/filament-panels/de/pages/auth/login.php
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Tenant-Login',
|
||||
'form' => [
|
||||
'actions' => [
|
||||
'authenticate' => [
|
||||
'label' => 'Anmelden',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
12
resources/lang/vendor/filament-panels/en/pages/auth/login.php
vendored
Normal file
12
resources/lang/vendor/filament-panels/en/pages/auth/login.php
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Tenant Login',
|
||||
'form' => [
|
||||
'actions' => [
|
||||
'authenticate' => [
|
||||
'label' => 'Sign in',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -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>
|
||||
|
||||
24
resources/views/errors/403.blade.php
Normal file
24
resources/views/errors/403.blade.php
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
206
resources/views/layouts/join-token/pdf.blade.php
Normal file
206
resources/views/layouts/join-token/pdf.blade.php
Normal 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’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>
|
||||
159
resources/views/layouts/join-token/svg.blade.php
Normal file
159
resources/views/layouts/join-token/svg.blade.php
Normal 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>
|
||||
– 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>
|
||||
Reference in New Issue
Block a user