das marketing frontend wurde auf lokalisierte urls umgestellt.
This commit is contained in:
@@ -150,6 +150,9 @@ class SendAbandonedCheckoutReminders extends Command
|
|||||||
{
|
{
|
||||||
// Für jetzt: Einfache Package-URL
|
// Für jetzt: Einfache Package-URL
|
||||||
// Später: Persönliche Resume-Token URLs
|
// Später: Persönliche Resume-Token URLs
|
||||||
return route('buy.packages', $checkout->package_id);
|
return route('buy.packages', [
|
||||||
|
'locale' => config('app.locale', 'de'),
|
||||||
|
'packageId' => $checkout->package_id,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class RegisteredUserController extends Controller
|
|||||||
{
|
{
|
||||||
$fullName = trim($request->first_name.' '.$request->last_name);
|
$fullName = trim($request->first_name.' '.$request->last_name);
|
||||||
|
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'username' => ['required', 'string', 'max:255', 'unique:'.User::class],
|
'username' => ['required', 'string', 'max:255', 'unique:'.User::class],
|
||||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
|
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
|
||||||
@@ -51,7 +52,7 @@ class RegisteredUserController extends Controller
|
|||||||
'package_id' => ['nullable', 'exists:packages,id'],
|
'package_id' => ['nullable', 'exists:packages,id'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$shouldAutoVerify = App::environment(['local', 'testing']);
|
$shouldAutoVerify = App::environment('local');
|
||||||
|
|
||||||
$user = User::create([
|
$user = User::create([
|
||||||
'username' => $validated['username'],
|
'username' => $validated['username'],
|
||||||
@@ -98,12 +99,16 @@ class RegisteredUserController extends Controller
|
|||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (! $user->tenant_id) {
|
||||||
|
$user->forceFill(['tenant_id' => $tenant->id])->save();
|
||||||
|
}
|
||||||
|
|
||||||
event(new Registered($user));
|
event(new Registered($user));
|
||||||
|
|
||||||
// Send Welcome Email
|
// Send Welcome Email
|
||||||
Mail::to($user)
|
Mail::to($user)
|
||||||
->locale($user->preferred_locale ?? app()->getLocale())
|
->locale($user->preferred_locale ?? app()->getLocale())
|
||||||
->queue(new \App\Mail\Welcome($user));
|
->send(new \App\Mail\Welcome($user));
|
||||||
|
|
||||||
if ($request->filled('package_id')) {
|
if ($request->filled('package_id')) {
|
||||||
$package = \App\Models\Package::find($request->package_id);
|
$package = \App\Models\Package::find($request->package_id);
|
||||||
@@ -131,7 +136,10 @@ class RegisteredUserController extends Controller
|
|||||||
Auth::login($user);
|
Auth::login($user);
|
||||||
} elseif ($package) {
|
} elseif ($package) {
|
||||||
// Redirect to buy for paid package
|
// Redirect to buy for paid package
|
||||||
return redirect()->route('marketing.buy', $package->id);
|
return redirect()->route('buy.packages', [
|
||||||
|
'locale' => session('preferred_locale', app()->getLocale()),
|
||||||
|
'packageId' => $package->id,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -200,7 +200,9 @@ class CheckoutGoogleController extends Controller
|
|||||||
return redirect()->route('purchase.wizard', ['package' => $firstPackageId]);
|
return redirect()->route('purchase.wizard', ['package' => $firstPackageId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect()->route('packages');
|
return redirect()->route('packages', [
|
||||||
|
'locale' => app()->getLocale(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function flashError(Request $request, string $message): void
|
private function flashError(Request $request, string $message): void
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\LegalPage;
|
use App\Models\LegalPage;
|
||||||
|
use Illuminate\Database\QueryException;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
@@ -16,21 +18,42 @@ use League\CommonMark\MarkdownConverter;
|
|||||||
|
|
||||||
class LegalPageController extends Controller
|
class LegalPageController extends Controller
|
||||||
{
|
{
|
||||||
public function show(?string $slug = null): Response
|
public function show(Request $request, string $locale, ?string $slug = null): Response
|
||||||
{
|
{
|
||||||
$resolvedSlug = $this->resolveSlug($slug);
|
$resolvedSlug = $this->resolveSlug($slug);
|
||||||
|
$page = null;
|
||||||
|
|
||||||
|
try {
|
||||||
$page = LegalPage::query()
|
$page = LegalPage::query()
|
||||||
->where('slug', $resolvedSlug)
|
->where('slug', $resolvedSlug)
|
||||||
->where('is_published', true)
|
->where('is_published', true)
|
||||||
->orderByDesc('version')
|
->orderByDesc('version')
|
||||||
->first();
|
->first();
|
||||||
|
} catch (QueryException $exception) {
|
||||||
|
// Table does not exist or query failed; fallback to filesystem documents
|
||||||
|
$page = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$locale = $request->route('locale', app()->getLocale());
|
||||||
|
|
||||||
if (! $page) {
|
if (! $page) {
|
||||||
|
$fallback = $this->loadFallbackDocument($resolvedSlug, $locale);
|
||||||
|
|
||||||
|
if (! $fallback) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$locale = app()->getLocale();
|
return Inertia::render('legal/Show', [
|
||||||
|
'seoTitle' => $fallback['title'].' - '.config('app.name', 'Fotospiel'),
|
||||||
|
'title' => $fallback['title'],
|
||||||
|
'content' => $this->convertMarkdownToHtml($fallback['markdown']),
|
||||||
|
'effectiveFrom' => null,
|
||||||
|
'effectiveFromLabel' => null,
|
||||||
|
'versionLabel' => null,
|
||||||
|
'slug' => $resolvedSlug,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$title = $page->title[$locale]
|
$title = $page->title[$locale]
|
||||||
?? $page->title[$page->locale_fallback]
|
?? $page->title[$page->locale_fallback]
|
||||||
?? $page->title['de']
|
?? $page->title['de']
|
||||||
@@ -87,4 +110,56 @@ class LegalPageController extends Controller
|
|||||||
|
|
||||||
return trim((string) $converter->convert($markdown));
|
return trim((string) $converter->convert($markdown));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function loadFallbackDocument(string $slug, string $locale): ?array
|
||||||
|
{
|
||||||
|
$candidates = array_unique([
|
||||||
|
strtolower($locale),
|
||||||
|
strtolower(config('app.fallback_locale', 'de')),
|
||||||
|
'de',
|
||||||
|
'en',
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($candidates as $candidateLocale) {
|
||||||
|
$path = base_path("docs/legal/{$slug}-{$candidateLocale}.md");
|
||||||
|
|
||||||
|
if (! is_file($path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$markdown = (string) file_get_contents($path);
|
||||||
|
$title = $this->extractTitleFromMarkdown($markdown) ?? Str::title($slug);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'markdown' => $markdown,
|
||||||
|
'title' => $title,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractTitleFromMarkdown(string $markdown): ?string
|
||||||
|
{
|
||||||
|
foreach (preg_split('/\r?\n/', $markdown) as $line) {
|
||||||
|
$trimmed = trim($line);
|
||||||
|
|
||||||
|
if ($trimmed === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($trimmed, '# ')) {
|
||||||
|
return trim(substr($trimmed, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($trimmed, '## ')) {
|
||||||
|
return trim(substr($trimmed, 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
// First non-empty line can act as fallback title
|
||||||
|
return $trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,14 +11,31 @@ class LocaleController extends Controller
|
|||||||
public function set(Request $request)
|
public function set(Request $request)
|
||||||
{
|
{
|
||||||
$locale = $request->input('locale');
|
$locale = $request->input('locale');
|
||||||
|
$supportedLocales = array_values(array_unique(array_filter([
|
||||||
|
config('app.locale'),
|
||||||
|
config('app.fallback_locale'),
|
||||||
|
...array_filter(array_map(
|
||||||
|
static fn ($value) => trim((string) $value),
|
||||||
|
explode(',', (string) env('APP_SUPPORTED_LOCALES', ''))
|
||||||
|
)),
|
||||||
|
])));
|
||||||
|
|
||||||
|
if (empty($supportedLocales)) {
|
||||||
$supportedLocales = ['de', 'en'];
|
$supportedLocales = ['de', 'en'];
|
||||||
|
}
|
||||||
|
|
||||||
if (in_array($locale, $supportedLocales)) {
|
if (in_array($locale, $supportedLocales)) {
|
||||||
App::setLocale($locale);
|
App::setLocale($locale);
|
||||||
Session::put('locale', $locale);
|
Session::put('locale', $locale);
|
||||||
|
Session::put('preferred_locale', $locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json([
|
||||||
|
'locale' => App::getLocale(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return JSON response for fetch requests
|
|
||||||
return back();
|
return back();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,17 +78,33 @@ class MarketingController extends Controller
|
|||||||
->with('success', __('marketing.contact.success', [], $locale));
|
->with('success', __('marketing.contact.success', [], $locale));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function contactView()
|
public function contactView(Request $request)
|
||||||
{
|
{
|
||||||
return Inertia::render('marketing.Kontakt');
|
$locale = app()->getLocale();
|
||||||
|
$secondSegment = $request->segment(2);
|
||||||
|
$slug = $secondSegment ? '/'.trim((string) $secondSegment, '/') : '/';
|
||||||
|
|
||||||
|
if ($locale === 'en' && $slug === '/kontakt') {
|
||||||
|
return redirect()->route('marketing.contact', [
|
||||||
|
'locale' => $request->route('locale') ?? $locale,
|
||||||
|
], 301);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($locale === 'de' && $slug === '/contact') {
|
||||||
|
return redirect()->route('kontakt', [
|
||||||
|
'locale' => $request->route('locale') ?? $locale,
|
||||||
|
], 301);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Inertia::render('marketing/Kontakt');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle package purchase flow.
|
* Handle package purchase flow.
|
||||||
*/
|
*/
|
||||||
public function buyPackages(Request $request, $packageId)
|
public function buyPackages(Request $request, string $locale, $packageId)
|
||||||
{
|
{
|
||||||
Log::info('Buy packages called', ['auth' => Auth::check(), 'package_id' => $packageId]);
|
Log::info('Buy packages called', ['auth' => Auth::check(), 'locale' => $locale, 'package_id' => $packageId]);
|
||||||
$package = Package::findOrFail($packageId);
|
$package = Package::findOrFail($packageId);
|
||||||
|
|
||||||
if (! Auth::check()) {
|
if (! Auth::check()) {
|
||||||
@@ -138,7 +154,10 @@ class MarketingController extends Controller
|
|||||||
if (! $package->paddle_price_id) {
|
if (! $package->paddle_price_id) {
|
||||||
Log::warning('Package missing Paddle price id', ['package_id' => $package->id]);
|
Log::warning('Package missing Paddle price id', ['package_id' => $package->id]);
|
||||||
|
|
||||||
return redirect()->route('packages', ['highlight' => $package->slug])
|
return redirect()->route('packages', [
|
||||||
|
'locale' => app()->getLocale(),
|
||||||
|
'highlight' => $package->slug,
|
||||||
|
])
|
||||||
->with('error', __('marketing.packages.paddle_not_configured'));
|
->with('error', __('marketing.packages.paddle_not_configured'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,8 +168,14 @@ class MarketingController extends Controller
|
|||||||
$this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
$this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
||||||
|
|
||||||
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, [
|
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, [
|
||||||
'success_url' => route('marketing.success', ['packageId' => $package->id]),
|
'success_url' => route('marketing.success', [
|
||||||
'return_url' => route('packages', ['highlight' => $package->slug]),
|
'locale' => app()->getLocale(),
|
||||||
|
'packageId' => $package->id,
|
||||||
|
]),
|
||||||
|
'return_url' => route('packages', [
|
||||||
|
'locale' => app()->getLocale(),
|
||||||
|
'highlight' => $package->slug,
|
||||||
|
]),
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'checkout_session_id' => $session->id,
|
'checkout_session_id' => $session->id,
|
||||||
],
|
],
|
||||||
@@ -314,22 +339,69 @@ class MarketingController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function occasionsType($type)
|
public function occasionsType(Request $request, string $locale, string $type)
|
||||||
{
|
{
|
||||||
Log::info('OccasionsType hit', [
|
Log::info('OccasionsType hit', [
|
||||||
'type' => $type,
|
'type' => $type,
|
||||||
'locale' => app()->getLocale(),
|
'locale' => $locale,
|
||||||
'url' => request()->fullUrl(),
|
'url' => request()->fullUrl(),
|
||||||
'route' => request()->route()->getName(),
|
'route' => request()->route()->getName(),
|
||||||
'isInertia' => request()->header('X-Inertia'),
|
'isInertia' => request()->header('X-Inertia'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$validTypes = ['hochzeit', 'geburtstag', 'firmenevent', 'konfirmation'];
|
$normalized = strtolower($type);
|
||||||
if (! in_array($type, $validTypes)) {
|
$typeMap = [
|
||||||
|
'hochzeit' => 'hochzeit',
|
||||||
|
'wedding' => 'hochzeit',
|
||||||
|
'geburtstag' => 'geburtstag',
|
||||||
|
'birthday' => 'geburtstag',
|
||||||
|
'firmenevent' => 'firmenevent',
|
||||||
|
'corporate-event' => 'firmenevent',
|
||||||
|
'konfirmation' => 'konfirmation',
|
||||||
|
'confirmation' => 'konfirmation',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (! array_key_exists($normalized, $typeMap)) {
|
||||||
Log::warning('Invalid occasion type accessed', ['type' => $type]);
|
Log::warning('Invalid occasion type accessed', ['type' => $type]);
|
||||||
abort(404, 'Invalid occasion type');
|
abort(404, 'Invalid occasion type');
|
||||||
}
|
}
|
||||||
|
|
||||||
return Inertia::render('marketing/Occasions', ['type' => $type]);
|
$baseSlug = $typeMap[$normalized];
|
||||||
|
|
||||||
|
$canonical = [
|
||||||
|
'hochzeit' => [
|
||||||
|
'de' => 'hochzeit',
|
||||||
|
'en' => 'wedding',
|
||||||
|
],
|
||||||
|
'geburtstag' => [
|
||||||
|
'de' => 'geburtstag',
|
||||||
|
'en' => 'birthday',
|
||||||
|
],
|
||||||
|
'firmenevent' => [
|
||||||
|
'de' => 'firmenevent',
|
||||||
|
'en' => 'corporate-event',
|
||||||
|
],
|
||||||
|
'konfirmation' => [
|
||||||
|
'de' => 'konfirmation',
|
||||||
|
'en' => 'confirmation',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$canonicalSlug = $canonical[$baseSlug][$locale] ?? $baseSlug;
|
||||||
|
$currentSlug = strtolower($type);
|
||||||
|
|
||||||
|
if ($currentSlug !== $canonicalSlug) {
|
||||||
|
$routeName = $locale === 'en' ? 'occasions.type' : 'anlaesse.type';
|
||||||
|
|
||||||
|
return redirect()->route($routeName, [
|
||||||
|
'locale' => $locale,
|
||||||
|
'type' => $canonicalSlug,
|
||||||
|
], 301);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Inertia::render('marketing/Occasions', [
|
||||||
|
'type' => $baseSlug,
|
||||||
|
'requestedType' => $normalized,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
app/Http/Middleware/Authenticate.php
Normal file
22
app/Http/Middleware/Authenticate.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Illuminate\Auth\Middleware\Authenticate as Middleware;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class Authenticate extends Middleware
|
||||||
|
{
|
||||||
|
protected function redirectTo(Request $request): ?string
|
||||||
|
{
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->routeIs('buy.packages') && $request->route('packageId')) {
|
||||||
|
return route('register', ['package_id' => $request->route('packageId')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return route('login');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,7 @@ class HandleInertiaRequests extends Middleware
|
|||||||
'user' => $request->user(),
|
'user' => $request->user(),
|
||||||
],
|
],
|
||||||
'supportedLocales' => $supportedLocales,
|
'supportedLocales' => $supportedLocales,
|
||||||
|
'appUrl' => rtrim(config('app.url'), '/'),
|
||||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||||
'locale' => app()->getLocale(),
|
'locale' => app()->getLocale(),
|
||||||
'translations' => [
|
'translations' => [
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class SetLocale
|
|||||||
|
|
||||||
App::setLocale($sessionLocale);
|
App::setLocale($sessionLocale);
|
||||||
Session::put('locale', $sessionLocale);
|
Session::put('locale', $sessionLocale);
|
||||||
|
Session::put('preferred_locale', $sessionLocale);
|
||||||
|
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|||||||
61
app/Http/Middleware/SetLocaleFromRequest.php
Normal file
61
app/Http/Middleware/SetLocaleFromRequest.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\App;
|
||||||
|
use Illuminate\Support\Facades\Session;
|
||||||
|
|
||||||
|
class SetLocaleFromRequest
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next)
|
||||||
|
{
|
||||||
|
$supportedLocales = $this->supportedLocales();
|
||||||
|
|
||||||
|
$locale = (string) $request->route('locale');
|
||||||
|
|
||||||
|
if (! $locale || ! in_array($locale, $supportedLocales, true)) {
|
||||||
|
$preferred = Session::get('preferred_locale');
|
||||||
|
|
||||||
|
if ($preferred && in_array($preferred, $supportedLocales, true)) {
|
||||||
|
App::setLocale($preferred);
|
||||||
|
Session::put('locale', $preferred);
|
||||||
|
$request->attributes->set('preferred_locale', $preferred);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
App::setLocale($locale);
|
||||||
|
Session::put('preferred_locale', $locale);
|
||||||
|
Session::put('locale', $locale);
|
||||||
|
$request->attributes->set('preferred_locale', $locale);
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function supportedLocales(): array
|
||||||
|
{
|
||||||
|
$configured = array_filter(array_map(
|
||||||
|
static fn ($value) => trim((string) $value),
|
||||||
|
explode(',', (string) env('APP_SUPPORTED_LOCALES', ''))
|
||||||
|
));
|
||||||
|
|
||||||
|
if (empty($configured)) {
|
||||||
|
$configured = array_filter([
|
||||||
|
config('app.locale'),
|
||||||
|
config('app.fallback_locale'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($configured)) {
|
||||||
|
$configured = ['de', 'en'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($configured));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ namespace App\Services\Paddle;
|
|||||||
use App\Models\Package;
|
use App\Models\Package;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\App;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class PaddleCheckoutService
|
class PaddleCheckoutService
|
||||||
@@ -21,8 +22,14 @@ class PaddleCheckoutService
|
|||||||
{
|
{
|
||||||
$customerId = $this->customers->ensureCustomerId($tenant);
|
$customerId = $this->customers->ensureCustomerId($tenant);
|
||||||
|
|
||||||
$successUrl = $options['success_url'] ?? route('marketing.success', ['packageId' => $package->id]);
|
$successUrl = $options['success_url'] ?? route('marketing.success', [
|
||||||
$returnUrl = $options['return_url'] ?? route('packages', ['highlight' => $package->slug]);
|
'locale' => App::getLocale(),
|
||||||
|
'packageId' => $package->id,
|
||||||
|
]);
|
||||||
|
$returnUrl = $options['return_url'] ?? route('packages', [
|
||||||
|
'locale' => App::getLocale(),
|
||||||
|
'highlight' => $package->slug,
|
||||||
|
]);
|
||||||
|
|
||||||
$metadata = $this->buildMetadata($tenant, $package, $options['metadata'] ?? []);
|
$metadata = $this->buildMetadata($tenant, $package, $options['metadata'] ?? []);
|
||||||
|
|
||||||
|
|||||||
@@ -3,20 +3,35 @@
|
|||||||
## Goal
|
## Goal
|
||||||
Establish a consistent canonical and hreflang setup for the marketing site so search engines can index German and English content without duplicate-content penalties.
|
Establish a consistent canonical and hreflang setup for the marketing site so search engines can index German and English content without duplicate-content penalties.
|
||||||
|
|
||||||
## Status (Stand 17.02.2026)
|
## Status (Stand 18.02.2026)
|
||||||
- **Discovery:** Not started.
|
- **Discovery:** In progress (route audit complete).
|
||||||
- **Implementation:** Not started.
|
- **Implementation:** In progress (canonical and locale-prefixed routing live).
|
||||||
- **Validation:** Not started.
|
- **Validation:** Not started.
|
||||||
|
|
||||||
## Discovery
|
## Discovery
|
||||||
- [ ] Audit current route map and localized content coverage (marketing pages, blog, checkout flow).
|
- [x] Audit current route map and localized content coverage (marketing pages, blog, checkout flow).
|
||||||
- [ ] Decide on URL strategy (session-based locale vs. language-prefixed routes) and document migration implications.
|
- Marketing routes live in `routes/web.php` without locale prefixes. Locale handling is session-based via `LocaleController::set`, `HandleInertiaRequests`, and `useLocalizedRoutes`.
|
||||||
- [ ] Identify required updates to `MarketingLayout`, sitemap generation, and Inertia responses for localized alternates.
|
- Slug coverage is mixed: `/contact` (EN) and `/kontakt` (DE) coexist, `/how-it-works` vs. `/so-funktionierts`, while other key pages only exist once (e.g. `/packages`, `/demo`, `/blog`, legal pages such as `/impressum` with no EN variant). Occasion detail routes are German-only (`/anlaesse/{type}` with `hochzeit`, `geburtstag`, etc.).
|
||||||
|
- Blog URLs are shared across locales (`/blog`, `/blog/{slug}`), with translated content injected server-side. Checkout surfaces (`/purchase-wizard/{package}`, `/checkout/{package}`) rely on shared slugs and localized copy but no alternate URLs.
|
||||||
|
- `MarketingLayout` currently generates a single canonical URL per request and an `x-default` alternate, but no locale-specific `hreflang` links. Canonical calculation removes a `/de|/en` prefix even though paths are prefix-free, so both locales resolve to the same canonical if we later introduce prefixed URLs.
|
||||||
|
- The sitemap at `public/sitemap.xml` already lists `/de/...` and `/en/...` alternates that the app does not currently serve, causing mismatch risk.
|
||||||
|
- [x] Decide on URL strategy (session-based locale vs. language-prefixed routes) and document migration implications.
|
||||||
|
- Decision: adopt path-prefixed locales (`/{locale}/{slug}`) for all marketing and checkout-facing routes, matching the i18n PRP guidance. Default requests (`/foo`) will 301 to the locale-specific URL using the visitor’s persisted preference or `de` fallback.
|
||||||
|
- Migration outline:
|
||||||
|
1. Introduce a `SetLocaleFromPath` middleware to extract the first segment and share it with Inertia; wire it into a `Route::group` with `prefix('{locale}')` (constrained to `de|en`) in `routes/web.php`.
|
||||||
|
2. Move existing marketing routes into the prefixed group, normalising slugs so EN and DE share identical structures where feasible (e.g. `/de/kontakt` → `/de/contact` or `/de/kontakt` mirrored by `/en/contact`). Keep legacy German-only slugs (`/so-funktionierts`, `/anlaesse/...`) behind per-locale path maps.
|
||||||
|
3. Add legacy fallback routes (without prefix) that permanently redirect to the new prefixed URLs to preserve existing backlinks and sitemap entries.
|
||||||
|
4. Ensure Inertia helpers (`useLocalizedRoutes`) and navigation components build URLs with the active locale segment instead of relying on session posts to `/set-locale`.
|
||||||
|
- Blog slugs remain language-agnostic identifiers under `/de/blog/{slug}` and `/en/blog/{slug}`; content localization continues via translated fields.
|
||||||
|
- [x] Identify required updates to `MarketingLayout`, sitemap generation, and Inertia responses for localized alternates.
|
||||||
|
- `MarketingLayout` needs to accept structured SEO props (canonical URL, title, description, alternates) instead of deriving everything client-side. It should emit `<link rel="alternate" hreflang="">` entries for each supported locale plus `x-default`, set `og:locale`/`og:locale:alternate`, and keep canonical URLs locale-specific (no prefix stripping).
|
||||||
|
- Server responses should standardise SEO data via a helper (e.g. `MarketingPage::make()` or dedicated view model) so each `Inertia::render` call provides `seo` props with `canonical`, `alternates`, and translated meta. `HandleInertiaRequests` can share `supportedLocales` and the resolved host, while a new `LocalizedUrlGenerator` service maps routes/slugs per locale.
|
||||||
|
- The static `public/sitemap.xml` must be regenerated (or replaced with an automated generator/Artisan command) once prefixed URLs exist, ensuring each entry carries self-referential canonicals and paired `xhtml:link` elements. Include blog detail pages and legal pages for both locales.
|
||||||
|
|
||||||
## Implementation
|
## Implementation
|
||||||
- [ ] Ensure canonical URLs and hreflang tags are generated per locale with reciprocal references.
|
- [x] Ensure canonical URLs and hreflang tags are generated per locale with reciprocal references.
|
||||||
- [ ] Expose locale-specific URLs in navigation, Open Graph tags, and any structured data.
|
- [x] Expose locale-specific URLs in navigation, Open Graph tags, and any structured data.
|
||||||
- [ ] Update translation files and config to support the chosen URL strategy.
|
- [x] Update translation files and config to support the chosen URL strategy.
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
- [ ] Add automated checks (feature test or Playwright) verifying hreflang/canonical tags for both locales.
|
- [ ] Add automated checks (feature test or Playwright) verifying hreflang/canonical tags for both locales.
|
||||||
@@ -24,6 +39,5 @@ Establish a consistent canonical and hreflang setup for the marketing site so se
|
|||||||
- [ ] Update docs (PRP + marketing playbooks) with the final hreflang strategy and operational guidance.
|
- [ ] Update docs (PRP + marketing playbooks) with the final hreflang strategy and operational guidance.
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
- Should blog posts use language-specific slugs or shared identifiers with query parameters?
|
|
||||||
- How will we handle locale fallbacks for missing translations when hreflang is enforced?
|
- How will we handle locale fallbacks for missing translations when hreflang is enforced?
|
||||||
- Do we need localized sitemap indexes per language or a unified sitemap with hreflang annotations?
|
- Do we need localized sitemap indexes per language or a unified sitemap with hreflang annotations?
|
||||||
|
|||||||
@@ -361,7 +361,22 @@
|
|||||||
"contact": "Kontakt",
|
"contact": "Kontakt",
|
||||||
"discover_packages": "Pakete entdecken",
|
"discover_packages": "Pakete entdecken",
|
||||||
"privacy": "Datenschutz",
|
"privacy": "Datenschutz",
|
||||||
"impressum": "Impressum"
|
"impressum": "Impressum",
|
||||||
|
"occasions_types": {
|
||||||
|
"confirmation": "Konfirmation & Jugendweihe"
|
||||||
|
},
|
||||||
|
"language": "Sprache",
|
||||||
|
"open_menu": "Menü öffnen",
|
||||||
|
"close_menu": "Menü schließen",
|
||||||
|
"cta_demo": "Jetzt ausprobieren",
|
||||||
|
"preferences": "Einstellungen",
|
||||||
|
"toggle_theme": "Darstellung wechseln",
|
||||||
|
"theme_light": "Helles Design",
|
||||||
|
"theme_dark": "Dunkles Design",
|
||||||
|
"dashboard": "Zum Admin-Bereich",
|
||||||
|
"logout": "Abmelden",
|
||||||
|
"login": "Anmelden",
|
||||||
|
"register": "Registrieren"
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"company": "Fotospiel GmbH",
|
"company": "Fotospiel GmbH",
|
||||||
@@ -821,5 +836,25 @@
|
|||||||
"description": "Teste Freigabemodus, Reaktionen und Favoriten – alles DSGVO-konform."
|
"description": "Teste Freigabemodus, Reaktionen und Favoriten – alles DSGVO-konform."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"not_found": {
|
||||||
|
"title": "Seite nicht gefunden",
|
||||||
|
"subtitle": "Ups! Diese Seite existiert nicht mehr.",
|
||||||
|
"description": "Vielleicht wurde der Link verschoben oder der Inhalt existiert nicht mehr. Hier sind ein paar Optionen, wie du weitermachen kannst.",
|
||||||
|
"tip_heading": "Was du tun kannst",
|
||||||
|
"tips": [
|
||||||
|
"Prüfe die URL auf mögliche Tippfehler.",
|
||||||
|
"Gehe zurück zur Startseite und entdecke unsere Funktionen.",
|
||||||
|
"Kontaktiere uns, wenn du etwas Bestimmtes suchst."
|
||||||
|
],
|
||||||
|
"cta_home": "Zur Startseite",
|
||||||
|
"cta_packages": "Pakete entdecken",
|
||||||
|
"cta_contact": "Kontakt aufnehmen",
|
||||||
|
"requested_path_label": "Angefragter Pfad"
|
||||||
|
},
|
||||||
|
"legal": {
|
||||||
|
"imprint": "Impressum",
|
||||||
|
"privacy": "Datenschutz",
|
||||||
|
"terms": "AGB"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -347,7 +347,22 @@
|
|||||||
"contact": "Contact",
|
"contact": "Contact",
|
||||||
"discover_packages": "Discover Packages",
|
"discover_packages": "Discover Packages",
|
||||||
"privacy": "Privacy",
|
"privacy": "Privacy",
|
||||||
"impressum": "Imprint"
|
"impressum": "Imprint",
|
||||||
|
"occasions_types": {
|
||||||
|
"confirmation": "Confirmations"
|
||||||
|
},
|
||||||
|
"language": "Language",
|
||||||
|
"open_menu": "Open menu",
|
||||||
|
"close_menu": "Close menu",
|
||||||
|
"cta_demo": "Try it now",
|
||||||
|
"preferences": "Preferences",
|
||||||
|
"toggle_theme": "Toggle appearance",
|
||||||
|
"theme_light": "Light mode",
|
||||||
|
"theme_dark": "Dark mode",
|
||||||
|
"dashboard": "Go to Admin",
|
||||||
|
"logout": "Sign out",
|
||||||
|
"login": "Log in",
|
||||||
|
"register": "Register"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
@@ -815,5 +830,25 @@
|
|||||||
"description": "Test approval, reactions, and favourites – fully GDPR compliant."
|
"description": "Test approval, reactions, and favourites – fully GDPR compliant."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"not_found": {
|
||||||
|
"title": "Page not found",
|
||||||
|
"subtitle": "Oops! This page is nowhere to be found.",
|
||||||
|
"description": "It may have moved or never existed. Try one of the options below to get back on track.",
|
||||||
|
"tip_heading": "What you can do",
|
||||||
|
"tips": [
|
||||||
|
"Double-check the URL for typos.",
|
||||||
|
"Head back to the homepage to continue exploring.",
|
||||||
|
"Reach out to us if you need a specific page."
|
||||||
|
],
|
||||||
|
"cta_home": "Back to homepage",
|
||||||
|
"cta_packages": "Explore packages",
|
||||||
|
"cta_contact": "Get in touch",
|
||||||
|
"requested_path_label": "Requested path"
|
||||||
|
},
|
||||||
|
"legal": {
|
||||||
|
"imprint": "Imprint",
|
||||||
|
"privacy": "Privacy",
|
||||||
|
"terms": "Terms & Conditions"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,79 +1,112 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||||
xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"
|
xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
||||||
xmlns:xhtml="http://www.w3.org/1999/xhtml"
|
|
||||||
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"
|
|
||||||
xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
|
|
||||||
<url>
|
|
||||||
<loc>https://fotospiel.app/</loc>
|
|
||||||
<lastmod>2025-10-02</lastmod>
|
|
||||||
<changefreq>weekly</changefreq>
|
|
||||||
<priority>1.0</priority>
|
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://fotospiel.app/de/" />
|
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://fotospiel.app/en/" />
|
|
||||||
</url>
|
|
||||||
<url>
|
<url>
|
||||||
<loc>https://fotospiel.app/de/</loc>
|
<loc>https://fotospiel.app/de/</loc>
|
||||||
<lastmod>2025-10-02</lastmod>
|
<lastmod>2026-02-18</lastmod>
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>1.0</priority>
|
<priority>1.0</priority>
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://fotospiel.app/de/" />
|
<xhtml:link rel="alternate" hreflang="de" href="https://fotospiel.app/de/" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://fotospiel.app/en/" />
|
<xhtml:link rel="alternate" hreflang="en" href="https://fotospiel.app/en/" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://fotospiel.app/en/" />
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://fotospiel.app/en/</loc>
|
<loc>https://fotospiel.app/en/</loc>
|
||||||
<lastmod>2025-10-02</lastmod>
|
<lastmod>2026-02-18</lastmod>
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>1.0</priority>
|
<priority>1.0</priority>
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://fotospiel.app/de/" />
|
<xhtml:link rel="alternate" hreflang="de" href="https://fotospiel.app/de/" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://fotospiel.app/en/" />
|
<xhtml:link rel="alternate" hreflang="en" href="https://fotospiel.app/en/" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://fotospiel.app/en/" />
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://fotospiel.app/de/packages</loc>
|
<loc>https://fotospiel.app/de/packages</loc>
|
||||||
<lastmod>2025-10-02</lastmod>
|
<lastmod>2026-02-18</lastmod>
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.9</priority>
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://fotospiel.app/de/packages" />
|
<xhtml:link rel="alternate" hreflang="de" href="https://fotospiel.app/de/packages" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://fotospiel.app/en/packages" />
|
<xhtml:link rel="alternate" hreflang="en" href="https://fotospiel.app/en/packages" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://fotospiel.app/en/packages" />
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://fotospiel.app/en/packages</loc>
|
<loc>https://fotospiel.app/en/packages</loc>
|
||||||
<lastmod>2025-10-02</lastmod>
|
<lastmod>2026-02-18</lastmod>
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.9</priority>
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://fotospiel.app/de/packages" />
|
<xhtml:link rel="alternate" hreflang="de" href="https://fotospiel.app/de/packages" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://fotospiel.app/en/packages" />
|
<xhtml:link rel="alternate" hreflang="en" href="https://fotospiel.app/en/packages" />
|
||||||
</url>
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://fotospiel.app/en/packages" />
|
||||||
<url>
|
|
||||||
<loc>https://fotospiel.app/de/blog</loc>
|
|
||||||
<lastmod>2025-10-02</lastmod>
|
|
||||||
<changefreq>daily</changefreq>
|
|
||||||
<priority>0.7</priority>
|
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://fotospiel.app/de/blog" />
|
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://fotospiel.app/en/blog" />
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://fotospiel.app/en/blog</loc>
|
|
||||||
<lastmod>2025-10-02</lastmod>
|
|
||||||
<changefreq>daily</changefreq>
|
|
||||||
<priority>0.7</priority>
|
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://fotospiel.app/de/blog" />
|
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://fotospiel.app/en/blog" />
|
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://fotospiel.app/de/kontakt</loc>
|
<loc>https://fotospiel.app/de/kontakt</loc>
|
||||||
<lastmod>2025-10-02</lastmod>
|
<lastmod>2026-02-18</lastmod>
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.6</priority>
|
<priority>0.6</priority>
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://fotospiel.app/de/kontakt" />
|
<xhtml:link rel="alternate" hreflang="de" href="https://fotospiel.app/de/kontakt" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://fotospiel.app/en/kontakt" />
|
<xhtml:link rel="alternate" hreflang="en" href="https://fotospiel.app/en/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://fotospiel.app/en/contact" />
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://fotospiel.app/en/kontakt</loc>
|
<loc>https://fotospiel.app/en/contact</loc>
|
||||||
<lastmod>2025-10-02</lastmod>
|
<lastmod>2026-02-18</lastmod>
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.6</priority>
|
<priority>0.6</priority>
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://fotospiel.app/de/kontakt" />
|
<xhtml:link rel="alternate" hreflang="de" href="https://fotospiel.app/de/kontakt" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://fotospiel.app/en/kontakt" />
|
<xhtml:link rel="alternate" hreflang="en" href="https://fotospiel.app/en/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://fotospiel.app/en/contact" />
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://fotospiel.app/de/so-funktionierts</loc>
|
||||||
|
<lastmod>2026-02-18</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://fotospiel.app/de/so-funktionierts" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://fotospiel.app/en/how-it-works" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://fotospiel.app/en/how-it-works" />
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://fotospiel.app/en/how-it-works</loc>
|
||||||
|
<lastmod>2026-02-18</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://fotospiel.app/de/so-funktionierts" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://fotospiel.app/en/how-it-works" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://fotospiel.app/en/how-it-works" />
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://fotospiel.app/de/blog</loc>
|
||||||
|
<lastmod>2026-02-18</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://fotospiel.app/de/blog" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://fotospiel.app/en/blog" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://fotospiel.app/en/blog" />
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://fotospiel.app/en/blog</loc>
|
||||||
|
<lastmod>2026-02-18</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://fotospiel.app/de/blog" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://fotospiel.app/en/blog" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://fotospiel.app/en/blog" />
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://fotospiel.app/de/anlaesse/hochzeit</loc>
|
||||||
|
<lastmod>2026-02-18</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://fotospiel.app/de/anlaesse/hochzeit" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://fotospiel.app/en/occasions/wedding" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://fotospiel.app/en/occasions/wedding" />
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://fotospiel.app/en/occasions/wedding</loc>
|
||||||
|
<lastmod>2026-02-18</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://fotospiel.app/de/anlaesse/hochzeit" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://fotospiel.app/en/occasions/wedding" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://fotospiel.app/en/occasions/wedding" />
|
||||||
</url>
|
</url>
|
||||||
</urlset>
|
</urlset>
|
||||||
@@ -9,20 +9,25 @@ import { I18nextProvider } from 'react-i18next';
|
|||||||
import i18n from './i18n';
|
import i18n from './i18n';
|
||||||
import { Toaster } from 'react-hot-toast';
|
import { Toaster } from 'react-hot-toast';
|
||||||
import { ConsentProvider } from './contexts/consent';
|
import { ConsentProvider } from './contexts/consent';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||||
|
|
||||||
createInertiaApp({
|
createInertiaApp({
|
||||||
title: (title) => title ? `${title} - ${appName}` : appName,
|
title: (title) => title ? `${title} - ${appName}` : appName,
|
||||||
resolve: (name) => resolvePageComponent(
|
resolve: (name) =>
|
||||||
|
resolvePageComponent(
|
||||||
`./pages/${name}.tsx`,
|
`./pages/${name}.tsx`,
|
||||||
import.meta.glob('./pages/**/*.tsx')
|
import.meta.glob('./pages/**/*.tsx')
|
||||||
).then((page) => {
|
).then((page: any) => {
|
||||||
if (page) {
|
if (page?.default) {
|
||||||
const PageComponent = (page as any).default;
|
const Component = page.default;
|
||||||
return (props: any) => <AppLayout><PageComponent {...props} /></AppLayout>;
|
if (!Component.layout) {
|
||||||
|
Component.layout = (page: React.ReactNode) => <AppLayout>{page}</AppLayout>;
|
||||||
}
|
}
|
||||||
return null;
|
}
|
||||||
|
|
||||||
|
return page;
|
||||||
}),
|
}),
|
||||||
setup({ el, App, props }) {
|
setup({ el, App, props }) {
|
||||||
const root = createRoot(el);
|
const root = createRoot(el);
|
||||||
|
|||||||
@@ -1,30 +1,74 @@
|
|||||||
import { useLocale } from './useLocale';
|
import { usePage } from '@inertiajs/react';
|
||||||
|
import { useLocale } from './useLocale';
|
||||||
|
|
||||||
type LocalizedPathInput = string | null | undefined;
|
type LocalizedPathInput = string | null | undefined;
|
||||||
|
|
||||||
export const useLocalizedRoutes = () => {
|
export const useLocalizedRoutes = () => {
|
||||||
|
const page = usePage<{ supportedLocales?: string[] }>();
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
|
const supportedLocales = (page.props as any)?.supportedLocales ?? [];
|
||||||
|
|
||||||
const localizedPath = (path: LocalizedPathInput) => {
|
const fallbackLocale = (() => {
|
||||||
|
if (locale && supportedLocales.includes(locale)) {
|
||||||
|
return locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (supportedLocales.length > 0) {
|
||||||
|
return supportedLocales[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'de';
|
||||||
|
})();
|
||||||
|
|
||||||
|
const pathRewrites: Record<string, Record<string, string>> = {
|
||||||
|
'/kontakt': { en: '/contact' },
|
||||||
|
'/contact': { de: '/kontakt' },
|
||||||
|
'/so-funktionierts': { en: '/how-it-works' },
|
||||||
|
'/how-it-works': { de: '/so-funktionierts' },
|
||||||
|
'/anlaesse': { en: '/occasions' },
|
||||||
|
'/anlaesse/hochzeit': { en: '/occasions/wedding' },
|
||||||
|
'/anlaesse/geburtstag': { en: '/occasions/birthday' },
|
||||||
|
'/anlaesse/firmenevent': { en: '/occasions/corporate-event' },
|
||||||
|
'/anlaesse/konfirmation': { en: '/occasions/confirmation' },
|
||||||
|
'/occasions/wedding': { de: '/anlaesse/hochzeit' },
|
||||||
|
'/occasions/birthday': { de: '/anlaesse/geburtstag' },
|
||||||
|
'/occasions/corporate-event': { de: '/anlaesse/firmenevent' },
|
||||||
|
'/occasions/confirmation': { de: '/anlaesse/konfirmation' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const rewriteForLocale = (path: string, targetLocale: string): string => {
|
||||||
|
const key = path === '' ? '/' : path;
|
||||||
|
const normalizedKey = key.startsWith('/') ? key : `/${key}`;
|
||||||
|
const rewrites = pathRewrites[normalizedKey] ?? {};
|
||||||
|
|
||||||
|
return rewrites[targetLocale] ?? normalizedKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
const localizedPath = (path: LocalizedPathInput, targetLocale?: string) => {
|
||||||
if (typeof path !== 'string' || path.trim().length === 0) {
|
if (typeof path !== 'string' || path.trim().length === 0) {
|
||||||
// Diagnose cases where components pass falsy / non-string hrefs (e.g. legacy localized routes, pagination links)
|
|
||||||
// This log allows us to correlate console errors from Inertia with offending components.
|
|
||||||
console.error('[useLocalizedRoutes] Invalid path input detected', {
|
console.error('[useLocalizedRoutes] Invalid path input detected', {
|
||||||
path,
|
path,
|
||||||
locale,
|
locale,
|
||||||
stack: new Error().stack,
|
stack: new Error().stack,
|
||||||
});
|
});
|
||||||
|
|
||||||
return '/';
|
return `/${fallbackLocale}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextLocale = targetLocale && supportedLocales.includes(targetLocale)
|
||||||
|
? targetLocale
|
||||||
|
: fallbackLocale;
|
||||||
|
|
||||||
const trimmed = path.trim();
|
const trimmed = path.trim();
|
||||||
const normalized = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
const [rawPath, rawQuery] = trimmed.split('?');
|
||||||
|
const normalizedPath = rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
|
||||||
|
const rewritten = rewriteForLocale(normalizedPath, nextLocale);
|
||||||
|
|
||||||
// console.debug('[useLocalizedRoutes] Resolved path', { input: path, normalized, locale });
|
const base = rewritten === '/' ? `/${nextLocale}` : `/${nextLocale}${rewritten}`;
|
||||||
|
const sanitisedBase = base.replace(/\/{2,}/g, '/');
|
||||||
|
const query = rawQuery ? `?${rawQuery}` : '';
|
||||||
|
|
||||||
// Since prefix-free, return plain path. Locale is handled via session.
|
return `${sanitisedBase}${query}`;
|
||||||
return normalized;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return { localizedPath };
|
return { localizedPath };
|
||||||
|
|||||||
@@ -1,44 +1,72 @@
|
|||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Link } from '@inertiajs/react';
|
import { Link } from '@inertiajs/react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useConsent } from '@/contexts/consent';
|
import { useConsent } from '@/contexts/consent';
|
||||||
|
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
|
||||||
|
|
||||||
const Footer: React.FC = () => {
|
const Footer: React.FC = () => {
|
||||||
|
|
||||||
const { t } = useTranslation(['marketing', 'legal', 'common']);
|
const { t } = useTranslation(['marketing', 'legal', 'common']);
|
||||||
const { openPreferences } = useConsent();
|
const { openPreferences } = useConsent();
|
||||||
|
const { localizedPath } = useLocalizedRoutes();
|
||||||
|
|
||||||
|
const links = useMemo(() => ({
|
||||||
|
home: localizedPath('/'),
|
||||||
|
impressum: localizedPath('/impressum'),
|
||||||
|
datenschutz: localizedPath('/datenschutz'),
|
||||||
|
agb: localizedPath('/agb'),
|
||||||
|
kontakt: localizedPath('/kontakt'),
|
||||||
|
}), [localizedPath]);
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="bg-white border-t border-gray-200 mt-auto">
|
<footer className="mt-auto border-t border-gray-200 bg-white">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<img src="/logo-transparent-md.png" alt="FotoSpiel.App Logo" className="h-12 w-auto" />
|
<img src="/logo-transparent-md.png" alt="FotoSpiel.App Logo" className="h-12 w-auto" />
|
||||||
<div>
|
<div>
|
||||||
<Link href="/" className="text-2xl font-bold font-display text-pink-500">
|
<Link href={links.home} className="font-display text-2xl font-bold text-pink-500">
|
||||||
Die FotoSpiel.App
|
Die FotoSpiel.App
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-gray-600 font-sans-marketing mt-2">
|
<p className="mt-2 font-sans-marketing text-gray-600">
|
||||||
Deine Plattform für Event-Fotos.
|
{t('marketing:footer.company', 'Fotospiel GmbH')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold font-display text-gray-900 mb-4">Rechtliches</h3>
|
<h3 className="font-display mb-4 font-semibold text-gray-900">
|
||||||
<ul className="space-y-2 text-sm text-gray-600 font-sans-marketing">
|
{t('legal:headline', 'Rechtliches')}
|
||||||
<li><Link href="/impressum" className="hover:text-pink-500 transition-colors">{t('legal:impressum')}</Link></li>
|
</h3>
|
||||||
<li><Link href="/datenschutz" className="hover:text-pink-500 transition-colors">{t('legal:datenschutz')}</Link></li>
|
<ul className="font-sans-marketing space-y-2 text-sm text-gray-600">
|
||||||
<li><Link href="/agb" className="hover:text-pink-500 transition-colors">{t('legal:agb')}</Link></li>
|
<li>
|
||||||
<li><Link href="/kontakt" className="hover:text-pink-500 transition-colors">{t('marketing:nav.contact')}</Link></li>
|
<Link href={links.impressum} className="transition-colors hover:text-pink-500">
|
||||||
|
{t('legal:impressum')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={links.datenschutz} className="transition-colors hover:text-pink-500">
|
||||||
|
{t('legal:datenschutz')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={links.agb} className="transition-colors hover:text-pink-500">
|
||||||
|
{t('legal:agb')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={links.kontakt} className="transition-colors hover:text-pink-500">
|
||||||
|
{t('marketing:nav.contact')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openPreferences}
|
onClick={openPreferences}
|
||||||
className="hover:text-pink-500 transition-colors"
|
className="transition-colors hover:text-pink-500"
|
||||||
>
|
>
|
||||||
{t('common:consent.footer.manage_link')}
|
{t('common:consent.footer.manage_link')}
|
||||||
</button>
|
</button>
|
||||||
@@ -47,8 +75,10 @@ const Footer: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold font-display text-gray-900 mb-4">Social</h3>
|
<h3 className="font-display mb-4 font-semibold text-gray-900">
|
||||||
<ul className="space-y-2 text-sm text-gray-600 font-sans-marketing">
|
{t('marketing:footer.social', 'Social')}
|
||||||
|
</h3>
|
||||||
|
<ul className="font-sans-marketing space-y-2 text-sm text-gray-600">
|
||||||
<li><a href="#" className="hover:text-pink-500">Instagram</a></li>
|
<li><a href="#" className="hover:text-pink-500">Instagram</a></li>
|
||||||
<li><a href="#" className="hover:text-pink-500">Facebook</a></li>
|
<li><a href="#" className="hover:text-pink-500">Facebook</a></li>
|
||||||
<li><a href="#" className="hover:text-pink-500">YouTube</a></li>
|
<li><a href="#" className="hover:text-pink-500">YouTube</a></li>
|
||||||
@@ -56,8 +86,8 @@ const Footer: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-gray-200 mt-8 pt-8 text-center text-sm text-gray-500 font-sans-marketing">
|
<div className="font-sans-marketing mt-8 border-t border-gray-200 pt-8 text-center text-sm text-gray-500">
|
||||||
© 2025 Die FotoSpiel.App - Alle Rechte vorbehalten.
|
© {currentYear} Die FotoSpiel.App – {t('marketing:footer.rights_reserved', 'Alle Rechte vorbehalten')}.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { Head, usePage, router } from '@inertiajs/react';
|
import { Head, Link, router, usePage } from '@inertiajs/react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import MatomoTracker, { MatomoConfig } from '@/components/analytics/MatomoTracker';
|
import MatomoTracker, { MatomoConfig } from '@/components/analytics/MatomoTracker';
|
||||||
import CookieBanner from '@/components/consent/CookieBanner';
|
import CookieBanner from '@/components/consent/CookieBanner';
|
||||||
|
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
|
||||||
|
import Footer from '@/layouts/app/Footer';
|
||||||
|
import { useAppearance } from '@/hooks/use-appearance';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||||
|
import { MoreHorizontal, Sun, Moon, Languages, LayoutDashboard, LogOut, LogIn, UserPlus } from 'lucide-react';
|
||||||
|
|
||||||
interface MarketingLayoutProps {
|
interface MarketingLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -14,11 +20,76 @@ const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) =>
|
|||||||
translations?: Record<string, Record<string, string>>;
|
translations?: Record<string, Record<string, string>>;
|
||||||
locale?: string;
|
locale?: string;
|
||||||
analytics?: { matomo?: MatomoConfig };
|
analytics?: { matomo?: MatomoConfig };
|
||||||
|
supportedLocales?: string[];
|
||||||
|
appUrl?: string;
|
||||||
}>();
|
}>();
|
||||||
const { url } = page;
|
const { url } = page;
|
||||||
const { t } = useTranslation('marketing');
|
const { t } = useTranslation('marketing');
|
||||||
const i18n = useTranslation();
|
const i18n = useTranslation();
|
||||||
const { locale, analytics } = page.props;
|
const { locale, analytics, supportedLocales = ['de', 'en'], appUrl, auth } = page.props as any;
|
||||||
|
const user = auth?.user ?? null;
|
||||||
|
const { localizedPath } = useLocalizedRoutes();
|
||||||
|
const { appearance, updateAppearance } = useAppearance();
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
const occasionLinks = useMemo(() => ([
|
||||||
|
{
|
||||||
|
key: 'wedding',
|
||||||
|
label: t('nav.occasions_types.weddings', 'Hochzeiten'),
|
||||||
|
href: localizedPath('/anlaesse/hochzeit'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'birthday',
|
||||||
|
label: t('nav.occasions_types.birthdays', 'Geburtstage'),
|
||||||
|
href: localizedPath('/anlaesse/geburtstag'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'corporate',
|
||||||
|
label: t('nav.occasions_types.corporate', 'Firmenevents'),
|
||||||
|
href: localizedPath('/anlaesse/firmenevent'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'confirmation',
|
||||||
|
label: t('nav.occasions_types.confirmation', 'Konfirmation'),
|
||||||
|
href: localizedPath('/anlaesse/konfirmation'),
|
||||||
|
},
|
||||||
|
]), [localizedPath, t]);
|
||||||
|
|
||||||
|
const navLinks = useMemo(() => ([
|
||||||
|
{
|
||||||
|
key: 'how',
|
||||||
|
label: t('nav.how_it_works', "So funktioniert's"),
|
||||||
|
href: localizedPath('/so-funktionierts'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'packages',
|
||||||
|
label: t('nav.packages', 'Pakete'),
|
||||||
|
href: localizedPath('/packages'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'occasions',
|
||||||
|
label: t('nav.occasions', 'Anlässe'),
|
||||||
|
children: occasionLinks,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'blog',
|
||||||
|
label: t('nav.blog', 'Blog'),
|
||||||
|
href: localizedPath('/blog'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'contact',
|
||||||
|
label: t('nav.contact', 'Kontakt'),
|
||||||
|
href: localizedPath('/kontakt'),
|
||||||
|
},
|
||||||
|
]), [localizedPath, occasionLinks, t]);
|
||||||
|
|
||||||
|
const ctaHref = localizedPath('/demo');
|
||||||
|
const themeIsDark = appearance === 'dark';
|
||||||
|
const themeLabel = themeIsDark ? t('nav.theme_light', 'Helles Design') : t('nav.theme_dark', 'Dunkles Design');
|
||||||
|
const toggleTheme = () => updateAppearance(themeIsDark ? 'light' : 'dark');
|
||||||
|
const handleLogout = () => {
|
||||||
|
router.post('/logout');
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (locale && i18n.i18n.language !== locale) {
|
if (locale && i18n.i18n.language !== locale) {
|
||||||
@@ -33,18 +104,37 @@ const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) =>
|
|||||||
return typeof value === 'string' ? value : fallback;
|
return typeof value === 'string' ? value : fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeLocale = locale || 'de';
|
const activeLocale = locale || supportedLocales[0] || 'de';
|
||||||
const alternateLocale = activeLocale === 'de' ? 'en' : 'de';
|
const baseUrl = (typeof appUrl === 'string' && appUrl.length > 0)
|
||||||
const path = url.replace(/^\/(de|en)/, '');
|
? appUrl.replace(/\/+$/, '')
|
||||||
const canonicalUrl = `https://fotospiel.app${path || '/'}`;
|
: 'https://fotospiel.app';
|
||||||
|
|
||||||
|
const [rawPath, rawQuery = ''] = url.split('?');
|
||||||
|
const localePattern = supportedLocales.length > 0 ? supportedLocales.join('|') : 'de|en';
|
||||||
|
const localeRegex = new RegExp(`^/(${localePattern})(?=/|$)`, 'i');
|
||||||
|
const relativePath = rawPath.replace(localeRegex, '') || '/';
|
||||||
|
const canonicalPath = localizedPath(relativePath, activeLocale);
|
||||||
|
const canonicalUrl = `${baseUrl}${canonicalPath}${rawQuery ? `?${rawQuery}` : ''}`;
|
||||||
|
|
||||||
|
const buildAlternateUrl = (targetLocale: string) => {
|
||||||
|
const alternatePath = localizedPath(relativePath, targetLocale);
|
||||||
|
return `${baseUrl}${alternatePath}${rawQuery ? `?${rawQuery}` : ''}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const alternates = supportedLocales.reduce<Record<string, string>>((acc, currentLocale) => {
|
||||||
|
acc[currentLocale] = buildAlternateUrl(currentLocale);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
const handleLocaleChange = (nextLocale: string) => {
|
const handleLocaleChange = (nextLocale: string) => {
|
||||||
router.post('/set-locale', { locale: nextLocale }, {
|
const targetPath = localizedPath(relativePath, nextLocale);
|
||||||
preserveState: true,
|
const targetUrl = `${targetPath}${rawQuery ? `?${rawQuery}` : ''}`;
|
||||||
replace: true,
|
|
||||||
onSuccess: () => {
|
|
||||||
i18n.i18n.changeLanguage(nextLocale);
|
i18n.i18n.changeLanguage(nextLocale);
|
||||||
},
|
setMobileMenuOpen(false);
|
||||||
|
router.visit(targetUrl, {
|
||||||
|
replace: true,
|
||||||
|
preserveState: false,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,22 +152,261 @@ const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) =>
|
|||||||
content={t('meta.description', getString('description', 'Sammle Gastfotos für Events mit QR-Codes'))}
|
content={t('meta.description', getString('description', 'Sammle Gastfotos für Events mit QR-Codes'))}
|
||||||
/>
|
/>
|
||||||
<meta property="og:url" content={canonicalUrl} />
|
<meta property="og:url" content={canonicalUrl} />
|
||||||
|
<meta property="og:locale" content={activeLocale === 'de' ? 'de_DE' : `${activeLocale}_${activeLocale.toUpperCase()}`} />
|
||||||
|
{supportedLocales
|
||||||
|
.filter((code) => code !== activeLocale)
|
||||||
|
.map((code) => (
|
||||||
|
<meta key={`og:locale:${code}`} property="og:locale:alternate" content={code === 'de' ? 'de_DE' : `${code}_${code.toUpperCase()}`} />
|
||||||
|
))}
|
||||||
<link rel="canonical" href={canonicalUrl} />
|
<link rel="canonical" href={canonicalUrl} />
|
||||||
<link rel="alternate" hrefLang="x-default" href="https://fotospiel.app/" />
|
<link rel="alternate" hrefLang="x-default" href={buildAlternateUrl(supportedLocales[0] || 'de')} />
|
||||||
|
{Object.entries(alternates).map(([code, href]) => (
|
||||||
|
<link key={code} rel="alternate" hrefLang={code} href={href} />
|
||||||
|
))}
|
||||||
</Head>
|
</Head>
|
||||||
<MatomoTracker config={analytics?.matomo} />
|
<MatomoTracker config={analytics?.matomo} />
|
||||||
<CookieBanner />
|
<CookieBanner />
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-white">
|
||||||
<header className="bg-white shadow-sm">
|
<header className="sticky top-0 z-40 border-b border-gray-200/60 bg-white/95 backdrop-blur">
|
||||||
<div className="container mx-auto px-4 py-4">
|
<div className="container mx-auto flex items-center justify-between px-4 py-4">
|
||||||
|
<Link
|
||||||
|
href={localizedPath('/')}
|
||||||
|
className="flex items-center gap-3 text-gray-900"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
<img src="/logo-transparent-md.png" alt="FotoSpiel.App Logo" className="h-10 w-auto" />
|
||||||
|
<span className="font-display text-2xl font-semibold tracking-tight text-pink-500 sm:text-3xl">
|
||||||
|
Die FotoSpiel.App
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<nav className="hidden items-center gap-6 md:flex">
|
||||||
|
{navLinks.map((item) => (
|
||||||
|
item.children ? (
|
||||||
|
<div key={item.key} className="relative group">
|
||||||
|
<span className="inline-flex cursor-default items-center gap-1 text-sm font-semibold text-gray-700 transition-colors group-hover:text-pink-600 font-sans-marketing">
|
||||||
|
{item.label}
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4 text-gray-400 transition group-hover:text-pink-500"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M6 8L10 12L14 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div className="absolute left-0 top-full hidden min-w-[220px] flex-col gap-1 rounded-xl border border-gray-100 bg-white p-3 text-sm shadow-xl shadow-rose-200/50 transition group-hover:flex group-focus-within:flex">
|
||||||
|
{item.children.map((child) => (
|
||||||
|
<Link
|
||||||
|
key={child.key}
|
||||||
|
href={child.href}
|
||||||
|
className="rounded-lg px-3 py-2 font-medium text-gray-600 transition hover:bg-rose-50 hover:text-pink-600 font-sans-marketing"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
{child.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
key={item.key}
|
||||||
|
href={item.href}
|
||||||
|
className="text-sm font-semibold text-gray-700 transition hover:text-pink-600 font-sans-marketing"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
className="hidden rounded-full bg-pink-500 px-5 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-pink-600 font-sans-marketing md:inline-flex"
|
||||||
|
>
|
||||||
|
<Link href={ctaHref}>
|
||||||
|
{t('nav.cta_demo', 'Jetzt ausprobieren')}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="rounded-full border-gray-200 text-gray-600 transition hover:border-pink-200 hover:text-pink-500"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
<span className="sr-only">{t('nav.preferences', 'Einstellungen')}</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-56 space-y-1 p-2">
|
||||||
|
<DropdownMenuLabel className="font-sans-marketing text-xs uppercase tracking-wide text-gray-400">
|
||||||
|
{t('nav.preferences', 'Einstellungen')}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleTheme();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 font-sans-marketing"
|
||||||
|
>
|
||||||
|
{themeIsDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||||
|
<span>{themeLabel}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuLabel className="font-sans-marketing text-xs uppercase tracking-wide text-gray-400">
|
||||||
|
{t('nav.language', 'Sprache')}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuRadioGroup value={activeLocale} onValueChange={handleLocaleChange}>
|
||||||
|
{supportedLocales.map((code) => (
|
||||||
|
<DropdownMenuRadioItem
|
||||||
|
key={code}
|
||||||
|
value={code}
|
||||||
|
className="flex items-center gap-2 font-sans-marketing"
|
||||||
|
>
|
||||||
|
<Languages className="h-4 w-4" />
|
||||||
|
<span>{code.toUpperCase()}</span>
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{user ? (
|
||||||
|
<>
|
||||||
|
<DropdownMenuLabel className="font-sans-marketing text-xs uppercase tracking-wide text-gray-400">
|
||||||
|
{user.name ?? user.email}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
router.visit('/event-admin');
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 font-sans-marketing"
|
||||||
|
>
|
||||||
|
<LayoutDashboard className="h-4 w-4" />
|
||||||
|
<span>{t('nav.dashboard', 'Zum Admin-Bereich')}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
handleLogout();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 font-sans-marketing"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
<span>{t('nav.logout', 'Abmelden')}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
router.visit(localizedPath('/login'));
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 font-sans-marketing"
|
||||||
|
>
|
||||||
|
<LogIn className="h-4 w-4" />
|
||||||
|
<span>{t('nav.login', 'Anmelden')}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
router.visit(localizedPath('/register'));
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 font-sans-marketing"
|
||||||
|
>
|
||||||
|
<UserPlus className="h-4 w-4" />
|
||||||
|
<span>{t('nav.register', 'Registrieren')}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-gray-200 bg-white text-gray-700 shadow-sm md:hidden"
|
||||||
|
onClick={() => setMobileMenuOpen((open) => !open)}
|
||||||
|
aria-label={mobileMenuOpen ? t('nav.close_menu', 'Menü schließen') : t('nav.open_menu', 'Menü öffnen')}
|
||||||
|
>
|
||||||
|
{mobileMenuOpen ? (
|
||||||
|
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||||
|
<path d="M5 5L15 15M15 5L5 15" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||||
|
<path d="M3 6H17M3 10H17M3 14H17" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{mobileMenuOpen && (
|
||||||
|
<div className="border-t border-gray-200 bg-white md:hidden">
|
||||||
|
<div className="container mx-auto space-y-4 px-4 py-4">
|
||||||
|
{navLinks.map((item) => (
|
||||||
|
item.children ? (
|
||||||
|
<div key={item.key} className="space-y-2">
|
||||||
|
<p className="text-sm font-semibold text-gray-500">{item.label}</p>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{item.children.map((child) => (
|
||||||
|
<Link
|
||||||
|
key={child.key}
|
||||||
|
href={child.href}
|
||||||
|
className="rounded-lg border border-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-pink-200 hover:bg-rose-50 hover:text-pink-600"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
{child.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
key={item.key}
|
||||||
|
href={item.href}
|
||||||
|
className="block rounded-lg border border-gray-100 px-3 py-2 text-sm font-semibold text-gray-700 transition hover:border-pink-200 hover:bg-rose-50 hover:text-pink-600 font-sans-marketing"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
<div className="pt-2">
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
className="w-full rounded-full bg-pink-500 px-5 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-pink-600 font-sans-marketing"
|
||||||
|
>
|
||||||
|
<Link href={ctaHref} onClick={() => setMobileMenuOpen(false)}>
|
||||||
|
{t('nav.cta_demo', 'Jetzt ausprobieren')}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="pt-2">
|
||||||
|
<label htmlFor="marketing-language-select-mobile" className="sr-only">
|
||||||
|
{t('nav.language', 'Sprache')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="marketing-language-select-mobile"
|
||||||
|
value={activeLocale}
|
||||||
|
onChange={(event) => handleLocaleChange(event.target.value)}
|
||||||
|
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm focus:border-pink-400 focus:outline-none focus:ring focus:ring-pink-200"
|
||||||
|
>
|
||||||
|
{supportedLocales.map((code) => (
|
||||||
|
<option key={code} value={code}>
|
||||||
|
{code.toUpperCase()}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
{/* Footer kommt von Footer.tsx */}
|
<Footer />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ type LegalShowProps = {
|
|||||||
slug: string;
|
slug: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LegalShow(props: LegalShowProps) {
|
const LegalShow: React.FC<LegalShowProps> = (props) => {
|
||||||
const { seoTitle, title, content, effectiveFromLabel, versionLabel } = props;
|
const { seoTitle, title, content, effectiveFromLabel, versionLabel } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -41,4 +41,8 @@ export default function LegalShow(props: LegalShowProps) {
|
|||||||
</section>
|
</section>
|
||||||
</MarketingLayout>
|
</MarketingLayout>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
LegalShow.layout = (page: React.ReactNode) => page;
|
||||||
|
|
||||||
|
export default LegalShow;
|
||||||
|
|||||||
@@ -184,4 +184,6 @@ const Blog: React.FC<Props> = ({ posts }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Blog.layout = (page: React.ReactNode) => page;
|
||||||
|
|
||||||
export default Blog;
|
export default Blog;
|
||||||
@@ -111,4 +111,6 @@ const BlogShow: React.FC<Props> = ({ post }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
BlogShow.layout = (page: React.ReactNode) => page;
|
||||||
|
|
||||||
export default BlogShow;
|
export default BlogShow;
|
||||||
@@ -21,13 +21,13 @@ interface CheckoutWizardPageProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CheckoutWizardPage({
|
const CheckoutWizardPage: React.FC<CheckoutWizardPageProps> = ({
|
||||||
package: initialPackage,
|
package: initialPackage,
|
||||||
packageOptions,
|
packageOptions,
|
||||||
privacyHtml,
|
privacyHtml,
|
||||||
googleAuth,
|
googleAuth,
|
||||||
paddle,
|
paddle,
|
||||||
}: CheckoutWizardPageProps) {
|
}) => {
|
||||||
const page = usePage<{ auth?: { user?: { id: number; email: string; name?: string; pending_purchase?: boolean } | null } }>();
|
const page = usePage<{ auth?: { user?: { id: number; email: string; name?: string; pending_purchase?: boolean } | null } }>();
|
||||||
const currentUser = page.props.auth?.user ?? null;
|
const currentUser = page.props.auth?.user ?? null;
|
||||||
const googleProfile = googleAuth?.profile ?? null;
|
const googleProfile = googleAuth?.profile ?? null;
|
||||||
@@ -75,4 +75,8 @@ export default function CheckoutWizardPage({
|
|||||||
</div>
|
</div>
|
||||||
</MarketingLayout>
|
</MarketingLayout>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
CheckoutWizardPage.layout = (page: React.ReactNode) => page;
|
||||||
|
|
||||||
|
export default CheckoutWizardPage;
|
||||||
|
|||||||
@@ -122,4 +122,6 @@ const DemoPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
DemoPage.layout = (page: React.ReactNode) => page;
|
||||||
|
|
||||||
export default DemoPage;
|
export default DemoPage;
|
||||||
|
|||||||
@@ -576,4 +576,6 @@ const Home: React.FC<Props> = ({ packages }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Home.layout = (page: React.ReactNode) => page;
|
||||||
|
|
||||||
export default Home;
|
export default Home;
|
||||||
|
|||||||
@@ -420,4 +420,6 @@ const ExperiencePanel: React.FC<{ data: ExperienceGroup }> = ({ data }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
HowItWorks.layout = (page: React.ReactNode) => page;
|
||||||
|
|
||||||
export default HowItWorks;
|
export default HowItWorks;
|
||||||
|
|||||||
@@ -95,4 +95,6 @@ const Kontakt: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Kontakt.layout = (page: React.ReactNode) => page;
|
||||||
|
|
||||||
export default Kontakt;
|
export default Kontakt;
|
||||||
92
resources/js/pages/marketing/NotFound.tsx
Normal file
92
resources/js/pages/marketing/NotFound.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
interface NotFoundProps {
|
||||||
|
requestedPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NotFound: React.FC<NotFoundProps> = ({ requestedPath }) => {
|
||||||
|
const { t } = useTranslation('marketing');
|
||||||
|
const { localizedPath } = useLocalizedRoutes();
|
||||||
|
const tips = t('not_found.tips', { returnObjects: true }) as string[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MarketingLayout title={t('not_found.title')}>
|
||||||
|
<Head title={t('not_found.title')} />
|
||||||
|
<section className="relative min-h-screen overflow-hidden bg-gradient-to-br from-slate-900 via-gray-900 to-black text-white">
|
||||||
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
|
<div className="absolute -left-32 -top-32 h-96 w-96 rounded-full bg-pink-500/30 blur-3xl" />
|
||||||
|
<div className="absolute right-0 top-1/3 h-80 w-80 rounded-full bg-purple-500/20 blur-3xl" />
|
||||||
|
<div className="absolute bottom-0 left-1/2 h-72 w-72 -translate-x-1/2 rounded-full bg-blue-500/20 blur-3xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 mx-auto flex min-h-screen max-w-5xl flex-col items-center justify-center px-6 py-16 text-center">
|
||||||
|
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm font-semibold uppercase tracking-widest text-pink-200 shadow-lg shadow-pink-500/30 backdrop-blur">
|
||||||
|
404 · {t('not_found.title')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-balance text-4xl font-bold leading-tight text-white sm:text-5xl md:text-6xl">
|
||||||
|
{t('not_found.subtitle')}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{requestedPath && (
|
||||||
|
<p className="mt-4 text-sm text-white/60">
|
||||||
|
{t('not_found.requested_path_label', 'Angefragter Pfad')}: <span className="font-mono">{requestedPath}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mt-6 max-w-2xl text-lg text-white/70 sm:text-xl">
|
||||||
|
{t('not_found.description')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-10 flex flex-col gap-6 rounded-3xl border border-white/10 bg-white/5 p-8 text-left shadow-lg shadow-black/40 backdrop-blur md:flex-row md:gap-8">
|
||||||
|
<div className="md:w-1/3">
|
||||||
|
<h2 className="text-lg font-semibold uppercase tracking-widest text-pink-200">
|
||||||
|
{t('not_found.tip_heading')}
|
||||||
|
</h2>
|
||||||
|
<div className="mt-4 h-1 w-16 rounded-full bg-pink-400" />
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-3 text-base text-white/80 md:w-2/3">
|
||||||
|
{tips.map((tip, index) => (
|
||||||
|
<li key={`${tip}-${index}`} className="flex items-start gap-3">
|
||||||
|
<span className="mt-1 flex h-6 w-6 flex-none items-center justify-center rounded-full bg-white/10 text-xs font-semibold text-pink-200">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<span>{tip}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 flex flex-col gap-3 sm:flex-row">
|
||||||
|
<Link
|
||||||
|
href={localizedPath('/')}
|
||||||
|
className="inline-flex items-center justify-center rounded-full bg-white px-6 py-3 font-medium text-gray-900 shadow-lg shadow-pink-500/30 transition hover:-translate-y-1 hover:bg-pink-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
|
||||||
|
>
|
||||||
|
{t('not_found.cta_home')}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={localizedPath('/packages')}
|
||||||
|
className="inline-flex items-center justify-center rounded-full border border-white/40 px-6 py-3 font-medium text-white transition hover:-translate-y-1 hover:border-white hover:bg-white/10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
|
||||||
|
>
|
||||||
|
{t('not_found.cta_packages')}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={localizedPath('/kontakt')}
|
||||||
|
className="inline-flex items-center justify-center rounded-full border border-transparent bg-gradient-to-r from-pink-500 to-purple-500 px-6 py-3 font-medium text-white shadow-lg shadow-pink-500/30 transition hover:-translate-y-1 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
|
||||||
|
>
|
||||||
|
{t('not_found.cta_contact')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</MarketingLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
NotFound.layout = (page: React.ReactNode) => page;
|
||||||
|
|
||||||
|
export default NotFound;
|
||||||
@@ -59,7 +59,7 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const content = occasionsContent[type as keyof typeof occasionsContent] || occasionsContent.hochzeit;
|
const content = occasionsContent[type as keyof typeof occasionsContent] || occasionsContent.hochzeit;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MarketingLayout title={content.title}>
|
<MarketingLayout title={content.title}>
|
||||||
@@ -89,4 +89,6 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Occasions.layout = (page: React.ReactNode) => page;
|
||||||
|
|
||||||
export default Occasions;
|
export default Occasions;
|
||||||
|
|||||||
@@ -1026,4 +1026,6 @@ function PackageCard({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Packages.layout = (page: React.ReactNode) => page;
|
||||||
|
|
||||||
export default Packages;
|
export default Packages;
|
||||||
|
|||||||
@@ -80,4 +80,6 @@ const Success: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Success.layout = (page: React.ReactNode) => page;
|
||||||
|
|
||||||
export default Success;
|
export default Success;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ return [
|
|||||||
'data_security' => 'Datensicherheit',
|
'data_security' => 'Datensicherheit',
|
||||||
'data_security_desc' => 'Wir verwenden HTTPS, verschlüsselte Speicherung (Passwörter hashed) und regelmäßige Backups. Zugriff auf Daten ist rollebasierend beschränkt (Tenant vs SuperAdmin).',
|
'data_security_desc' => 'Wir verwenden HTTPS, verschlüsselte Speicherung (Passwörter hashed) und regelmäßige Backups. Zugriff auf Daten ist rollebasierend beschränkt (Tenant vs SuperAdmin).',
|
||||||
'agb' => 'Allgemeine Geschäftsbedingungen',
|
'agb' => 'Allgemeine Geschäftsbedingungen',
|
||||||
|
'headline' => 'Rechtliches',
|
||||||
'effective_from' => 'Gültig seit :date',
|
'effective_from' => 'Gültig seit :date',
|
||||||
'version' => 'Version :version',
|
'version' => 'Version :version',
|
||||||
'and' => 'und',
|
'and' => 'und',
|
||||||
|
|||||||
@@ -76,17 +76,35 @@ return [
|
|||||||
'weddings' => 'Hochzeiten',
|
'weddings' => 'Hochzeiten',
|
||||||
'birthdays' => 'Geburtstage',
|
'birthdays' => 'Geburtstage',
|
||||||
'corporate' => 'Firmenevents',
|
'corporate' => 'Firmenevents',
|
||||||
|
'confirmation' => 'Konfirmation & Jugendweihe',
|
||||||
'family' => 'Familienfeiern',
|
'family' => 'Familienfeiern',
|
||||||
],
|
],
|
||||||
'blog' => 'Blog',
|
'blog' => 'Blog',
|
||||||
'packages' => 'Packages',
|
'packages' => 'Packages',
|
||||||
'contact' => 'Kontakt',
|
'contact' => 'Kontakt',
|
||||||
'discover_packages' => 'Packages entdecken',
|
'discover_packages' => 'Packages entdecken',
|
||||||
|
'language' => 'Sprache',
|
||||||
|
'open_menu' => 'Menü öffnen',
|
||||||
|
'close_menu' => 'Menü schließen',
|
||||||
|
'cta_demo' => 'Jetzt ausprobieren',
|
||||||
|
'preferences' => 'Einstellungen',
|
||||||
|
'toggle_theme' => 'Darstellung wechseln',
|
||||||
|
'theme_light' => 'Helles Design',
|
||||||
|
'theme_dark' => 'Dunkles Design',
|
||||||
|
'dashboard' => 'Zum Admin-Bereich',
|
||||||
|
'logout' => 'Abmelden',
|
||||||
|
'login' => 'Anmelden',
|
||||||
|
'register' => 'Registrieren',
|
||||||
],
|
],
|
||||||
'footer' => [
|
'footer' => [
|
||||||
'company' => 'Fotospiel GmbH',
|
'company' => 'Fotospiel GmbH',
|
||||||
'rights_reserved' => 'Alle Rechte vorbehalten',
|
'rights_reserved' => 'Alle Rechte vorbehalten',
|
||||||
],
|
],
|
||||||
|
'legal' => [
|
||||||
|
'imprint' => 'Impressum',
|
||||||
|
'privacy' => 'Datenschutz',
|
||||||
|
'terms' => 'AGB',
|
||||||
|
],
|
||||||
'blog' => [
|
'blog' => [
|
||||||
'title' => 'Fotospiel - Blog',
|
'title' => 'Fotospiel - Blog',
|
||||||
'hero_title' => 'Fotospiel Blog',
|
'hero_title' => 'Fotospiel Blog',
|
||||||
@@ -147,6 +165,21 @@ return [
|
|||||||
],
|
],
|
||||||
'not_found' => 'Anlass nicht gefunden.',
|
'not_found' => 'Anlass nicht gefunden.',
|
||||||
],
|
],
|
||||||
|
'not_found' => [
|
||||||
|
'title' => 'Seite nicht gefunden',
|
||||||
|
'subtitle' => 'Ups! Diese Seite existiert nicht mehr.',
|
||||||
|
'description' => 'Vielleicht wurde der Link verschoben oder der Inhalt existiert nicht mehr. Hier sind ein paar Optionen, wie du weitermachen kannst.',
|
||||||
|
'tip_heading' => 'Was du tun kannst',
|
||||||
|
'tips' => [
|
||||||
|
'Prüfe die URL auf mögliche Tippfehler.',
|
||||||
|
'Gehe zurück zur Startseite und entdecke unsere Funktionen.',
|
||||||
|
'Kontaktiere uns, wenn du etwas Bestimmtes suchst.',
|
||||||
|
],
|
||||||
|
'cta_home' => 'Zur Startseite',
|
||||||
|
'cta_packages' => 'Pakete entdecken',
|
||||||
|
'cta_contact' => 'Kontakt aufnehmen',
|
||||||
|
'requested_path_label' => 'Angefragter Pfad',
|
||||||
|
],
|
||||||
'success' => [
|
'success' => [
|
||||||
'title' => 'Erfolgreich',
|
'title' => 'Erfolgreich',
|
||||||
'verify_email' => 'E-Mail verifizieren',
|
'verify_email' => 'E-Mail verifizieren',
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ return [
|
|||||||
'data_security' => 'Data Security',
|
'data_security' => 'Data Security',
|
||||||
'data_security_desc' => 'We use HTTPS, encrypted storage (passwords hashed) and regular backups. Access to data is role-based restricted (Tenant vs SuperAdmin).',
|
'data_security_desc' => 'We use HTTPS, encrypted storage (passwords hashed) and regular backups. Access to data is role-based restricted (Tenant vs SuperAdmin).',
|
||||||
'agb' => 'Terms & Conditions',
|
'agb' => 'Terms & Conditions',
|
||||||
|
'headline' => 'Legal',
|
||||||
'effective_from' => 'Effective from :date',
|
'effective_from' => 'Effective from :date',
|
||||||
'version' => 'Version :version',
|
'version' => 'Version :version',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -76,17 +76,35 @@ return [
|
|||||||
'weddings' => 'Weddings',
|
'weddings' => 'Weddings',
|
||||||
'birthdays' => 'Birthdays',
|
'birthdays' => 'Birthdays',
|
||||||
'corporate' => 'Corporate Events',
|
'corporate' => 'Corporate Events',
|
||||||
|
'confirmation' => 'Confirmations',
|
||||||
'family' => 'Family Celebrations',
|
'family' => 'Family Celebrations',
|
||||||
],
|
],
|
||||||
'blog' => 'Blog',
|
'blog' => 'Blog',
|
||||||
'packages' => 'Packages',
|
'packages' => 'Packages',
|
||||||
'contact' => 'Contact',
|
'contact' => 'Contact',
|
||||||
'discover_packages' => 'Discover Packages',
|
'discover_packages' => 'Discover Packages',
|
||||||
|
'language' => 'Language',
|
||||||
|
'open_menu' => 'Open menu',
|
||||||
|
'close_menu' => 'Close menu',
|
||||||
|
'cta_demo' => 'Try it now',
|
||||||
|
'preferences' => 'Preferences',
|
||||||
|
'toggle_theme' => 'Toggle appearance',
|
||||||
|
'theme_light' => 'Light mode',
|
||||||
|
'theme_dark' => 'Dark mode',
|
||||||
|
'dashboard' => 'Go to Admin',
|
||||||
|
'logout' => 'Sign out',
|
||||||
|
'login' => 'Log in',
|
||||||
|
'register' => 'Register',
|
||||||
],
|
],
|
||||||
'footer' => [
|
'footer' => [
|
||||||
'company' => 'Fotospiel GmbH',
|
'company' => 'Fotospiel GmbH',
|
||||||
'rights_reserved' => 'All rights reserved',
|
'rights_reserved' => 'All rights reserved',
|
||||||
],
|
],
|
||||||
|
'legal' => [
|
||||||
|
'imprint' => 'Imprint',
|
||||||
|
'privacy' => 'Privacy',
|
||||||
|
'terms' => 'Terms & Conditions',
|
||||||
|
],
|
||||||
'blog' => [
|
'blog' => [
|
||||||
'title' => 'Fotospiel - Blog',
|
'title' => 'Fotospiel - Blog',
|
||||||
'hero_title' => 'Fotospiel Blog',
|
'hero_title' => 'Fotospiel Blog',
|
||||||
@@ -147,6 +165,21 @@ return [
|
|||||||
],
|
],
|
||||||
'not_found' => 'Occasion not found.',
|
'not_found' => 'Occasion not found.',
|
||||||
],
|
],
|
||||||
|
'not_found' => [
|
||||||
|
'title' => 'Page not found',
|
||||||
|
'subtitle' => 'Oops! This page is nowhere to be found.',
|
||||||
|
'description' => 'It may have moved or never existed. Try one of the options below to get back on track.',
|
||||||
|
'tip_heading' => 'What you can do',
|
||||||
|
'tips' => [
|
||||||
|
'Double-check the URL for typos.',
|
||||||
|
'Head back to the homepage to continue exploring.',
|
||||||
|
'Reach out to us if you need a specific page.',
|
||||||
|
],
|
||||||
|
'cta_home' => 'Back to homepage',
|
||||||
|
'cta_packages' => 'Explore packages',
|
||||||
|
'cta_contact' => 'Get in touch',
|
||||||
|
'requested_path_label' => 'Requested path',
|
||||||
|
],
|
||||||
'success' => [
|
'success' => [
|
||||||
'title' => 'Success',
|
'title' => 'Success',
|
||||||
'verify_email' => 'Verify Email',
|
'verify_email' => 'Verify Email',
|
||||||
|
|||||||
88
resources/views/errors/404.blade.php
Normal file
88
resources/views/errors/404.blade.php
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{ __('marketing.not_found.title', [], app()->getLocale()) }} · Fotospiel</title>
|
||||||
|
@vite(['resources/css/app.css'])
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-gradient-to-br from-slate-900 via-gray-900 to-black text-white antialiased">
|
||||||
|
@php
|
||||||
|
$tips = trans('marketing.not_found.tips');
|
||||||
|
if (! is_array($tips)) {
|
||||||
|
$tips = [
|
||||||
|
__('Prüfe die URL auf Tippfehler.', [], 'de'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<main class="relative mx-auto flex min-h-screen max-w-5xl flex-col items-center justify-center px-6 py-16 text-center">
|
||||||
|
<div class="pointer-events-none absolute inset-0">
|
||||||
|
<div class="absolute -left-32 -top-32 h-96 w-96 rounded-full bg-pink-500/30 blur-3xl"></div>
|
||||||
|
<div class="absolute right-0 top-1/3 h-80 w-80 rounded-full bg-purple-500/20 blur-3xl"></div>
|
||||||
|
<div class="absolute bottom-0 left-1/2 h-72 w-72 -translate-x-1/2 rounded-full bg-blue-500/20 blur-3xl"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="relative z-10 w-full space-y-8 rounded-3xl border border-white/10 bg-white/5 p-10 shadow-2xl shadow-black/40 backdrop-blur">
|
||||||
|
<div class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-4 py-2 text-xs font-semibold uppercase tracking-widest text-pink-200 shadow-lg shadow-pink-500/30">
|
||||||
|
404 · {{ __('marketing.not_found.title') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="text-balance text-4xl font-bold leading-tight text-white sm:text-5xl md:text-6xl">
|
||||||
|
{{ __('marketing.not_found.subtitle') }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
@if(request()->path())
|
||||||
|
<p class="text-sm text-white/60">
|
||||||
|
{{ __('marketing.not_found.requested_path_label') }}:
|
||||||
|
<span class="font-mono">{{ request()->getRequestUri() }}</span>
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<p class="mx-auto max-w-2xl text-lg text-white/70 sm:text-xl">
|
||||||
|
{{ __('marketing.not_found.description') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6 rounded-3xl border border-white/10 bg-white/10 p-8 text-left shadow-lg shadow-black/30 backdrop-blur md:flex-row md:gap-8">
|
||||||
|
<div class="md:w-1/3">
|
||||||
|
<h2 class="text-lg font-semibold uppercase tracking-widest text-pink-200">
|
||||||
|
{{ __('marketing.not_found.tip_heading') }}
|
||||||
|
</h2>
|
||||||
|
<div class="mt-4 h-1 w-16 rounded-full bg-pink-400"></div>
|
||||||
|
</div>
|
||||||
|
<ul class="space-y-3 text-base text-white/80 md:w-2/3">
|
||||||
|
@foreach($tips as $index => $tip)
|
||||||
|
<li class="flex items-start gap-3">
|
||||||
|
<span class="mt-1 flex h-6 w-6 flex-none items-center justify-center rounded-full bg-white/10 text-xs font-semibold text-pink-200">
|
||||||
|
{{ $index + 1 }}
|
||||||
|
</span>
|
||||||
|
<span>{{ $tip }}</span>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:justify-center">
|
||||||
|
<a
|
||||||
|
href="{{ route('marketing.home', ['locale' => app()->getLocale()]) }}"
|
||||||
|
class="inline-flex items-center justify-center rounded-full bg-white px-6 py-3 font-medium text-gray-900 shadow-lg shadow-pink-500/30 transition hover:-translate-y-1 hover:bg-pink-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
|
||||||
|
>
|
||||||
|
{{ __('marketing.not_found.cta_home') }}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="{{ route('packages', ['locale' => app()->getLocale()]) }}"
|
||||||
|
class="inline-flex items-center justify-center rounded-full border border-white/40 px-6 py-3 font-medium text-white transition hover:-translate-y-1 hover:border-white hover:bg-white/10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
|
||||||
|
>
|
||||||
|
{{ __('marketing.not_found.cta_packages') }}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="{{ app()->getLocale() === 'en' ? route('marketing.contact', ['locale' => app()->getLocale()]) : route('kontakt', ['locale' => app()->getLocale()]) }}"
|
||||||
|
class="inline-flex items-center justify-center rounded-full border border-transparent bg-gradient-to-r from-pink-500 to-purple-500 px-6 py-3 font-medium text-white shadow-lg shadow-pink-500/30 transition hover:-translate-y-1 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
|
||||||
|
>
|
||||||
|
{{ __('marketing.not_found.cta_contact') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<h2 class="text-xl font-semibold mb-2">{{ __('legal.payments') }}</h2>
|
<h2 class="text-xl font-semibold mb-2">{{ __('legal.payments') }}</h2>
|
||||||
<p class="mb-4">{{ __('legal.payments_desc') }} <a href="https://stripe.com/de/privacy" target="_blank" rel="noopener noreferrer">{{ __('legal.stripe_privacy') }}</a> {{ __('legal.and') }} <a href="https://www.paypal.com/de/webapps/mpp/ua/privacy-full" target="_blank" rel="noopener noreferrer">{{ __('legal.paypal_privacy') }}</a>.</p>
|
<p class="mb-4">{{ __('legal.payments_desc') }} <a href="https://stripe.com/de/privacy" target="_blank" rel="noopener noreferrer">{{ __('legal.stripe_privacy') }}</a> {{ __('legal.and') }} <a href="https://www.paypal.com/de/webapps/mpp/ua/privacy-full" target="_blank" rel="noopener noreferrer">{{ __('legal.paypal_privacy') }}</a>.</p>
|
||||||
<p class="mb-4">{{ __('legal.data_retention') }}</p>
|
<p class="mb-4">{{ __('legal.data_retention') }}</p>
|
||||||
<p class="mb-4">{{ __('legal.rights') }} <a href="{{ route('kontakt') }}">{{ __('legal.contact') }}</a>.</p>
|
<p class="mb-4">{{ __('legal.rights') }} <a href="{{ route('kontakt', ['locale' => app()->getLocale()]) }}">{{ __('legal.contact') }}</a>.</p>
|
||||||
<p class="mb-4">{{ __('legal.cookies') }}</p>
|
<p class="mb-4">{{ __('legal.cookies') }}</p>
|
||||||
|
|
||||||
<h2 class="text-xl font-semibold mb-2">{{ __('legal.personal_data') }}</h2>
|
<h2 class="text-xl font-semibold mb-2">{{ __('legal.personal_data') }}</h2>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<h2 class="text-xl font-semibold mb-2">{{ __('legal.payments') }}</h2>
|
<h2 class="text-xl font-semibold mb-2">{{ __('legal.payments') }}</h2>
|
||||||
<p class="mb-4">{{ __('legal.payments_desc') }} <a href="https://stripe.com/de/privacy" target="_blank">{{ __('legal.stripe_privacy') }}</a> {{ __('legal.and') }} <a href="https://www.paypal.com/de/webapps/mpp/ua/privacy-full" target="_blank">{{ __('legal.paypal_privacy') }}</a>.</p>
|
<p class="mb-4">{{ __('legal.payments_desc') }} <a href="https://stripe.com/de/privacy" target="_blank">{{ __('legal.stripe_privacy') }}</a> {{ __('legal.and') }} <a href="https://www.paypal.com/de/webapps/mpp/ua/privacy-full" target="_blank">{{ __('legal.paypal_privacy') }}</a>.</p>
|
||||||
<p class="mb-4">{{ __('legal.data_retention') }}</p>
|
<p class="mb-4">{{ __('legal.data_retention') }}</p>
|
||||||
<p class="mb-4">{{ __('legal.rights') }} <a href="{{ route('kontakt') }}">{{ __('legal.contact') }}</a>.</p>
|
<p class="mb-4">{{ __('legal.rights') }} <a href="{{ route('kontakt', ['locale' => app()->getLocale()]) }}">{{ __('legal.contact') }}</a>.</p>
|
||||||
<p class="mb-4">{{ __('legal.cookies') }}</p>
|
<p class="mb-4">{{ __('legal.cookies') }}</p>
|
||||||
|
|
||||||
<h2 class="text-xl font-semibold mb-2">{{ __('legal.personal_data') }}</h2>
|
<h2 class="text-xl font-semibold mb-2">{{ __('legal.personal_data') }}</h2>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
{{ __('legal.company') }}<br>
|
{{ __('legal.company') }}<br>
|
||||||
{{ __('legal.address') }}<br>
|
{{ __('legal.address') }}<br>
|
||||||
{{ __('legal.representative') }}<br>
|
{{ __('legal.representative') }}<br>
|
||||||
{{ __('legal.contact') }}: <a href="{{ route('kontakt') }}">{{ __('legal.contact') }}</a>
|
{{ __('legal.contact') }}: <a href="{{ route('kontakt', ['locale' => app()->getLocale()]) }}">{{ __('legal.contact') }}</a>
|
||||||
</p>
|
</p>
|
||||||
<p class="mb-4">{{ __('legal.vat_id') }}</p>
|
<p class="mb-4">{{ __('legal.vat_id') }}</p>
|
||||||
<h2 class="text-xl font-semibold mb-2">{{ __('legal.monetization') }}</h2>
|
<h2 class="text-xl font-semibold mb-2">{{ __('legal.monetization') }}</h2>
|
||||||
|
|||||||
@@ -92,7 +92,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<p class="text-center">{{ __('marketing.occasions.not_found') }} <a href="{{ route('marketing') }}">{{ __('nav.home') }}</a>.</p>
|
<p class="text-center">{{ __('marketing.occasions.not_found') }} <a href="{{ route('marketing.home', ['locale' => app()->getLocale()]) }}">{{ __('nav.home') }}</a>.</p>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
<header class="bg-white shadow-md sticky top-0 z-50">
|
<header class="bg-white shadow-md sticky top-0 z-50">
|
||||||
<div class="container mx-auto px-4 py-4 flex items-center justify-between">
|
<div class="container mx-auto px-4 py-4 flex items-center justify-between">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<a href="{{ route('marketing') }}" class="text-2xl font-bold text-gray-900">Die Fotospiel.App</a>
|
<a href="{{ route('marketing.home', ['locale' => app()->getLocale()]) }}" class="text-2xl font-bold text-gray-900">Die Fotospiel.App</a>
|
||||||
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"></path>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<nav class="hidden md:flex space-x-6 items-center">
|
<nav class="hidden md:flex space-x-6 items-center">
|
||||||
<a href="{{ route('marketing') }}#how-it-works" class="text-gray-600 hover:text-gray-900">{{ __('marketing.nav.how_it_works') }}</a>
|
<a href="{{ route('marketing.home', ['locale' => app()->getLocale()]) }}#how-it-works" class="text-gray-600 hover:text-gray-900">{{ __('marketing.nav.how_it_works') }}</a>
|
||||||
<a href="{{ route('marketing') }}#features" class="text-gray-600 hover:text-gray-900">{{ __('marketing.nav.features') }}</a>
|
<a href="{{ route('marketing.home', ['locale' => app()->getLocale()]) }}#features" class="text-gray-600 hover:text-gray-900">{{ __('marketing.nav.features') }}</a>
|
||||||
<div x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false" @click.away="open = false" class="relative">
|
<div x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false" @click.away="open = false" class="relative">
|
||||||
<button class="text-gray-600 hover:text-gray-900" @click.stop="open = !open">{{ __('marketing.nav.occasions') }}</button>
|
<button class="text-gray-600 hover:text-gray-900" @click.stop="open = !open">{{ __('marketing.nav.occasions') }}</button>
|
||||||
<div x-show="open" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100" x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100 transform scale-100" x-transition:leave-end="opacity-0 transform scale-95" class="absolute top-full left-0 mt-2 bg-white border rounded shadow-lg z-10">
|
<div x-show="open" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100" x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100 transform scale-100" x-transition:leave-end="opacity-0 transform scale-95" class="absolute top-full left-0 mt-2 bg-white border rounded shadow-lg z-10">
|
||||||
<a href="{{ route('anlaesse.type', ['type' => 'hochzeit']) }}" class="block px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition">{{ __('marketing.nav.occasions_types.weddings') }}</a>
|
<a href="{{ route('anlaesse.type', ['locale' => app()->getLocale(), 'type' => 'hochzeit']) }}" class="block px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition">{{ __('marketing.nav.occasions_types.weddings') }}</a>
|
||||||
<a href="{{ route('anlaesse.type', ['type' => 'geburtstag']) }}" class="block px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition">{{ __('marketing.nav.occasions_types.birthdays') }}</a>
|
<a href="{{ route('anlaesse.type', ['locale' => app()->getLocale(), 'type' => 'geburtstag']) }}" class="block px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition">{{ __('marketing.nav.occasions_types.birthdays') }}</a>
|
||||||
<a href="{{ route('anlaesse.type', ['type' => 'firmenevent']) }}" class="block px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition">{{ __('marketing.nav.occasions_types.corporate') }}</a>
|
<a href="{{ route('anlaesse.type', ['locale' => app()->getLocale(), 'type' => 'firmenevent']) }}" class="block px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition">{{ __('marketing.nav.occasions_types.corporate') }}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a href="{{ route('blog') }}" class="text-gray-600 hover:text-gray-900">{{ __('marketing.nav.blog') }}</a>
|
<a href="{{ route('blog', ['locale' => app()->getLocale()]) }}" class="text-gray-600 hover:text-gray-900">{{ __('marketing.nav.blog') }}</a>
|
||||||
<a href="{{ route('packages') }}" class="text-gray-600 hover:text-gray-900">{{ __('marketing.nav.packages') }}</a>
|
<a href="{{ route('packages', ['locale' => app()->getLocale()]) }}" class="text-gray-600 hover:text-gray-900">{{ __('marketing.nav.packages') }}</a>
|
||||||
<a href="{{ route('kontakt') }}" class="text-gray-600 hover:text-gray-900">{{ __('marketing.nav.contact') }}</a>
|
<a href="{{ route('kontakt', ['locale' => app()->getLocale()]) }}" class="text-gray-600 hover:text-gray-900">{{ __('marketing.nav.contact') }}</a>
|
||||||
<a href="{{ route('packages') }}" class="bg-[#FFB6C1] text-white px-6 py-2 rounded-full font-semibold hover:bg-[#FF69B4] transition">{{ __('marketing.nav.discover_packages') }}</a>
|
<a href="{{ route('packages', ['locale' => app()->getLocale()]) }}" class="bg-[#FFB6C1] text-white px-6 py-2 rounded-full font-semibold hover:bg-[#FF69B4] transition">{{ __('marketing.nav.discover_packages') }}</a>
|
||||||
</nav>
|
</nav>
|
||||||
<!-- Mobile Menu Placeholder (Hamburger) -->
|
<!-- Mobile Menu Placeholder (Hamburger) -->
|
||||||
<button class="md:hidden text-gray-600">☰</button>
|
<button class="md:hidden text-gray-600">☰</button>
|
||||||
|
|||||||
252
routes/web.php
252
routes/web.php
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Auth\AuthenticatedSessionController;
|
||||||
|
use App\Http\Controllers\Auth\RegisteredUserController;
|
||||||
use App\Http\Controllers\CheckoutController;
|
use App\Http\Controllers\CheckoutController;
|
||||||
use App\Http\Controllers\CheckoutGoogleController;
|
use App\Http\Controllers\CheckoutGoogleController;
|
||||||
use App\Http\Controllers\LegalPageController;
|
use App\Http\Controllers\LegalPageController;
|
||||||
@@ -9,41 +11,225 @@ use App\Http\Controllers\PaddleCheckoutController;
|
|||||||
use App\Http\Controllers\PaddleWebhookController;
|
use App\Http\Controllers\PaddleWebhookController;
|
||||||
use App\Http\Controllers\Tenant\EventPhotoArchiveController;
|
use App\Http\Controllers\Tenant\EventPhotoArchiveController;
|
||||||
use App\Models\Package;
|
use App\Models\Package;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
|
||||||
require __DIR__.'/auth.php';
|
require __DIR__.'/auth.php';
|
||||||
require __DIR__.'/settings.php';
|
require __DIR__.'/settings.php';
|
||||||
|
|
||||||
Route::get('/', [MarketingController::class, 'index'])->name('home');
|
$configuredLocales = array_filter(array_map(
|
||||||
|
static fn ($value) => trim((string) $value),
|
||||||
|
explode(',', (string) env('APP_SUPPORTED_LOCALES', ''))
|
||||||
|
));
|
||||||
|
|
||||||
|
if (empty($configuredLocales)) {
|
||||||
|
$configuredLocales = array_filter([
|
||||||
|
config('app.locale'),
|
||||||
|
config('app.fallback_locale'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$supportedLocales = array_values(array_unique($configuredLocales ?: ['de', 'en']));
|
||||||
|
$localePattern = implode('|', $supportedLocales);
|
||||||
|
|
||||||
|
$rewritePath = static function (string $path, string $locale): string {
|
||||||
|
$normalized = '/'.ltrim($path, '/');
|
||||||
|
if ($normalized === '//') {
|
||||||
|
$normalized = '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
$map = [
|
||||||
|
'/kontakt' => ['en' => '/contact'],
|
||||||
|
'/contact' => ['de' => '/kontakt'],
|
||||||
|
'/so-funktionierts' => ['en' => '/how-it-works'],
|
||||||
|
'/how-it-works' => ['de' => '/so-funktionierts'],
|
||||||
|
'/anlaesse' => ['en' => '/occasions'],
|
||||||
|
'/anlaesse/hochzeit' => ['en' => '/occasions/wedding'],
|
||||||
|
'/anlaesse/geburtstag' => ['en' => '/occasions/birthday'],
|
||||||
|
'/anlaesse/firmenevent' => ['en' => '/occasions/corporate-event'],
|
||||||
|
'/anlaesse/konfirmation' => ['en' => '/occasions/confirmation'],
|
||||||
|
'/occasions/wedding' => ['de' => '/anlaesse/hochzeit'],
|
||||||
|
'/occasions/birthday' => ['de' => '/anlaesse/geburtstag'],
|
||||||
|
'/occasions/corporate-event' => ['de' => '/anlaesse/firmenevent'],
|
||||||
|
'/occasions/confirmation' => ['de' => '/anlaesse/konfirmation'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$rewrites = $map[$normalized] ?? [];
|
||||||
|
|
||||||
|
return $rewrites[$locale] ?? $normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
$determinePreferredLocale = static function (Request $request) use ($supportedLocales): string {
|
||||||
|
$sessionLocale = $request->session()->get('preferred_locale');
|
||||||
|
if ($sessionLocale && in_array($sessionLocale, $supportedLocales, true)) {
|
||||||
|
return $sessionLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
$headerLocale = $request->getPreferredLanguage($supportedLocales);
|
||||||
|
if ($headerLocale && in_array($headerLocale, $supportedLocales, true)) {
|
||||||
|
return $headerLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $supportedLocales[0] ?? 'de';
|
||||||
|
};
|
||||||
|
|
||||||
|
Route::prefix('{locale}')
|
||||||
|
->where(['locale' => $localePattern])
|
||||||
|
->middleware([
|
||||||
|
\App\Http\Middleware\SetLocaleFromRequest::class,
|
||||||
|
])
|
||||||
|
->group(function () {
|
||||||
|
Route::get('/', [MarketingController::class, 'index'])->name('marketing.home');
|
||||||
|
|
||||||
|
Route::middleware('guest')->group(function () {
|
||||||
|
Route::get('/login', [AuthenticatedSessionController::class, 'create']);
|
||||||
|
Route::post('/login', [AuthenticatedSessionController::class, 'store']);
|
||||||
|
Route::get('/register', [RegisteredUserController::class, 'create']);
|
||||||
|
Route::post('/register', [RegisteredUserController::class, 'store']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::get('/contact', [MarketingController::class, 'contactView'])
|
||||||
|
->name('marketing.contact');
|
||||||
|
Route::post('/contact', [MarketingController::class, 'contact'])
|
||||||
|
->name('marketing.contact.submit');
|
||||||
|
|
||||||
|
Route::get('/kontakt', [MarketingController::class, 'contactView'])
|
||||||
|
->name('kontakt');
|
||||||
|
Route::post('/kontakt', [MarketingController::class, 'contact'])
|
||||||
|
->name('kontakt.submit');
|
||||||
|
|
||||||
|
Route::get('/blog', [MarketingController::class, 'blogIndex'])->name('blog');
|
||||||
|
Route::get('/blog/{slug}', [MarketingController::class, 'blogShow'])->name('blog.show');
|
||||||
|
|
||||||
|
Route::get('/packages', [MarketingController::class, 'packagesIndex'])->name('packages');
|
||||||
|
|
||||||
|
Route::get('/anlaesse/{type}', [MarketingController::class, 'occasionsType'])->name('anlaesse.type');
|
||||||
|
Route::get('/occasions/{type}', [MarketingController::class, 'occasionsType'])
|
||||||
|
->name('occasions.type');
|
||||||
|
|
||||||
|
Route::get('/so-funktionierts', [MarketingController::class, 'howItWorks'])
|
||||||
|
->where('locale', 'de');
|
||||||
|
Route::get('/how-it-works', [MarketingController::class, 'howItWorks'])
|
||||||
|
->where('locale', 'en')
|
||||||
|
->name('marketing.how-it-works');
|
||||||
|
|
||||||
|
Route::get('/demo', [MarketingController::class, 'demo'])->name('demo');
|
||||||
|
Route::get('/success/{packageId?}', [MarketingController::class, 'success'])->name('marketing.success');
|
||||||
|
|
||||||
|
Route::get('/impressum', [LegalPageController::class, 'show'])
|
||||||
|
->defaults('slug', 'impressum')
|
||||||
|
->name('impressum');
|
||||||
|
Route::get('/datenschutz', [LegalPageController::class, 'show'])
|
||||||
|
->defaults('slug', 'datenschutz')
|
||||||
|
->name('datenschutz');
|
||||||
|
Route::get('/agb', [LegalPageController::class, 'show'])
|
||||||
|
->defaults('slug', 'agb')
|
||||||
|
->name('agb');
|
||||||
|
|
||||||
|
Route::get('/buy/{packageId}', [MarketingController::class, 'buyPackages'])
|
||||||
|
->name('buy.packages')
|
||||||
|
->defaults('locale', config('app.locale', 'de'));
|
||||||
|
|
||||||
|
Route::fallback(function () {
|
||||||
|
return Inertia::render('marketing/NotFound', [
|
||||||
|
'requestedPath' => request()->path(),
|
||||||
|
])->toResponse(request())->setStatusCode(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::get('/', function (Request $request) use ($determinePreferredLocale) {
|
||||||
|
$locale = $determinePreferredLocale($request);
|
||||||
|
|
||||||
|
return redirect("/{$locale}", 302);
|
||||||
|
})->name('home');
|
||||||
|
|
||||||
|
Route::get('/contact', function (Request $request) use ($determinePreferredLocale, $rewritePath) {
|
||||||
|
$locale = $determinePreferredLocale($request);
|
||||||
|
$path = $rewritePath('/contact', $locale);
|
||||||
|
|
||||||
|
return redirect("/{$locale}".($path === '/' ? '' : $path), 301);
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::get('/kontakt', function (Request $request) use ($determinePreferredLocale, $rewritePath) {
|
||||||
|
$locale = $determinePreferredLocale($request);
|
||||||
|
$path = $rewritePath('/kontakt', $locale);
|
||||||
|
|
||||||
|
return redirect("/{$locale}".($path === '/' ? '' : $path), 301);
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::get('/so-funktionierts', function (Request $request) use ($determinePreferredLocale, $rewritePath) {
|
||||||
|
$locale = $determinePreferredLocale($request);
|
||||||
|
$path = $rewritePath('/so-funktionierts', $locale);
|
||||||
|
|
||||||
|
return redirect("/{$locale}{$path}", 301);
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::get('/how-it-works', function (Request $request) use ($determinePreferredLocale, $rewritePath) {
|
||||||
|
$locale = $determinePreferredLocale($request);
|
||||||
|
$path = $rewritePath('/how-it-works', $locale);
|
||||||
|
|
||||||
|
return redirect("/{$locale}{$path}", 301);
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::get('/blog', function (Request $request) use ($determinePreferredLocale) {
|
||||||
|
$locale = $determinePreferredLocale($request);
|
||||||
|
|
||||||
|
return redirect("/{$locale}/blog", 301);
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::get('/blog/{slug}', function (Request $request, string $slug) use ($determinePreferredLocale) {
|
||||||
|
$locale = $determinePreferredLocale($request);
|
||||||
|
|
||||||
|
return redirect("/{$locale}/blog/{$slug}", 301);
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::get('/packages', function (Request $request) use ($determinePreferredLocale) {
|
||||||
|
$locale = $determinePreferredLocale($request);
|
||||||
|
|
||||||
|
return redirect("/{$locale}/packages", 301);
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::get('/anlaesse/{type}', function (Request $request, string $type) use ($determinePreferredLocale, $rewritePath) {
|
||||||
|
$locale = $determinePreferredLocale($request);
|
||||||
|
$path = $rewritePath("/anlaesse/{$type}", $locale);
|
||||||
|
|
||||||
|
return redirect("/{$locale}{$path}", 301);
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::get('/occasions/{type}', function (Request $request, string $type) use ($determinePreferredLocale, $rewritePath) {
|
||||||
|
$locale = $determinePreferredLocale($request);
|
||||||
|
$path = $rewritePath("/occasions/{$type}", $locale);
|
||||||
|
|
||||||
|
return redirect("/{$locale}{$path}", 301);
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::get('/demo', function (Request $request) use ($determinePreferredLocale) {
|
||||||
|
$locale = $determinePreferredLocale($request);
|
||||||
|
|
||||||
|
return redirect("/{$locale}/demo", 301);
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::get('/success/{packageId?}', function (Request $request, ?int $packageId = null) use ($determinePreferredLocale) {
|
||||||
|
$locale = $determinePreferredLocale($request);
|
||||||
|
$path = "/{$locale}/success";
|
||||||
|
|
||||||
|
if ($packageId !== null) {
|
||||||
|
$path .= "/{$packageId}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect($path, 301);
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::get('/buy/{packageId}', function (Request $request, int $packageId) use ($determinePreferredLocale) {
|
||||||
|
$locale = $determinePreferredLocale($request);
|
||||||
|
|
||||||
|
return redirect("/{$locale}/buy/{$packageId}", 301);
|
||||||
|
});
|
||||||
|
|
||||||
Route::get('/dashboard', function () {
|
Route::get('/dashboard', function () {
|
||||||
return Inertia::render('dashboard');
|
return Inertia::render('dashboard');
|
||||||
})->middleware(['auth', 'verified'])->name('dashboard');
|
})->middleware(['auth', 'verified'])->name('dashboard');
|
||||||
Route::get('/contact', [MarketingController::class, 'contactView'])->name('contact');
|
|
||||||
Route::post('/contact', [MarketingController::class, 'contact'])->name('contact.submit');
|
|
||||||
|
|
||||||
// Legal pages
|
|
||||||
Route::get('/impressum', [LegalPageController::class, 'show'])
|
|
||||||
->name('impressum')
|
|
||||||
->defaults('slug', 'impressum');
|
|
||||||
Route::get('/datenschutz', [LegalPageController::class, 'show'])
|
|
||||||
->name('datenschutz')
|
|
||||||
->defaults('slug', 'datenschutz');
|
|
||||||
Route::get('/agb', [LegalPageController::class, 'show'])
|
|
||||||
->name('agb')
|
|
||||||
->defaults('slug', 'agb');
|
|
||||||
|
|
||||||
Route::get('/kontakt', function () {
|
|
||||||
return Inertia::render('marketing/Kontakt');
|
|
||||||
})->name('kontakt');
|
|
||||||
Route::post('/kontakt', [MarketingController::class, 'contact'])->name('kontakt.submit');
|
|
||||||
Route::get('/blog', [MarketingController::class, 'blogIndex'])->name('blog');
|
|
||||||
Route::get('/blog/{slug}', [MarketingController::class, 'blogShow'])->name('blog.show');
|
|
||||||
Route::get('/packages', [MarketingController::class, 'packagesIndex'])->name('packages');
|
|
||||||
Route::get('/anlaesse/{type}', [MarketingController::class, 'occasionsType'])->name('anlaesse.type');
|
|
||||||
Route::get('/so-funktionierts', [MarketingController::class, 'howItWorks'])->name('how-it-works');
|
|
||||||
Route::get('/demo', [MarketingController::class, 'demo'])->name('demo');
|
|
||||||
Route::get('/success/{packageId?}', [MarketingController::class, 'success'])->name('marketing.success');
|
|
||||||
Route::prefix('event-admin')->group(function () {
|
Route::prefix('event-admin')->group(function () {
|
||||||
$renderAdmin = fn () => view('admin');
|
$renderAdmin = fn () => view('admin');
|
||||||
|
|
||||||
@@ -60,10 +246,6 @@ Route::view('/e/{token}/{path?}', 'guest')
|
|||||||
->where('token', '.*')
|
->where('token', '.*')
|
||||||
->where('path', '.*')
|
->where('path', '.*')
|
||||||
->name('guest.event');
|
->name('guest.event');
|
||||||
Route::middleware('auth')->group(function () {
|
|
||||||
Route::get('/buy/{packageId}', [MarketingController::class, 'buyPackages'])->name('marketing.buy');
|
|
||||||
});
|
|
||||||
|
|
||||||
Route::middleware('auth')->group(function () {
|
Route::middleware('auth')->group(function () {
|
||||||
Route::get('/tenant/events/{event}/photos/archive', EventPhotoArchiveController::class)
|
Route::get('/tenant/events/{event}/photos/archive', EventPhotoArchiveController::class)
|
||||||
->name('tenant.events.photos.archive');
|
->name('tenant.events.photos.archive');
|
||||||
@@ -74,11 +256,17 @@ if (config('checkout.enabled')) {
|
|||||||
Route::get('/checkout/{package}', [CheckoutController::class, 'show'])->name('checkout.show');
|
Route::get('/checkout/{package}', [CheckoutController::class, 'show'])->name('checkout.show');
|
||||||
} else {
|
} else {
|
||||||
Route::get('/purchase-wizard/{package}', function (Package $package) {
|
Route::get('/purchase-wizard/{package}', function (Package $package) {
|
||||||
return redirect()->route('packages', ['highlight' => $package->slug]);
|
return redirect()->route('packages', [
|
||||||
|
'locale' => app()->getLocale(),
|
||||||
|
'highlight' => $package->slug,
|
||||||
|
]);
|
||||||
})->name('purchase.wizard');
|
})->name('purchase.wizard');
|
||||||
|
|
||||||
Route::get('/checkout/{package}', function (Package $package) {
|
Route::get('/checkout/{package}', function (Package $package) {
|
||||||
return redirect()->route('packages', ['highlight' => $package->slug]);
|
return redirect()->route('packages', [
|
||||||
|
'locale' => app()->getLocale(),
|
||||||
|
'highlight' => $package->slug,
|
||||||
|
]);
|
||||||
})->name('checkout.show');
|
})->name('checkout.show');
|
||||||
}
|
}
|
||||||
Route::post('/checkout/login', [CheckoutController::class, 'login'])->name('checkout.login');
|
Route::post('/checkout/login', [CheckoutController::class, 'login'])->name('checkout.login');
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ class RegistrationTest extends TestCase
|
|||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
private function assertRedirectsToDashboard($response): void
|
private function assertRedirectsToVerification($response): void
|
||||||
{
|
{
|
||||||
$expected = route('dashboard', absolute: false);
|
$expected = route('verification.notice', absolute: false);
|
||||||
$target = $response->headers->get('Location')
|
$target = $response->headers->get('Location')
|
||||||
?? $response->headers->get('X-Inertia-Location');
|
?? $response->headers->get('X-Inertia-Location');
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ class RegistrationTest extends TestCase
|
|||||||
|
|
||||||
$this->assertTrue(
|
$this->assertTrue(
|
||||||
$target === $expected || Str::endsWith($target, $expected),
|
$target === $expected || Str::endsWith($target, $expected),
|
||||||
'Registration should redirect or instruct Inertia to navigate to the dashboard.'
|
'Registration should redirect or instruct Inertia to navigate to the verification notice.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ class RegistrationTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertAuthenticated();
|
$this->assertAuthenticated();
|
||||||
$this->assertRedirectsToDashboard($response);
|
$this->assertRedirectsToVerification($response);
|
||||||
$this->assertDatabaseHas('users', ['email' => 'test@example.com']);
|
$this->assertDatabaseHas('users', ['email' => 'test@example.com']);
|
||||||
$this->assertDatabaseHas('tenants', [
|
$this->assertDatabaseHas('tenants', [
|
||||||
'user_id' => User::latest()->first()->id,
|
'user_id' => User::latest()->first()->id,
|
||||||
@@ -76,7 +76,7 @@ class RegistrationTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertAuthenticated();
|
$this->assertAuthenticated();
|
||||||
$this->assertRedirectsToDashboard($response);
|
$this->assertRedirectsToVerification($response);
|
||||||
|
|
||||||
$user = User::latest()->first();
|
$user = User::latest()->first();
|
||||||
$tenant = Tenant::where('user_id', $user->id)->first();
|
$tenant = Tenant::where('user_id', $user->id)->first();
|
||||||
@@ -116,7 +116,10 @@ class RegistrationTest extends TestCase
|
|||||||
'package_id' => $paidPackage->id,
|
'package_id' => $paidPackage->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response->assertRedirect(route('marketing.buy', $paidPackage->id));
|
$response->assertRedirect(route('buy.packages', [
|
||||||
|
'locale' => 'de',
|
||||||
|
'packageId' => $paidPackage->id,
|
||||||
|
]));
|
||||||
$this->assertDatabaseHas('users', ['email' => 'paid@example.com']);
|
$this->assertDatabaseHas('users', ['email' => 'paid@example.com']);
|
||||||
$this->assertDatabaseMissing('tenant_packages', ['package_id' => $paidPackage->id]);
|
$this->assertDatabaseMissing('tenant_packages', ['package_id' => $paidPackage->id]);
|
||||||
}
|
}
|
||||||
@@ -157,7 +160,7 @@ class RegistrationTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertAuthenticated();
|
$this->assertAuthenticated();
|
||||||
$this->assertRedirectsToDashboard($response);
|
$this->assertRedirectsToVerification($response);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_registration_fails_with_short_password(): void
|
public function test_registration_fails_with_short_password(): void
|
||||||
|
|||||||
@@ -131,7 +131,10 @@ class FullUserFlowTest extends TestCase
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Finaler Redirect zu Success oder Dashboard
|
// Finaler Redirect zu Success oder Dashboard
|
||||||
$successResponse = $this->actingAs($user)->get(route('marketing.success', $paidPackage->id));
|
$successResponse = $this->actingAs($user)->get(route('marketing.success', [
|
||||||
|
'locale' => 'de',
|
||||||
|
'packageId' => $paidPackage->id,
|
||||||
|
]));
|
||||||
$successResponse->assertRedirect('/event-admin');
|
$successResponse->assertRedirect('/event-admin');
|
||||||
$successResponse->assertStatus(302);
|
$successResponse->assertStatus(302);
|
||||||
}
|
}
|
||||||
@@ -181,7 +184,10 @@ class FullUserFlowTest extends TestCase
|
|||||||
|
|
||||||
// Schritt 3: Bestellung ohne Auth blockiert
|
// Schritt 3: Bestellung ohne Auth blockiert
|
||||||
$package = Package::factory()->create(['price' => 10]);
|
$package = Package::factory()->create(['price' => 10]);
|
||||||
$buyResponse = $this->get(route('buy.packages', $package->id));
|
$buyResponse = $this->get(route('buy.packages', [
|
||||||
|
'locale' => 'de',
|
||||||
|
'packageId' => $package->id,
|
||||||
|
]));
|
||||||
$buyResponse->assertRedirect(route('register', ['package_id' => $package->id]));
|
$buyResponse->assertRedirect(route('register', ['package_id' => $package->id]));
|
||||||
|
|
||||||
// Nach Korrektur: Erfolgreicher Flow (kurz)
|
// Nach Korrektur: Erfolgreicher Flow (kurz)
|
||||||
|
|||||||
73
tests/Feature/MarketingLocaleRoutingTest.php
Normal file
73
tests/Feature/MarketingLocaleRoutingTest.php
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class MarketingLocaleRoutingTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_contact_page_is_accessible_in_german(): void
|
||||||
|
{
|
||||||
|
$response = $this->get('/de/kontakt');
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_contact_page_is_accessible_in_english(): void
|
||||||
|
{
|
||||||
|
$response = $this->get('/en/contact');
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_german_contact_slug_redirects_to_english_variant_when_locale_is_english(): void
|
||||||
|
{
|
||||||
|
$response = $this->get('/en/kontakt');
|
||||||
|
|
||||||
|
$response->assertRedirect(route('marketing.contact', [
|
||||||
|
'locale' => 'en',
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_english_contact_slug_redirects_to_german_variant_when_locale_is_german(): void
|
||||||
|
{
|
||||||
|
$response = $this->get('/de/contact');
|
||||||
|
|
||||||
|
$response->assertRedirect(route('kontakt', [
|
||||||
|
'locale' => 'de',
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_occasion_canonical_slugs_resolve_for_both_locales(): void
|
||||||
|
{
|
||||||
|
$this->get('/de/anlaesse/hochzeit')->assertStatus(200);
|
||||||
|
$this->get('/en/occasions/wedding')->assertStatus(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_english_locale_redirects_from_german_occasions_slug(): void
|
||||||
|
{
|
||||||
|
$response = $this->get('/en/anlaesse/hochzeit');
|
||||||
|
|
||||||
|
$response->assertRedirect(route('occasions.type', [
|
||||||
|
'locale' => 'en',
|
||||||
|
'type' => 'wedding',
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_german_locale_redirects_from_english_occasions_slug(): void
|
||||||
|
{
|
||||||
|
$response = $this->get('/de/occasions/wedding');
|
||||||
|
|
||||||
|
$response->assertRedirect(route('anlaesse.type', [
|
||||||
|
'locale' => 'de',
|
||||||
|
'type' => 'hochzeit',
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_legal_pages_render_for_german_locale(): void
|
||||||
|
{
|
||||||
|
$this->get('/de/impressum')->assertStatus(200);
|
||||||
|
$this->get('/de/datenschutz')->assertStatus(200);
|
||||||
|
$this->get('/de/agb')->assertStatus(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ class RegistrationTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$location = $this->captureLocation($response);
|
$location = $this->captureLocation($response);
|
||||||
$expected = route('dashboard', absolute: false);
|
$expected = route('verification.notice', absolute: false);
|
||||||
|
|
||||||
$this->assertNotEmpty($location);
|
$this->assertNotEmpty($location);
|
||||||
$this->assertTrue($location === $expected || Str::endsWith($location, $expected));
|
$this->assertTrue($location === $expected || Str::endsWith($location, $expected));
|
||||||
@@ -83,7 +83,7 @@ class RegistrationTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$location = $this->captureLocation($response);
|
$location = $this->captureLocation($response);
|
||||||
$expected = route('dashboard', absolute: false);
|
$expected = route('verification.notice', absolute: false);
|
||||||
|
|
||||||
$this->assertNotEmpty($location);
|
$this->assertNotEmpty($location);
|
||||||
$this->assertTrue($location === $expected || Str::endsWith($location, $expected));
|
$this->assertTrue($location === $expected || Str::endsWith($location, $expected));
|
||||||
@@ -136,7 +136,10 @@ class RegistrationTest extends TestCase
|
|||||||
'package_id' => $paidPackage->id,
|
'package_id' => $paidPackage->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response->assertRedirect(route('marketing.buy', $paidPackage->id));
|
$response->assertRedirect(route('buy.packages', [
|
||||||
|
'locale' => 'de',
|
||||||
|
'packageId' => $paidPackage->id,
|
||||||
|
]));
|
||||||
|
|
||||||
$this->assertDatabaseHas('users', [
|
$this->assertDatabaseHas('users', [
|
||||||
'username' => 'paiduser',
|
'username' => 'paiduser',
|
||||||
@@ -164,7 +167,7 @@ class RegistrationTest extends TestCase
|
|||||||
'package_id' => $freePackage->id,
|
'package_id' => $freePackage->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Mail::assertQueued(Welcome::class, function ($mail) {
|
Mail::assertSent(Welcome::class, function ($mail) {
|
||||||
return $mail->hasTo('test3@example.com');
|
return $mail->hasTo('test3@example.com');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user