Fotospiel GmbH entfernt, Jon Token des demo events gefixt + demoeventseeder. Favicon auf ".ico" gesetzt.

This commit is contained in:
Codex Agent
2025-11-16 16:24:30 +01:00
parent 4f78546ba3
commit 5290072ffe
23 changed files with 505 additions and 262 deletions

View File

@@ -298,8 +298,8 @@ class EventPublicController extends BaseController
return ApiError::response( return ApiError::response(
'token_rate_limited', 'token_rate_limited',
'Too Many Attempts', $this->tokenErrorTitle('token_rate_limited'),
'Too many invalid join token attempts. Try again later.', __('api.join_tokens.invalid_attempts_message'),
Response::HTTP_TOO_MANY_REQUESTS, Response::HTTP_TOO_MANY_REQUESTS,
array_merge($context, ['rate_limiter_key' => $rateLimiterKey]) array_merge($context, ['rate_limiter_key' => $rateLimiterKey])
); );
@@ -333,21 +333,22 @@ class EventPublicController extends BaseController
private function tokenErrorMessage(string $code): string private function tokenErrorMessage(string $code): string
{ {
return match ($code) { return match ($code) {
'invalid_token' => 'The provided join token is invalid.', 'invalid_token' => __('api.join_tokens.invalid_message'),
'token_expired' => 'The join token has expired.', 'token_expired' => __('api.join_tokens.expired_message'),
'token_revoked' => 'The join token has been revoked.', 'token_revoked' => __('api.join_tokens.revoked_message'),
default => 'Access denied.', 'token_rate_limited' => __('api.join_tokens.rate_limited_message'),
default => __('api.join_tokens.default_message'),
}; };
} }
private function tokenErrorTitle(string $code): string private function tokenErrorTitle(string $code): string
{ {
return match ($code) { return match ($code) {
'invalid_token' => 'Invalid Join Token', 'invalid_token' => __('api.join_tokens.invalid_title'),
'token_expired' => 'Join Token Expired', 'token_expired' => __('api.join_tokens.expired_title'),
'token_revoked' => 'Join Token Revoked', 'token_revoked' => __('api.join_tokens.revoked_title'),
'token_rate_limited' => 'Join Token Rate Limited', 'token_rate_limited' => __('api.join_tokens.rate_limited_title'),
default => 'Access Denied', default => __('api.join_tokens.default_title'),
}; };
} }

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Mail\ContactConfirmation; use App\Mail\ContactConfirmation;
use App\Models\BlogPost; use App\Models\BlogPost;
use App\Models\Event;
use App\Models\CheckoutSession; use App\Models\CheckoutSession;
use App\Models\Package; use App\Models\Package;
use App\Models\PackagePurchase; use App\Models\PackagePurchase;
@@ -14,6 +15,7 @@ use App\Services\Paddle\PaddleCheckoutService;
use App\Support\Concerns\PresentsPackages; use App\Support\Concerns\PresentsPackages;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@@ -258,9 +260,9 @@ class MarketingController extends Controller
return null; return null;
} }
public function blogIndex(Request $request) public function blogIndex(Request $request, string $locale)
{ {
$locale = $request->get('locale', app()->getLocale()); $locale = $locale ?: app()->getLocale();
Log::info('Blog Index Debug - Initial', [ Log::info('Blog Index Debug - Initial', [
'locale' => $locale, 'locale' => $locale,
'full_url' => $request->fullUrl(), 'full_url' => $request->fullUrl(),
@@ -288,31 +290,32 @@ class MarketingController extends Controller
Log::info('Blog Index Debug - With Translation', ['count' => $totalWithTranslation, 'locale' => $locale]); Log::info('Blog Index Debug - With Translation', ['count' => $totalWithTranslation, 'locale' => $locale]);
$posts = $query->orderBy('published_at', 'desc') $posts = $query->orderBy('published_at', 'desc')
->paginate(8); ->paginate(4)
->through(function (BlogPost $post) use ($locale) {
// Transform posts to include translated strings for the current locale return [
$posts->getCollection()->transform(function ($post) use ($locale) { 'id' => $post->id,
$post->title = $post->getTranslation('title', $locale) ?? $post->getTranslation('title', 'de') ?? ''; 'slug' => $post->slug,
$post->excerpt = $post->getTranslation('excerpt', $locale) ?? $post->getTranslation('excerpt', 'de') ?? ''; 'title' => $post->getTranslation('title', $locale) ?? $post->getTranslation('title', 'de') ?? '',
$post->content = $post->getTranslation('content', $locale) ?? $post->getTranslation('content', 'de') ?? ''; 'excerpt' => $post->getTranslation('excerpt', $locale) ?? $post->getTranslation('excerpt', 'de') ?? '',
'featured_image' => $post->featured_image ?? $post->banner_url ?? null,
// Author name is a string, no translation needed; author is loaded via with('author') 'published_at' => optional($post->published_at)->toDateString(),
return $post; 'author' => $post->author ? ['name' => $post->author->name] : null,
}); ];
});
Log::info('Blog Index Debug - Final Posts', [ Log::info('Blog Index Debug - Final Posts', [
'count' => $posts->count(), 'count' => $posts->count(),
'total' => $posts->total(), 'total' => $posts->total(),
'posts_data' => $posts->toArray(), 'posts_data' => $posts->toArray(),
'first_post_title' => $posts->count() > 0 ? $posts->first()->title : 'No posts', 'first_post_title' => $posts->count() > 0 ? ($posts->first()['title'] ?? 'No title') : 'No posts',
]); ]);
return Inertia::render('marketing/Blog', compact('posts')); return Inertia::render('marketing/Blog', compact('posts'));
} }
public function blogShow($slug) public function blogShow(string $locale, string $slug)
{ {
$locale = app()->getLocale(); $locale = $locale ?: app()->getLocale();
$postModel = BlogPost::query() $postModel = BlogPost::query()
->with('author') ->with('author')
->whereHas('category', function ($query) { ->whereHas('category', function ($query) {
@@ -362,7 +365,33 @@ class MarketingController extends Controller
public function demo() public function demo()
{ {
return Inertia::render('marketing/Demo'); $joinToken = optional(Event::firstWhere('slug', 'demo-wedding-2025'))
?->joinTokens()
->latest('id')
->first();
$demoToken = null;
if ($joinToken) {
if (! empty($joinToken->token_encrypted)) {
try {
$demoToken = Crypt::decryptString($joinToken->token_encrypted);
} catch (\Throwable $exception) {
Log::warning('Failed to decrypt demo join token', [
'token_id' => $joinToken->id,
'exception' => $exception->getMessage(),
]);
}
}
if (! $demoToken) {
$demoToken = $joinToken->metadata['plain_token'] ?? null;
}
}
return Inertia::render('marketing/Demo', [
'demoToken' => $demoToken,
]);
} }
public function packagesIndex() public function packagesIndex()

View File

@@ -11,6 +11,7 @@ use App\Models\Task;
use App\Models\TaskCollection; use App\Models\TaskCollection;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\EventJoinTokenService; use App\Services\EventJoinTokenService;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
@@ -41,6 +42,7 @@ class DemoEventSeeder extends Seeder
'event_type' => $weddingType, 'event_type' => $weddingType,
'package' => $standardPackage, 'package' => $standardPackage,
'token_label' => 'Demo QR', 'token_label' => 'Demo QR',
'token_value' => 'W2E3sbt7yclzpkAwNSARHYTVN1sPLBad8hfUjLVHmjkUviPd',
'collection_slugs' => ['wedding-classics-2025'], 'collection_slugs' => ['wedding-classics-2025'],
'task_slug_prefix' => 'wedding-', 'task_slug_prefix' => 'wedding-',
'branding' => [ 'branding' => [
@@ -91,7 +93,7 @@ class DemoEventSeeder extends Seeder
] ]
); );
$this->ensureJoinToken($event, $config['token_label']); $this->ensureJoinToken($event, $config['token_label'], $config['token_value'] ?? null);
$this->attachEventPackage( $this->attachEventPackage(
event: $event, event: $event,
@@ -106,13 +108,31 @@ class DemoEventSeeder extends Seeder
} }
} }
private function ensureJoinToken(Event $event, string $label): void private function ensureJoinToken(Event $event, string $label, ?string $token = null): void
{ {
if ($event->joinTokens()->exists()) { if ($event->joinTokens()->exists()) {
return; return;
} }
app(EventJoinTokenService::class)->createToken($event, ['label' => $label]); $attributes = ['label' => $label];
if ($token) {
$attributes['metadata'] = ['seeded' => true, 'plain_token' => $token];
}
$tokenModel = app(EventJoinTokenService::class)->createToken($event, $attributes);
if ($token) {
$hash = hash('sha256', $token);
$preview = strlen($token) <= 10 ? $token : substr($token, 0, 6).'…'.substr($token, -4);
$tokenModel->forceFill([
'token' => $hash,
'token_hash' => $hash,
'token_encrypted' => Crypt::encryptString($token),
'token_preview' => $preview,
])->save();
}
} }
private function attachEventPackage(Event $event, Package $package, Tenant $tenant, string $providerId, Carbon $purchasedAt): void private function attachEventPackage(Event $event, Package $package, Tenant $tenant, string $providerId, Carbon $purchasedAt): void

View File

@@ -1,2 +0,0 @@
url=http://192.168.78.2:10880/soeren/fotospiel-app.git
token=d3030b8d95a890e1b611e5bb7519f346b310d012

View File

@@ -4,7 +4,7 @@
"impressum_title": "Impressum - Fotospiel", "impressum_title": "Impressum - Fotospiel",
"datenschutz_title": "Datenschutzerklärung - Fotospiel", "datenschutz_title": "Datenschutzerklärung - Fotospiel",
"impressum_section": "Angaben gemäß § 5 TMG", "impressum_section": "Angaben gemäß § 5 TMG",
"company": "Fotospiel GmbH", "company": "S.E.B. Fotografie",
"address": "Musterstraße 1, 12345 Musterstadt", "address": "Musterstraße 1, 12345 Musterstadt",
"representative": "Vertreten durch: Max Mustermann", "representative": "Vertreten durch: Max Mustermann",
"contact": "Kontakt", "contact": "Kontakt",
@@ -14,7 +14,7 @@
"register_court": "Registergericht: Amtsgericht Musterstadt", "register_court": "Registergericht: Amtsgericht Musterstadt",
"commercial_register": "Handelsregister: HRB 12345", "commercial_register": "Handelsregister: HRB 12345",
"datenschutz_intro": "Wir nehmen den Schutz Ihrer persönlichen Daten sehr ernst und halten uns strikt an die Regeln der Datenschutzgesetze.", "datenschutz_intro": "Wir nehmen den Schutz Ihrer persönlichen Daten sehr ernst und halten uns strikt an die Regeln der Datenschutzgesetze.",
"responsible": "Verantwortlich: Fotospiel GmbH, Musterstraße 1, 12345 Musterstadt", "responsible": "Verantwortlich: S.E.B. Fotografie, Musterstraße 1, 12345 Musterstadt",
"data_collection": "Datenerfassung: Keine PII-Speicherung, anonyme Sessions für Gäste. E-Mails werden nur für Kontaktzwecke verarbeitet.", "data_collection": "Datenerfassung: Keine PII-Speicherung, anonyme Sessions für Gäste. E-Mails werden nur für Kontaktzwecke verarbeitet.",
"payments": "Zahlungen und Packages", "payments": "Zahlungen und Packages",
"payments_desc": "Wir verarbeiten Zahlungen für Packages über Paddle. Zahlungsdaten werden als Merchant of Record sicher und verschlüsselt durch Paddle verarbeitet.", "payments_desc": "Wir verarbeiten Zahlungen für Packages über Paddle. Zahlungsdaten werden als Merchant of Record sicher und verschlüsselt durch Paddle verarbeitet.",

View File

@@ -379,7 +379,7 @@
"register": "Registrieren" "register": "Registrieren"
}, },
"footer": { "footer": {
"company": "Fotospiel GmbH", "company": "S.E.B. Fotografie",
"rights_reserved": "Alle Rechte vorbehalten" "rights_reserved": "Alle Rechte vorbehalten"
}, },
"register": { "register": {
@@ -810,7 +810,9 @@
"good_to_know": "Gut zu wissen", "good_to_know": "Gut zu wissen",
"openDemoFull": "Demo im neuen Tab öffnen", "openDemoFull": "Demo im neuen Tab öffnen",
"readyToLaunch": "Bereit für dein Event?", "readyToLaunch": "Bereit für dein Event?",
"readyToLaunchCopy": "Registriere dich kostenlos und lege noch heute dein erstes Event an." "readyToLaunchCopy": "Registriere dich kostenlos und lege noch heute dein erstes Event an.",
"demoUnavailable": "Demo-Link aktuell nicht verfügbar",
"demoUnavailableCopy": "Führe die Demo-Seeds aus oder kontaktiere uns, damit wir dir sofort einen neuen Zugangscode hinterlegen können."
}, },
"actions": { "actions": {
"tips": "Tipps" "tips": "Tipps"

View File

@@ -4,7 +4,7 @@
"impressum_title": "Imprint - Fotospiel", "impressum_title": "Imprint - Fotospiel",
"datenschutz_title": "Privacy Policy - Fotospiel", "datenschutz_title": "Privacy Policy - Fotospiel",
"impressum_section": "Information pursuant to § 5 TMG", "impressum_section": "Information pursuant to § 5 TMG",
"company": "Fotospiel GmbH", "company": "S.E.B. Fotografie",
"address": "Musterstraße 1, 12345 Musterstadt", "address": "Musterstraße 1, 12345 Musterstadt",
"representative": "Represented by: Max Mustermann", "representative": "Represented by: Max Mustermann",
"contact": "Contact", "contact": "Contact",
@@ -14,7 +14,7 @@
"register_court": "Register Court: District Court Musterstadt", "register_court": "Register Court: District Court Musterstadt",
"commercial_register": "Commercial Register: HRB 12345", "commercial_register": "Commercial Register: HRB 12345",
"datenschutz_intro": "We take the protection of your personal data very seriously and strictly adhere to the rules of data protection laws.", "datenschutz_intro": "We take the protection of your personal data very seriously and strictly adhere to the rules of data protection laws.",
"responsible": "Responsible: Fotospiel GmbH, Musterstraße 1, 12345 Musterstadt", "responsible": "Responsible: S.E.B. Fotografie, Musterstraße 1, 12345 Musterstadt",
"data_collection": "Data collection: No PII storage, anonymous sessions for guests. Emails are only processed for contact purposes.", "data_collection": "Data collection: No PII storage, anonymous sessions for guests. Emails are only processed for contact purposes.",
"payments": "Payments and Packages", "payments": "Payments and Packages",
"payments_desc": "We process payments for Packages via Paddle. Payment data is handled securely and encrypted by Paddle as the merchant of record.", "payments_desc": "We process payments for Packages via Paddle. Payment data is handled securely and encrypted by Paddle as the merchant of record.",

View File

@@ -373,7 +373,7 @@
"register": "Register" "register": "Register"
}, },
"footer": { "footer": {
"company": "Fotospiel GmbH", "company": "S.E.B. Fotografie",
"rights_reserved": "All Rights Reserved" "rights_reserved": "All Rights Reserved"
}, },
"register": { "register": {
@@ -804,7 +804,9 @@
"good_to_know": "Good to know", "good_to_know": "Good to know",
"openDemoFull": "Open demo in new tab", "openDemoFull": "Open demo in new tab",
"readyToLaunch": "Ready to launch?", "readyToLaunch": "Ready to launch?",
"readyToLaunchCopy": "Sign up for free and create your first event today." "readyToLaunchCopy": "Sign up for free and create your first event today.",
"demoUnavailable": "Demo link currently unavailable",
"demoUnavailableCopy": "Run the demo seeds or ping us so we can attach a fresh access code for you right away."
}, },
"actions": { "actions": {
"tips": "Tips" "tips": "Tips"

View File

@@ -31,7 +31,7 @@ const Footer: React.FC = () => {
Die Fotospiel App Die Fotospiel App
</Link> </Link>
<p className="mt-2 font-sans-marketing text-gray-600"> <p className="mt-2 font-sans-marketing text-gray-600">
{t('marketing:footer.company', 'Fotospiel GmbH')} {t('marketing:footer.company', 'S.E.B. Fotografie')}
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,17 +1,34 @@
import React from 'react'; import React from 'react';
import { Head, Link } from '@inertiajs/react'; import { Head, Link, usePage } from '@inertiajs/react';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes'; import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import MarketingLayout from '@/layouts/mainWebsite'; import MarketingLayout from '@/layouts/mainWebsite';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
interface PostSummary {
id: number;
slug: string;
title: string;
excerpt?: string;
featured_image?: string;
published_at?: string;
author?: { name?: string } | string;
}
interface PaginationLink {
url: string | null;
label: string;
active: boolean;
}
interface Props { interface Props {
posts: { posts: {
data: any[]; data: PostSummary[];
links: any[]; links: PaginationLink[];
current_page: number; current_page: number;
last_page: number; last_page: number;
}; };
@@ -19,8 +36,47 @@ interface Props {
const Blog: React.FC<Props> = ({ posts }) => { const Blog: React.FC<Props> = ({ posts }) => {
const { localizedPath } = useLocalizedRoutes(); const { localizedPath } = useLocalizedRoutes();
const { props } = usePage<{ supportedLocales?: string[] }>();
const supportedLocales = props.supportedLocales && props.supportedLocales.length > 0 ? props.supportedLocales : ['de', 'en'];
const { t, i18n } = useTranslation('marketing'); const { t, i18n } = useTranslation('marketing');
const locale = i18n.language || 'de'; // Fallback to 'de' if no locale const locale = i18n.language || 'de';
const articles = posts?.data ?? [];
const isLandingLayout = posts.current_page === 1;
const featuredPost = isLandingLayout ? articles[0] : null;
const gridPosts = isLandingLayout ? articles.slice(1, 4) : [];
const listPosts = isLandingLayout ? [] : articles;
const dateLocale = locale === 'en' ? 'en-US' : 'de-DE';
const buildArticleHref = React.useCallback(
(slug?: string | null) => {
if (!slug) {
return '#';
}
return localizedPath(`/blog/${encodeURIComponent(slug)}`);
},
[localizedPath]
);
const formatPublishedDate = React.useCallback(
(raw?: string) => {
if (!raw) {
return '';
}
try {
return new Date(raw).toLocaleDateString(dateLocale, {
day: 'numeric',
month: 'long',
year: 'numeric',
});
} catch (error) {
console.warn('[Marketing Blog] Unable to parse date', { raw, error });
return raw;
}
},
[dateLocale]
);
const resolvePaginationHref = React.useCallback( const resolvePaginationHref = React.useCallback(
(raw?: string | null) => { (raw?: string | null) => {
@@ -35,6 +91,12 @@ const Blog: React.FC<Props> = ({ posts }) => {
); );
const normalized = `${parsed.pathname}${parsed.search}`; const normalized = `${parsed.pathname}${parsed.search}`;
const localePrefixRegex = new RegExp(`^/(${supportedLocales.join('|')})(/|$)`);
if (localePrefixRegex.test(parsed.pathname)) {
return normalized;
}
return localizedPath(normalized); return localizedPath(normalized);
} catch (error) { } catch (error) {
console.warn('[Marketing Blog] Fallback resolving pagination link', { raw, error }); console.warn('[Marketing Blog] Fallback resolving pagination link', { raw, error });
@@ -45,11 +107,13 @@ const Blog: React.FC<Props> = ({ posts }) => {
); );
const renderPagination = () => { const renderPagination = () => {
if (!posts.links || posts.links.length <= 3) return null; if (!posts.links || posts.links.length <= 3) {
return null;
}
return ( return (
<div className="mt-12"> <div className="mt-12">
<Card className="p-6"> <Card className="p-4 md:p-6">
<div className="flex justify-center"> <div className="flex justify-center">
<div className="flex flex-wrap justify-center gap-2"> <div className="flex flex-wrap justify-center gap-2">
{posts.links.map((link, index) => { {posts.links.map((link, index) => {
@@ -59,9 +123,9 @@ const Blog: React.FC<Props> = ({ posts }) => {
return ( return (
<Button <Button
key={index} key={index}
variant={link.active ? "default" : "outline"} variant={link.active ? 'default' : 'outline'}
disabled disabled
className={link.active ? "bg-[#FFB6C1] hover:bg-[#FF69B4]" : ""} className={link.active ? 'bg-[#FFB6C1] hover:bg-[#FF69B4]' : ''}
dangerouslySetInnerHTML={{ __html: link.label }} dangerouslySetInnerHTML={{ __html: link.label }}
/> />
); );
@@ -71,13 +135,10 @@ const Blog: React.FC<Props> = ({ posts }) => {
<Button <Button
key={index} key={index}
asChild asChild
variant={link.active ? "default" : "outline"} variant={link.active ? 'default' : 'outline'}
className={link.active ? "bg-[#FFB6C1] hover:bg-[#FF69B4]" : ""} className={link.active ? 'bg-[#FFB6C1] hover:bg-[#FF69B4]' : ''}
> >
<Link <Link href={href} dangerouslySetInnerHTML={{ __html: link.label }} />
href={href}
dangerouslySetInnerHTML={{ __html: link.label }}
/>
</Button> </Button>
); );
})} })}
@@ -88,96 +149,168 @@ const Blog: React.FC<Props> = ({ posts }) => {
); );
}; };
const renderPostMeta = (post: PostSummary) => (
<div className="flex flex-col gap-2 text-sm text-gray-600 dark:text-gray-300 sm:flex-row sm:items-center sm:gap-4">
<span>
{t('blog.by')} {typeof post.author === 'string' ? post.author : post.author?.name || t('blog.team')}
</span>
<Separator orientation="vertical" className="hidden h-4 sm:block" />
<span>
{t('blog.published_at')} {formatPublishedDate(post.published_at)}
</span>
</div>
);
const renderLandingGrid = () => {
if (!featuredPost) {
return (
<Alert className="bg-white shadow-sm dark:bg-gray-950">
<AlertDescription className="text-gray-600 dark:text-gray-300">
{t('blog.empty')}
</AlertDescription>
</Alert>
);
}
return (
<div className="space-y-16">
<div className="grid gap-8 lg:grid-cols-12 lg:items-center">
<div className="order-2 space-y-6 rounded-3xl border border-gray-200 bg-white p-8 shadow-xl dark:border-gray-800 dark:bg-gray-950 lg:order-1 lg:col-span-6">
<Badge variant="outline" className="w-fit border-pink-200 text-pink-600 dark:border-pink-500 dark:text-pink-300">
{t('blog.posts_title')}
</Badge>
<h2 className="text-3xl font-bold text-gray-900 dark:text-gray-50">
{featuredPost.title || 'Untitled'}
</h2>
<p className="text-lg leading-relaxed text-gray-600 dark:text-gray-300">
{featuredPost.excerpt || ''}
</p>
{renderPostMeta(featuredPost)}
<Button asChild size="lg" className="bg-[#FFB6C1] hover:bg-[#FF69B4] text-white">
<Link href={buildArticleHref(featuredPost.slug)}>
{t('blog.read_more')}
</Link>
</Button>
</div>
<div className="order-1 lg:order-2 lg:col-span-6">
<div className="relative overflow-hidden rounded-3xl border border-gray-200 bg-gradient-to-br from-pink-200 via-white to-amber-100 shadow-2xl dark:border-gray-800 dark:from-pink-900/40 dark:via-gray-900 dark:to-gray-950">
{featuredPost.featured_image && (
<img
src={featuredPost.featured_image}
alt={featuredPost.title}
className="h-full w-full object-cover"
/>
)}
{!featuredPost.featured_image && (
<div className="aspect-[5/4] p-8 text-3xl font-semibold text-gray-900 dark:text-gray-50">
Fotospiel Stories
</div>
)}
</div>
</div>
</div>
{gridPosts.length > 0 && (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{gridPosts.map((post) => (
<Card key={post.id} className="flex h-full flex-col overflow-hidden border-gray-200 shadow-sm transition hover:-translate-y-1 hover:shadow-lg dark:border-gray-800">
{post.featured_image && (
<div className="aspect-video">
<img src={post.featured_image} alt={post.title} className="h-full w-full object-cover" />
</div>
)}
<CardContent className="flex flex-1 flex-col space-y-4 p-6">
<div className="space-y-2">
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-50">
{post.title || 'Untitled'}
</h3>
<p className="text-sm leading-relaxed text-gray-600 dark:text-gray-300">
{post.excerpt || ''}
</p>
</div>
<div className="flex-1" />
{renderPostMeta(post)}
<Button asChild variant="ghost" className="justify-start p-0 text-pink-600 hover:text-pink-700">
<Link href={buildArticleHref(post.slug)}>
{t('blog.read_more')}
</Link>
</Button>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
};
const renderListView = () => (
<div className="space-y-6">
{listPosts.map((post) => (
<Card key={post.id} className="overflow-hidden border-gray-200 shadow-sm hover:shadow-lg dark:border-gray-800">
<div className="grid gap-0 md:grid-cols-3">
{post.featured_image && (
<img
src={post.featured_image}
alt={post.title}
className="h-full w-full object-cover"
/>
)}
<CardContent className="md:col-span-2">
<div className="space-y-4 py-6">
<h3 className="text-2xl font-semibold text-gray-900 dark:text-gray-50">
{post.title || 'Untitled'}
</h3>
<p className="text-gray-600 dark:text-gray-300">{post.excerpt || ''}</p>
{renderPostMeta(post)}
<Button asChild variant="outline" className="w-fit border-pink-200 text-pink-600 hover:bg-pink-50 dark:border-pink-500/40 dark:text-pink-200">
<Link href={buildArticleHref(post.slug)}>
{t('blog.read_more')}
</Link>
</Button>
</div>
</CardContent>
</div>
</Card>
))}
{listPosts.length === 0 && (
<Card className="p-8 text-center text-gray-600 dark:text-gray-300">{t('blog.empty')}</Card>
)}
</div>
);
return ( return (
<MarketingLayout title={t('blog.title')}> <MarketingLayout title={t('blog.title')}>
<Head title={t('blog.title')} /> <Head title={t('blog.title')} />
{/* Hero Section */}
<section className="bg-aurora-enhanced py-20 px-4"> <section className="bg-gradient-to-br from-pink-50 via-white to-amber-50 px-4 py-14 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950">
<div className="container mx-auto max-w-4xl"> <div className="container mx-auto max-w-4xl space-y-6 text-center">
<Card className="bg-white/10 backdrop-blur-sm border-white/20 shadow-xl"> <Badge variant="outline" className="border-pink-200 text-pink-600 dark:border-pink-500/50 dark:text-pink-200">
<CardContent className="p-8 md:p-12 text-center"> Fotospiel Blog
<h1 className="text-4xl md:text-6xl font-bold mb-6 font-display text-gray-900 dark:text-gray-100">{t('blog.hero_title')}</h1> </Badge>
<p className="text-xl md:text-2xl mb-8 max-w-3xl mx-auto font-sans-marketing text-gray-800 dark:text-gray-200">{t('blog.hero_description')}</p> <h1 className="text-4xl font-bold text-gray-900 dark:text-gray-50 md:text-5xl">{t('blog.hero_title')}</h1>
<Button <p className="text-lg leading-relaxed text-gray-600 dark:text-gray-300">{t('blog.hero_description')}</p>
asChild <div className="flex flex-wrap justify-center gap-3">
size="lg" <Button asChild size="lg" className="bg-[#FFB6C1] hover:bg-[#FF69B4] text-white">
className="bg-white text-[#FFB6C1] hover:bg-gray-100 px-8 py-4 rounded-full font-semibold text-lg font-sans-marketing" <Link href={localizedPath('/packages')}>{t('home.cta_explore')}</Link>
> </Button>
<Link href="#posts"> <Button asChild size="lg" variant="outline" className="border-gray-300 text-gray-800 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-50 dark:hover:bg-gray-800">
{t('blog.hero_cta')} <Link href="#articles">{t('blog.hero_cta')}</Link>
</Link> </Button>
</Button> </div>
</CardContent>
</Card>
</div> </div>
</section> </section>
{/* Posts Section */} <section id="articles" className="bg-white px-4 py-16 dark:bg-gray-950">
<section id="posts" className="py-20 px-4 bg-white dark:bg-gray-900"> <div className="container mx-auto max-w-6xl space-y-12">
<div className="container mx-auto max-w-6xl"> <div className="text-center">
<div className="text-center mb-12"> <h2 className="text-3xl font-bold text-gray-900 dark:text-gray-50">{t('blog.posts_title')}</h2>
<h2 className="text-3xl font-bold font-display text-gray-900 dark:text-gray-100 mb-4">{t('blog.posts_title')}</h2> <Separator className="mx-auto mt-4 w-24" />
<Separator className="w-24 mx-auto" />
</div> </div>
{posts.data.length > 0 ? ( {isLandingLayout ? renderLandingGrid() : renderListView()}
<>
<div className="grid md:grid-cols-2 gap-8">
{posts.data.map((post) => (
<Card key={post.id} className="overflow-hidden hover:shadow-lg transition-shadow duration-300">
{post.featured_image && (
<div className="aspect-video overflow-hidden">
<img
src={post.featured_image}
alt={post.title}
className="w-full h-full object-cover hover:scale-105 transition-transform duration-300"
/>
</div>
)}
<CardContent className="p-6">
<CardTitle className="text-xl font-semibold mb-3 font-sans-marketing">
<Link
href={`${localizedPath(`/blog/${post.slug}`)}`}
className="hover:text-[#FFB6C1] transition-colors"
>
{post.title?.[locale] || post.title?.de || post.title || 'No Title'}
</Link>
</CardTitle>
<p className="text-gray-700 dark:text-gray-300 font-serif-custom mb-4 leading-relaxed"> {renderPagination()}
{post.excerpt?.[locale] || post.excerpt?.de || post.excerpt || 'No Excerpt'}
</p>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 mb-4">
<Badge variant="secondary" className="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
{t('blog.by')} {post.author?.name?.[locale] || post.author?.name?.de || post.author?.name || t('blog.team')}
</Badge>
<Separator orientation="vertical" className="hidden sm:block h-4" />
<Badge variant="outline" className="text-gray-500">
{t('blog.published_at')} {new Date(post.published_at).toLocaleDateString('de-DE', {
day: 'numeric',
month: 'long',
year: 'numeric'
})}
</Badge>
</div>
<Button asChild variant="ghost" className="p-0 h-auto text-[#FFB6C1] hover:text-[#FF69B4] hover:bg-transparent">
<Link href={`${localizedPath(`/blog/${post.slug}`)}`} className="font-semibold">
{t('blog.read_more')}
</Link>
</Button>
</CardContent>
</Card>
))}
</div>
{renderPagination()}
</>
) : (
<Card className="p-8 text-center">
<p className="text-gray-600 dark:text-gray-400 font-serif-custom">{t('blog.empty')}</p>
</Card>
)}
</div> </div>
</section> </section>
</MarketingLayout> </MarketingLayout>

View File

@@ -1,125 +1,148 @@
import React from 'react';
import { Head, Link } from '@inertiajs/react';
import { useTranslation } from 'react-i18next';
import MarketingLayout from '@/layouts/mainWebsite';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Sparkles, CheckCircle2 } from 'lucide-react'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import MarketingLayout from '@/layouts/mainWebsite';
import { Head, Link } from '@inertiajs/react';
import { CheckCircle2, Sparkles } from 'lucide-react';
import React from 'react';
import { useTranslation } from 'react-i18next';
type DemoFeature = { title: string; description: string }; type DemoFeature = { title: string; description: string };
const DEMO_TOKEN = 'mdhyA5XSVEVEabF8JhZ85B6fMocyyRMTfmThSeUKPzk7LLTu'; interface DemoPageProps {
demoToken?: string | null;
}
const DemoPage: React.FC = () => { const DemoPage: React.FC<DemoPageProps> = ({ demoToken }) => {
const { t } = useTranslation('marketing'); const { t } = useTranslation('marketing');
const { localizedPath } = useLocalizedRoutes(); const { localizedPath } = useLocalizedRoutes();
const embedUrl = demoToken ? `/e/${demoToken}` : null;
const demo = t('demo_page', { returnObjects: true }) as { const demo = t('demo_page', { returnObjects: true }) as {
title: string; title: string;
subtitle: string; subtitle: string;
primaryCta: string; primaryCta: string;
secondaryCta: string; secondaryCta: string;
iframeNote: string; iframeNote: string;
openFull: string; openFull: string;
features: DemoFeature[]; features: DemoFeature[];
}; };
return ( return (
<MarketingLayout title={demo.title}> <MarketingLayout title={demo.title}>
<Head title={demo.title} /> <Head title={demo.title} />
<section className="relative overflow-hidden bg-gradient-to-br from-pink-100 via-white to-white px-4 py-16 dark:from-pink-950/40 dark:via-gray-950 dark:to-gray-950"> <section className="relative overflow-hidden bg-gradient-to-br from-pink-100 via-white to-white px-4 py-16 dark:from-pink-950/40 dark:via-gray-950 dark:to-gray-950">
<div className="absolute -top-32 right-20 hidden h-72 w-72 rounded-full bg-pink-200/50 blur-3xl dark:bg-pink-900/30 lg:block" /> <div className="absolute -top-32 right-20 hidden h-72 w-72 rounded-full bg-pink-200/50 blur-3xl lg:block dark:bg-pink-900/30" />
<div className="container mx-auto relative z-10 flex max-w-5xl flex-col gap-10 lg:flex-row lg:items-center"> <div className="relative z-10 container mx-auto flex max-w-5xl flex-col gap-10 lg:flex-row lg:items-center">
<div className="flex-1 space-y-6"> <div className="flex-1 space-y-6">
<Badge variant="outline" className="border-pink-400 text-pink-600 dark:border-pink-500 dark:text-pink-300"> <Badge variant="outline" className="border-pink-400 text-pink-600 dark:border-pink-500 dark:text-pink-300">
Demo Live Demo Live
</Badge> </Badge>
<h1 className="text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-50 md:text-5xl"> <h1 className="text-4xl font-bold tracking-tight text-gray-900 md:text-5xl dark:text-gray-50">{demo.title}</h1>
{demo.title} <p className="max-w-xl text-lg text-gray-600 dark:text-gray-300">{demo.subtitle}</p>
</h1> <div className="flex flex-wrap items-center gap-3">
<p className="max-w-xl text-lg text-gray-600 dark:text-gray-300"> <Button asChild size="lg" className="bg-pink-500 hover:bg-pink-600">
{demo.subtitle} <Link href={localizedPath('/packages')}>{demo.primaryCta}</Link>
</p> </Button>
<div className="flex flex-wrap items-center gap-3"> <Button asChild size="lg" variant="ghost" className="text-pink-600 hover:text-pink-700 dark:text-pink-300">
<Button asChild size="lg" className="bg-pink-500 hover:bg-pink-600"> <Link href={localizedPath('/so-funktionierts')}>{demo.secondaryCta}</Link>
<Link href={localizedPath('/packages')}> </Button>
{demo.primaryCta} </div>
</Link> </div>
</Button> <div className="flex-1">
<Button asChild size="lg" variant="ghost" className="text-pink-600 hover:text-pink-700 dark:text-pink-300"> {embedUrl ? (
<Link href={localizedPath('/so-funktionierts')}> <>
{demo.secondaryCta} <div className="relative mx-auto w-full max-w-[320px] rounded-[2.5rem] border border-gray-200 bg-gray-900 p-4 shadow-2xl md:max-w-[360px] dark:border-gray-700">
</Link> <div
</Button> className="absolute top-2 left-1/2 h-1.5 w-16 -translate-x-1/2 rounded-full bg-gray-300 dark:bg-gray-600"
</div> aria-hidden
</div> />
<div className="flex-1"> <iframe
<div className="relative mx-auto w-full max-w-[320px] rounded-[2.5rem] border border-gray-200 bg-gray-900 p-4 shadow-2xl dark:border-gray-700 md:max-w-[360px]"> title="Fotospiel App Demo"
<div className="absolute left-1/2 top-2 h-1.5 w-16 -translate-x-1/2 rounded-full bg-gray-300 dark:bg-gray-600" aria-hidden /> src={embedUrl}
<iframe className="aspect-[9/16] w-full rounded-[1.75rem] border-0 bg-white shadow-inner dark:bg-gray-950"
title="Fotospiel App Demo" loading="lazy"
src={`/e/${DEMO_TOKEN}`} sandbox="allow-scripts allow-same-origin allow-forms"
className="aspect-[9/16] w-full rounded-[1.75rem] border-0 bg-white shadow-inner dark:bg-gray-950" />
loading="lazy" </div>
sandbox="allow-scripts allow-same-origin allow-forms" <div className="mt-4 flex flex-col items-center gap-1 text-center">
/> <p className="text-sm text-gray-600 dark:text-gray-300">{demo.iframeNote}</p>
</div> <Button asChild variant="link" className="text-pink-600 hover:text-pink-700 dark:text-pink-300">
<div className="mt-4 flex flex-col items-center gap-1 text-center"> <Link href={embedUrl} target="_blank" rel="noopener">
<p className="text-sm text-gray-600 dark:text-gray-300">{demo.iframeNote}</p> {demo.openFull}
<Button asChild variant="link" className="text-pink-600 hover:text-pink-700 dark:text-pink-300"> </Link>
<Link href={`/e/${DEMO_TOKEN}`} target="_blank" rel="noopener"> </Button>
{demo.openFull} </div>
</Link> </>
</Button> ) : (
</div> <Alert className="border-pink-200 bg-white shadow-lg dark:border-pink-900/50 dark:bg-gray-950">
</div> <AlertTitle className="text-gray-900 dark:text-gray-100">
</div> {t('labels.demoUnavailable', 'Demo-Link aktuell nicht verfügbar')}
</section> </AlertTitle>
<AlertDescription className="mt-2 text-sm text-gray-600 dark:text-gray-300">
{t(
'labels.demoUnavailableCopy',
'Führe die Demo-Seeds aus oder kontaktiere uns, damit wir dir sofort einen neuen Zugangscode hinterlegen können.',
)}
</AlertDescription>
<div className="mt-4 flex flex-wrap gap-3">
<Button asChild size="sm" className="bg-pink-500 hover:bg-pink-600">
<Link href={localizedPath('/packages')}>{demo.primaryCta}</Link>
</Button>
<Button
asChild
size="sm"
variant="outline"
className="text-pink-600 hover:text-pink-700 dark:border-pink-900/40 dark:text-pink-300"
>
<Link href={localizedPath('/kontakt')}>{t('nav.contact', 'Kontakt')}</Link>
</Button>
</div>
</Alert>
)}
</div>
</div>
</section>
<section className="container mx-auto px-4 pb-16"> <section className="container mx-auto px-4 pb-16">
<div className="mx-auto max-w-5xl"> <div className="mx-auto max-w-5xl">
<div className="grid gap-6 md:grid-cols-3"> <div className="grid gap-6 md:grid-cols-3">
{demo.features.map((feature) => ( {demo.features.map((feature) => (
<Card key={feature.title} className="border-gray-100 shadow-sm dark:border-gray-800"> <Card key={feature.title} className="border-gray-100 shadow-sm dark:border-gray-800">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-lg"> <CardTitle className="flex items-center gap-2 text-lg">
<Sparkles className="h-5 w-5 text-pink-500" aria-hidden /> <Sparkles className="h-5 w-5 text-pink-500" aria-hidden />
{feature.title} {feature.title}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<CardDescription className="text-sm text-gray-600 dark:text-gray-300"> <CardDescription className="text-sm text-gray-600 dark:text-gray-300">{feature.description}</CardDescription>
{feature.description} </CardContent>
</CardDescription> </Card>
</CardContent> ))}
</Card> </div>
))}
</div>
<Alert className="mt-10 border-pink-200 bg-white shadow-lg dark:border-pink-900/50 dark:bg-gray-950"> <Alert className="mt-10 border-pink-200 bg-white shadow-lg dark:border-pink-900/50 dark:bg-gray-950">
<AlertTitle className="flex items-center gap-2 text-gray-900 dark:text-gray-100"> <AlertTitle className="flex items-center gap-2 text-gray-900 dark:text-gray-100">
<CheckCircle2 className="h-5 w-5 text-pink-500" aria-hidden /> <CheckCircle2 className="h-5 w-5 text-pink-500" aria-hidden />
{t('marketing.labels.readyToLaunch', 'Bereit für dein Event?')} {t('marketing.labels.readyToLaunch', 'Bereit für dein Event?')}
</AlertTitle> </AlertTitle>
<AlertDescription className="mt-3 flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <AlertDescription className="mt-3 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<span className="text-sm text-gray-600 dark:text-gray-300"> <span className="text-sm text-gray-600 dark:text-gray-300">
{t('marketing.labels.readyToLaunchCopy', 'Registriere dich kostenlos und lege noch heute dein erstes Event an.')} {t('marketing.labels.readyToLaunchCopy', 'Registriere dich kostenlos und lege noch heute dein erstes Event an.')}
</span> </span>
<Button asChild className="bg-pink-500 hover:bg-pink-600"> <Button asChild className="bg-pink-500 hover:bg-pink-600">
<Link href={localizedPath('/register')}> <Link href={localizedPath('/register')}>{demo.primaryCta}</Link>
{demo.primaryCta} </Button>
</Link> </AlertDescription>
</Button> </Alert>
</AlertDescription> </div>
</Alert> </section>
</div> </MarketingLayout>
</section> );
</MarketingLayout>
);
}; };
DemoPage.layout = (page: React.ReactNode) => page; DemoPage.layout = (page: React.ReactNode) => page;

17
resources/lang/de/api.php Normal file
View File

@@ -0,0 +1,17 @@
<?php
return [
'join_tokens' => [
'invalid_title' => 'Ungültiger QR-Zugang',
'invalid_message' => 'Dieser QR-Link oder Zugangscode wurde nicht erkannt. Bitte scanne ihn erneut oder fordere einen neuen Link an.',
'expired_title' => 'QR-Zugang abgelaufen',
'expired_message' => 'Dieser QR-Zugang ist abgelaufen, weil das Event bereits beendet wurde.',
'revoked_title' => 'QR-Zugang deaktiviert',
'revoked_message' => 'Der Veranstalter hat diesen QR-Zugang deaktiviert. Bitte nutze einen neuen Link.',
'rate_limited_title' => 'Zu viele Versuche',
'rate_limited_message' => 'Der QR-Zugang wurde zu oft aufgerufen. Bitte warte kurz und versuche es erneut.',
'invalid_attempts_message' => 'Der QR-Zugang wurde zu oft falsch eingegeben. Bitte versuche es in wenigen Minuten erneut.',
'default_title' => 'Zugang verweigert',
'default_message' => 'Mit diesem QR-Zugang konnte kein Zugriff gewährt werden.',
],
];

View File

@@ -6,7 +6,7 @@ return [
'impressum_title' => 'Impressum - Fotospiel', 'impressum_title' => 'Impressum - Fotospiel',
'datenschutz_title' => 'Datenschutzerklärung - Fotospiel', 'datenschutz_title' => 'Datenschutzerklärung - Fotospiel',
'impressum_section' => 'Angaben gemäß § 5 TMG', 'impressum_section' => 'Angaben gemäß § 5 TMG',
'company' => 'Fotospiel GmbH', 'company' => 'S.E.B. Fotografie',
'address' => 'Musterstraße 1, 12345 Musterstadt', 'address' => 'Musterstraße 1, 12345 Musterstadt',
'representative' => 'Vertreten durch: Max Mustermann', 'representative' => 'Vertreten durch: Max Mustermann',
'contact' => 'Kontakt', 'contact' => 'Kontakt',
@@ -16,7 +16,7 @@ return [
'register_court' => 'Registergericht: Amtsgericht Musterstadt', 'register_court' => 'Registergericht: Amtsgericht Musterstadt',
'commercial_register' => 'Handelsregister: HRB 12345', 'commercial_register' => 'Handelsregister: HRB 12345',
'datenschutz_intro' => 'Wir nehmen den Schutz Ihrer persönlichen Daten sehr ernst und halten uns strikt an die Regeln der Datenschutzgesetze.', 'datenschutz_intro' => 'Wir nehmen den Schutz Ihrer persönlichen Daten sehr ernst und halten uns strikt an die Regeln der Datenschutzgesetze.',
'responsible' => 'Verantwortlich: Fotospiel GmbH, Musterstraße 1, 12345 Musterstadt', 'responsible' => 'Verantwortlich: S.E.B. Fotografie, Musterstraße 1, 12345 Musterstadt',
'data_collection' => 'Datenerfassung: Keine PII-Speicherung, anonyme Sessions für Gäste. E-Mails werden nur für Kontaktzwecke verarbeitet.', 'data_collection' => 'Datenerfassung: Keine PII-Speicherung, anonyme Sessions für Gäste. E-Mails werden nur für Kontaktzwecke verarbeitet.',
'payments' => 'Zahlungen und Packages', 'payments' => 'Zahlungen und Packages',
'payments_desc' => 'Wir verarbeiten Zahlungen für Packages über Paddle. Zahlungsinformationen werden sicher und verschlüsselt durch Paddle als Merchant of Record verarbeitet.', 'payments_desc' => 'Wir verarbeiten Zahlungen für Packages über Paddle. Zahlungsinformationen werden sicher und verschlüsselt durch Paddle als Merchant of Record verarbeitet.',

View File

@@ -234,7 +234,7 @@
"discover_packages": "Pakete entdecken" "discover_packages": "Pakete entdecken"
}, },
"footer": { "footer": {
"company": "Fotospiel GmbH", "company": "S.E.B. Fotografie",
"rights_reserved": "Alle Rechte vorbehalten" "rights_reserved": "Alle Rechte vorbehalten"
}, },
"register": { "register": {

View File

@@ -97,7 +97,7 @@ return [
'register' => 'Registrieren', 'register' => 'Registrieren',
], ],
'footer' => [ 'footer' => [
'company' => 'Fotospiel GmbH', 'company' => 'S.E.B. Fotografie',
'rights_reserved' => 'Alle Rechte vorbehalten', 'rights_reserved' => 'Alle Rechte vorbehalten',
], ],
'legal' => [ 'legal' => [

17
resources/lang/en/api.php Normal file
View File

@@ -0,0 +1,17 @@
<?php
return [
'join_tokens' => [
'invalid_title' => 'Invalid QR access link',
'invalid_message' => 'We could not verify this QR link or access code. Please scan it again or ask the host for a fresh link.',
'expired_title' => 'QR access expired',
'expired_message' => 'This QR access link is no longer valid because the event has already ended.',
'revoked_title' => 'QR access disabled',
'revoked_message' => 'The organiser disabled this QR access link. Please request a new one.',
'rate_limited_title' => 'Too many QR attempts',
'rate_limited_message' => 'You tried to open the QR access too often. Please wait a moment and try again.',
'invalid_attempts_message' => 'Too many invalid QR attempts. Please wait a moment and try again.',
'default_title' => 'Access denied',
'default_message' => 'We could not grant access with this QR link.',
],
];

View File

@@ -6,7 +6,7 @@ return [
'impressum_title' => 'Imprint - Fotospiel', 'impressum_title' => 'Imprint - Fotospiel',
'datenschutz_title' => 'Privacy Policy - Fotospiel', 'datenschutz_title' => 'Privacy Policy - Fotospiel',
'impressum_section' => 'Information pursuant to § 5 TMG', 'impressum_section' => 'Information pursuant to § 5 TMG',
'company' => 'Fotospiel GmbH', 'company' => 'S.E.B. Fotografie',
'address' => 'Musterstraße 1, 12345 Musterstadt', 'address' => 'Musterstraße 1, 12345 Musterstadt',
'representative' => 'Represented by: Max Mustermann', 'representative' => 'Represented by: Max Mustermann',
'contact' => 'Contact', 'contact' => 'Contact',
@@ -16,7 +16,7 @@ return [
'register_court' => 'Register Court: District Court Musterstadt', 'register_court' => 'Register Court: District Court Musterstadt',
'commercial_register' => 'Commercial Register: HRB 12345', 'commercial_register' => 'Commercial Register: HRB 12345',
'datenschutz_intro' => 'We take the protection of your personal data very seriously and strictly adhere to the rules of data protection laws.', 'datenschutz_intro' => 'We take the protection of your personal data very seriously and strictly adhere to the rules of data protection laws.',
'responsible' => 'Responsible: Fotospiel GmbH, Musterstraße 1, 12345 Musterstadt', 'responsible' => 'Responsible: S.E.B. Fotografie, Musterstraße 1, 12345 Musterstadt',
'data_collection' => 'Data collection: No PII storage, anonymous sessions for guests. Emails are only processed for contact purposes.', 'data_collection' => 'Data collection: No PII storage, anonymous sessions for guests. Emails are only processed for contact purposes.',
'payments' => 'Payments and Packages', 'payments' => 'Payments and Packages',
'payments_desc' => 'We process payments for Packages via Paddle. Payment information is handled securely and encrypted by Paddle as the merchant of record.', 'payments_desc' => 'We process payments for Packages via Paddle. Payment information is handled securely and encrypted by Paddle as the merchant of record.',

View File

@@ -234,7 +234,7 @@
"discover_packages": "Discover Packages" "discover_packages": "Discover Packages"
}, },
"footer": { "footer": {
"company": "Fotospiel GmbH", "company": "S.E.B. Fotografie",
"rights_reserved": "All rights reserved" "rights_reserved": "All rights reserved"
}, },
"register": { "register": {

View File

@@ -97,7 +97,7 @@ return [
'register' => 'Register', 'register' => 'Register',
], ],
'footer' => [ 'footer' => [
'company' => 'Fotospiel GmbH', 'company' => 'S.E.B. Fotografie',
'rights_reserved' => 'All rights reserved', 'rights_reserved' => 'All rights reserved',
], ],
'legal' => [ 'legal' => [

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}"> <meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ __('admin.shell.tenant_admin_title') }}</title> <title>{{ __('admin.shell.tenant_admin_title') }}</title>
<link rel="icon" href="{{ asset('favicon.ico') }}" type="image/x-icon">
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#f43f5e"> <meta name="theme-color" content="#f43f5e">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">

View File

@@ -30,8 +30,7 @@
<title inertia>{{ config('app.name', 'Laravel') }}</title> <title inertia>{{ config('app.name', 'Laravel') }}</title>
<link rel="icon" href="/favicon.ico" sizes="any"> <link rel="icon" href="{{ asset('favicon.ico') }}" type="image/x-icon">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="preconnect" href="https://fonts.bunny.net"> <link rel="preconnect" href="https://fonts.bunny.net">

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ config('app.name', 'Fotospiel') }}</title> <title>{{ config('app.name', 'Fotospiel') }}</title>
<meta name="csrf-token" content="{{ csrf_token() }}"> <meta name="csrf-token" content="{{ csrf_token() }}">
<link rel="icon" href="{{ asset('favicon.ico') }}" type="image/x-icon">
@viteReactRefresh @viteReactRefresh
@vite(['resources/css/app.css', 'resources/js/guest/main.tsx']) @vite(['resources/css/app.css', 'resources/js/guest/main.tsx'])
@php @php

View File

@@ -9,7 +9,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>@yield('title', 'Fotospiel - Event-Fotos einfach und sicher mit QR-Codes')</title> <title>@yield('title', 'Fotospiel - Event-Fotos einfach und sicher mit QR-Codes')</title>
<meta name="description" content="Sammle Gastfotos für Events mit QR-Codes und unserer PWA-Plattform. Für Hochzeiten, Firmenevents und mehr. Kostenloser Einstieg."> <meta name="description" content="Sammle Gastfotos für Events mit QR-Codes und unserer PWA-Plattform. Für Hochzeiten, Firmenevents und mehr. Kostenloser Einstieg.">
<link rel="icon" href="{{ asset('logo.svg') }}" type="image/svg+xml"> <link rel="icon" href="{{ asset('favicon.ico') }}" type="image/x-icon">
<meta name="csrf-token" content="{{ csrf_token() }}"> <meta name="csrf-token" content="{{ csrf_token() }}">
@if($scriptNonce) @if($scriptNonce)
<meta name="csp-nonce" content="{{ $scriptNonce }}"> <meta name="csp-nonce" content="{{ $scriptNonce }}">